Compare commits

..

63 Commits

Author SHA1 Message Date
790db95bda feat: back system notification bell with database 2026-07-06 01:46:14 +05:30
Sai-karthik
9062ce9f22 PRM-88 add CareerSprint report KPI backend 2026-07-05 11:43:08 +00:00
Sai-karthik
d252077135 Expose static curator day outcomes 2026-07-04 12:22:29 +00:00
Sai-karthik
5606f987c5 Serve static curator reads without actor startup 2026-07-02 20:19:57 +00:00
Sai-karthik
4353e8fe33 Align curator sprint with static task docs 2026-07-02 19:54:50 +00:00
Sai-karthik
f826b7b9a3 Remove legacy curator ICP shim 2026-07-02 18:03:14 +00:00
Sai-karthik
6432734f74 Split curator ICP and task registries 2026-07-02 17:59:07 +00:00
Sai-karthik
0394dfbca0 Verify static curator handoff registry 2026-07-02 13:18:49 +00:00
Sai-karthik
837a36276a Make curator missions static by ICP sprint 2026-07-02 12:57:38 +00:00
Sai-karthik
a43b7dd9dd Align recovery curator CTA with roleplay 2026-07-01 13:16:36 +00:00
Sai-karthik
4ec3f57210 Clean resume proof curator copy 2026-07-01 12:37:02 +00:00
Sai-karthik
9df22e01e7 Restrict curator streak tasks to active services 2026-07-01 12:27:31 +00:00
Sai-karthik
35052a0ced Merge remote-tracking branch 'origin/fix/curator-streak-agent-mapping' into staging 2026-07-01 11:16:09 +00:00
e1bb3b2b11 fix: map curator tasks to service agents 2026-07-01 16:32:06 +05:30
Sai-karthik
ac66abec28 Merge PR #13: passive cross-service actions 2026-07-01 10:29:09 +00:00
Sai-karthik
7d74f81913 Recover deep-linked curator starts 2026-06-30 15:46:15 +00:00
Sai-karthik
3bba99e874 Recover completed deep-linked curator tasks 2026-06-30 13:31:30 +00:00
Sai-karthik
697040e9d3 Complete deep-linked matchmaking tasks 2026-06-30 13:23:44 +00:00
Sai-karthik
39ba59ab25 Cap home mission cards 2026-06-29 18:44:28 +00:00
Sai-karthik
10478fb035 Fix staging home mission service routing 2026-06-29 18:38:03 +00:00
a6b0cf3a00 feat: add passive cross-service actions 2026-06-29 23:18:45 +05:30
Sai-karthik
dfc45fbea2 Merge PR #13: passive mission lifecycle 2026-06-29 17:19:43 +00:00
210b577462 feat: add passive mission lifecycle 2026-06-29 22:15:59 +05:30
Sai-karthik
60332956a0 Merge PRM-80 backend canonical service event contract 2026-06-29 11:37:22 +00:00
Sai-karthik
1b90e5db39 fix: inline service registry event contracts 2026-06-28 08:27:04 +00:00
Sai-karthik
52589fc76d Merge remote-tracking branch 'origin/pr/13' into karthiksaiketha/prm-80-backend-canonical-service-event-contract-for-streaks-qscore 2026-06-28 08:16:30 +00:00
685f2dcd24 feat: implement onboarding ledger event handling and related tests 2026-06-28 04:03:39 +05:30
Sai-karthik
3329eeb2fd Merge PR #12: PRM-80 canonical service events 2026-06-25 12:55:07 +00:00
Sai-karthik
760103f838 fix: harden PRM-80 audit requirements 2026-06-25 12:54:54 +00:00
Sai-karthik
592bbf0f57 docs: add PRM-80 final PR audit 2026-06-25 12:24:50 +00:00
-Puter
57b31d58cc Harden conversation bootstrap on staging 2026-06-25 17:49:39 +05:30
Sai-karthik
e13dfe7d46 fix: keep qscore out of curator tasks 2026-06-25 12:19:39 +00:00
Sai-karthik
b895d6be79 fix: emit canonical service events for PRM-80 2026-06-25 11:35:37 +00:00
-Puter
91600e4e8c fix: make onboarding status ledger-only 2026-06-24 19:41:42 +05:30
-Puter
eaba7f95e3 fix: gate onboarding on ledger snapshot 2026-06-24 19:34:36 +05:30
-Puter
a442f1f53a fix: keep user activity analytics resilient 2026-06-24 19:11:18 +05:30
e88bc02012 Merge PR #11: Register curator actor
Register curatorActor in the Rivet registry and route Curator APIs through the actor handle.
2026-06-24 11:46:00 +00:00
sai karthik
13e82e0a52 Register curator actor 2026-06-24 16:53:27 +05:30
-Puter
750a6ab03b puter fix: service registry and curator onboaridng data fix, home feed route fixes 2026-06-24 15:30:00 +05:30
dv
1ecd964104 Merge pull request 'PRM-71 Backend QA Curator streak loop' (#10) from prm-71-backend-qa-curator-streak-loop into staging
Reviewed-on: #10
2026-06-23 21:26:55 +00:00
sai karthik
97ed70a921 Add PRM-71 backend QA evidence 2026-06-24 02:35:47 +05:30
Sai-karthik
0bfc18305b Project scored service completions into QScore 2026-06-23 21:01:10 +00:00
Sai-karthik
a83a27eb50 Recognize explicit abandoned curator service events 2026-06-23 20:45:12 +00:00
Sai-karthik
2de70d3b8c Solve PRM-71 curator backend QA loop 2026-06-23 20:38:33 +00:00
dv
b379d5b9fc Merge pull request '[PRM-63] Backend service registry issue solved' (#9) from prm-63-service-registry into staging
Reviewed-on: #9
2026-06-23 18:49:19 +00:00
dv
71f18fde9d Merge branch 'staging' into prm-63-service-registry 2026-06-23 18:49:11 +00:00
dv
dfdde7fa4d Merge pull request 'Backend: Add Curator 30-Day Streak Curation and Onboarding Loop' (#8) from backend/service-curation-layer into staging
Reviewed-on: #8
2026-06-23 18:48:19 +00:00
Sai-karthik
dbc984ed7f Keep home feed available when agent generation slows 2026-06-23 08:54:37 +00:00
Sai-karthik
4092025693 Backfill short home feed agent responses 2026-06-23 08:41:55 +00:00
Sai-karthik
29ed0a15cd Throttle user stack provisioning retries 2026-06-23 06:23:56 +00:00
Sai-karthik
7bad0a46c2 Repair missing home feed hrefs 2026-06-23 05:37:47 +00:00
Sai-karthik
f888a6fc0d Remove QScore estimate fallback 2026-06-23 05:04:55 +00:00
Sai-karthik
1cbd3e1a84 Repair missing home feed agent tags 2026-06-22 23:41:00 +00:00
Sai-karthik
bff336baa7 Add generated content quality smoke 2026-06-22 23:16:52 +00:00
Sai-karthik
cad24ea089 Keep public service catalog registry-only 2026-06-22 22:48:30 +00:00
Sai-karthik
459832a2a3 Add service registry acceptance probe 2026-06-22 22:35:06 +00:00
Sai-karthik
610975561f Harden home feed agent generation 2026-06-22 22:29:32 +00:00
Sai-karthik
a3a84faae7 Add service gateway write-flow smoke 2026-06-22 22:19:03 +00:00
Sai-karthik
d493ce8f33 Wire gateway user context through user service 2026-06-22 22:09:38 +00:00
Sai-karthik
fe62662cb6 Harden service gateway smoke coverage 2026-06-22 21:46:06 +00:00
Sai-karthik
6a77bb5d2e Enrich service preview gateway payloads 2026-06-22 21:31:58 +00:00
Sai-karthik
c48c28fdb3 Implement canonical service registry 2026-06-22 21:25:38 +00:00
17a888bd67 feat: update curator schemas to support 6-week plans and enhance user context
- Increased weekIndex max from 5 to 6 in curator task, plan day, and week schemas.
- Adjusted days array in curator week schema to allow a minimum of 1 day.
- Modified weeks array in curator plan schema to accept between 5 and 6 weeks.
- Enhanced CuratorUserContext type to include detailed user information and QScore.
- Introduced Curator ICP Playbooks for various user profiles with structured actions.
- Implemented onboarding loop for user onboarding completion and notification.
- Added prompt builder for generating structured 30-day plans based on user context and playbooks.
2026-06-22 22:24:27 +05:30
78 changed files with 7414 additions and 1365 deletions

View File

@@ -2,14 +2,15 @@ FROM node:22-alpine AS base
WORKDIR /app
FROM base AS deps
COPY package.json package-lock.json* ./
RUN npm install
RUN corepack enable && corepack prepare pnpm@10.24.0 --activate
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile
FROM base AS build
COPY --from=deps /app/node_modules ./node_modules
COPY tsconfig.json ./
COPY src ./src
RUN npx tsc -p tsconfig.json
RUN ./node_modules/.bin/tsc -p tsconfig.json
FROM base AS runtime
ARG RIVET_RUNNER_VERSION=dev

View File

@@ -105,6 +105,8 @@ services:
LLM_BASE_URL: ${LLM_BASE_URL:-https://opencode.ai/zen/v1}
LLM_MODEL: ${LLM_MODEL:-kimi-k2.6}
GROW_AGENT_MODEL: ${GROW_AGENT_MODEL:-kimi-k2.6}
HOME_FEED_AGENT_TIMEOUT_MS: ${HOME_FEED_AGENT_TIMEOUT_MS:-90000}
HOME_FEED_AGENT_ATTEMPTS: ${HOME_FEED_AGENT_ATTEMPTS:-2}
# Per-user OpenCode containers
OPENCODE_IMAGE: ${OPENCODE_IMAGE:-growqr/opencode:dev}
USER_CONTAINER_HOST: ${USER_CONTAINER_HOST:-host.docker.internal}
@@ -116,6 +118,11 @@ services:
ROLEPLAY_SERVICE_URL: ${ROLEPLAY_SERVICE_URL:-http://host.docker.internal:8008}
QSCORE_SERVICE_URL: ${QSCORE_SERVICE_URL:-http://host.docker.internal:8000}
RESUME_SERVICE_URL: ${RESUME_SERVICE_URL:-http://host.docker.internal:8002}
USER_SERVICE_URL: ${USER_SERVICE_URL:-http://host.docker.internal:8003}
COURSES_SERVICE_URL: ${COURSES_SERVICE_URL:-http://host.docker.internal:8060}
ASSESSMENT_SERVICE_URL: ${ASSESSMENT_SERVICE_URL:-http://host.docker.internal:8070}
MATCHMAKING_SERVICE_URL: ${MATCHMAKING_SERVICE_URL:-http://host.docker.internal:8006}
PATHWAYS_SERVICE_URL: ${PATHWAYS_SERVICE_URL:-http://host.docker.internal:8009}
# Frontend
FRONTEND_ORIGIN: ${FRONTEND_ORIGIN:-http://localhost:3000}
volumes:

View File

@@ -0,0 +1,121 @@
# PRM-80 PR #12 Final Audit
Date: 2026-06-25
PR: https://git.openputer.com/growqr-app/growqr-backend/pulls/12
Branch: `prm-80-canonical-events` -> `staging`
Latest verified code commit before this audit doc: `e13dfe7d468209685596385edc749e5506f9f8a2`
## Commits In PR
- `b895d6b` - `fix: emit canonical service events for PRM-80`
- `e13dfe7` - `fix: keep qscore out of curator tasks`
## Scope
This PR now covers the PRM-80 canonical Grow Events contract work plus the curator task policy update requested after QA:
- Workflow/service events are normalized into canonical PRM-80 event names before ingestion.
- Workflow bridge events carry `subject` and service identity details instead of storing `subject: null`.
- Resume, roleplay, QScore analytics/read, and matchmaking workflow paths emit or bridge their canonical service events into `grow_events`.
- Duplicate canonical service events are kept idempotent through deterministic dedupe behavior.
- Curator no longer assigns `qscore-service` as a task or handoff service.
## Curator QScore Policy Change
QScore remains available as a scoring/readiness projection service for dashboard and backend consumers. It is no longer offered as a curator task.
Files changed:
- `src/v1/curator/curator-store.ts`
- `src/v1/curator/curator-tools.ts`
Behavior added:
- Curator seed generation remaps `qscore-service` tasks to non-QScore services.
- Measurement tasks that previously pointed at QScore now point at `assessment-service`.
- Proof tasks that previously pointed at QScore now point at `resume-service`.
- Practice tasks that previously pointed at QScore now point at `interview-service`.
- Recovery tasks that previously pointed at QScore now point at `roleplay-service`.
- Curator capability listing filters out `qscore-service`.
- `prepare_qscore_review` is disabled for curator task handoffs and returns `qscore_curator_handoff_disabled`.
## Curator Services Allowed After Change
- `interview-service`
- `roleplay-service`
- `resume-service`
- `cover-letter-service`
- `courses-service`
- `assessment-service`
- `matchmaking-service`
- `social-branding-service`
Excluded from curator task assignment:
- `qscore-service`
## Runtime Verification
Backend container:
- `growqr-backend` rebuilt successfully with Docker.
- `growqr-backend` restarted and is healthy.
- Root backend health response returned `200 application/json` with `{"name":"growqr-backend","status":"ok","env":"production"}`.
Curator today API verification:
```text
task_count 3
qscore_task_count 0
measurement | assessment-service | Assessment | Check whether confidence is improving | Open assessment
proof | resume-service | Resume | Generate a cleaner role-fit artifact | Open resume workspace
practice | interview-service | Interview | Run one focused interview rep | Open interview preview
```
Curator 30-day sprint verification:
```text
planned_service_count 90
planned_qscore_count 0
task_qscore_count 0
assessment-service: 30
interview-service: 11
matchmaking-service: 11
resume-service: 21
roleplay-service: 8
social-branding-service: 9
```
Build verification:
```text
docker compose build backend
Image growqr-backend-backend Built
```
Remote branch verification:
```text
refs/heads/prm-80-canonical-events -> e13dfe7d468209685596385edc749e5506f9f8a2
```
PR page verification:
```text
https://git.openputer.com/growqr-app/growqr-backend/pulls/12
HTTP/2 200
PR title: #12 - PRM-80: Emit canonical service events for Grow Events
Latest commit visible: e13dfe7 fix: keep qscore out of curator tasks
```
## Audit Notes
- Only the intended tracked files were committed for the curator update.
- VPS has untracked `.bak.*` backup files from prior QA/debug work; these were intentionally not staged or committed.
- QScore was not removed from the service registry because dashboard scoring, backend QScore reads, and projection consumers still depend on it.
- The change is limited to curator task assignment/handoff behavior.

View File

@@ -0,0 +1,154 @@
# PRM-71 Backend QA Evidence
This file keeps the PRM-71 backend QA proof inside the backend PR. The checks below were run against the real deployed API at `https://app.sai-onchain.me/api/growqr`, not against mocks or fallback-only fixtures.
## Deployed Target
- Public backend base: `https://app.sai-onchain.me/api/growqr`
- Local backend base on VPS: `http://127.0.0.1:4000`
- Branch: `prm-71-backend-qa-curator-streak-loop`
- Runtime implementation commit verified: `0bfc18305bd2462fc7c0fcbfb2a3f5cd76df3f9d`
- PR: `https://git.openputer.com/growqr-app/growqr-backend/pulls/10`
## Service Commit SHAs
- `growqr-backend`: `0bfc18305bd2462fc7c0fcbfb2a3f5cd76df3f9d`
- `growqr-dashboard`: `c4e79d7a17767a083f19f02ba1ca4065f1d415d7`
- `interview-service`: `61b238b00463bc3a1e283bf3b850c97279d94ece`
- `roleplay-service`: `b4a4913df28c00985578e3af5f1a95e12cf4260e`
- `resume-service`: `ebcc6e0826c2e7762251080b6365ebb6b5439c93`
- `qscore-service`: `058903f9686067398640a6a56aebce0b57408ccb`
- `matchmaking-service`: `e36e831794cccb0e176df4e9113ab1957d4c3612`
- `courses-service`: `f702728247bb4e66edf4552d792d25825ceb44fe`
- `assessment-service`: `d2885ad2c83c86a95b6a8d9a46dafe5415678422`
- `pathways-service`: `b20abed9d7a5fb9c68804b986a9d46a1015d54af`
- `social-branding-service`: `98463cdcf75f720a3035c2954b2a847956df24f2`
## Health Proof
- Backend container: `growqr-backend Up ... (healthy)`
- Local backend health: `GET http://127.0.0.1:4000/healthz` returned `{"ok":true}`
- Public API health was exercised through authenticated real API calls at `https://app.sai-onchain.me/api/growqr/...`
- Gateway health passed for `interview`, `roleplay`, `resume`, and `social`
- Direct declared health paths passed for `qscore-service`, `matchmaking-service`, `courses-service`, `assessment-service`, and `pathways-service`
## Real API Evidence Users
- Full evidence flow user: `qa-prm71-full-flow-1782248569`
- Full handoff sample user: `qa-prm71-handoffs-1782248569`
- Final battle-test flow user: `qa-prm71-battle-flow-1782248509`
- Final battle-test all-complete user: `qa-prm71-battle-complete-1782248509`
## API Contract Evidence
The full evidence run captured:
- `GET /v1/curator/today?date=2026-06-23` for a fresh test seeker
- `POST /v1/curator/tasks/:taskId/handoff` samples for:
- `interview-service`
- `roleplay-service`
- `resume-service`
- `qscore-service`
- `POST /v1/events/track` sample payloads for:
- `service.started`
- `service.abandoned`
- `service.completed`
- `GET /v1/qscore/latest` before and after completion
- `GET /v1/analytics/insight-snapshot` before and after completion
- `GET /v1/analytics/activity-history` after event ingestion
The battle-test run additionally checked auth rejection, malformed event rejection, idempotent duplicate event replay, cross-user isolation, large activity-history limit clamping, all-complete Day 1 behavior, and recovery Day 2 behavior.
## Day 1 To Day 2 Replan Proof
Fresh seeker flow:
- Day 1 returned exactly 3 tasks: `measurement`, `proof`, `practice`
- A practice handoff recorded `task.opened`
- Real event payloads recorded `service.started` and `service.abandoned`
- Day 2 returned 4 tasks with a `recovery` task
- Day 1 statuses after replan included `skipped`, `skipped`, and `abandoned`
- Adaptation reason: `day 1 incomplete: 1 abandoned/partial, 2 skipped`
All-complete control flow:
- Day 1 tasks were completed with real `service.completed` events
- Duplicate completion replays returned idempotent responses
- Day 2 did not include a recovery task
- Day 1 statuses were all `completed`
## QScore And Analytics Proof
- QScore before completion: `null` / `baseline_needed`
- QScore after completion: `89` / `ready`
- Analytics roleFit before completion: `baseline_needed`
- Analytics roleFit after completion: `strong` with score `89`
- Follow-up battle test verified a scored `service.completed` event updates QScore/readiness state, closing the earlier gap where generic scored completions could process without moving QScore.
## Event Storage Proof
Database proof for the full evidence flow:
```text
curator.day.opened|pending|4
curator.onboarding_plan.ready|pending|1
curator.sprint.started|pending|1
service.abandoned|processed|1
service.completed|processed|1
service.started|processed|1
task.opened|pending|2
```
API proof was also captured through `GET /v1/analytics/activity-history`, which returned the ingested event stream for the test seeker.
## Battle-Test Checklist
Final battle-test result on the deployed real API: `23/23` checks passed.
- [x] Public health endpoint is reachable
- [x] Protected endpoint rejects missing auth
- [x] Event contract rejects missing type/action
- [x] Fresh QScore is `baseline_needed`
- [x] Fresh analytics roleFit is `baseline_needed`
- [x] Onboarding run succeeds
- [x] Day 1 returns three frontend-consumable tasks
- [x] Day 1 tasks include service routing metadata
- [x] Curator handoff succeeds
- [x] `service.started` processes
- [x] Duplicate started event is idempotent
- [x] `service.abandoned` processes
- [x] Day 2 adds recovery after abandoned Day 1
- [x] Day 1 statuses reflect skipped/abandoned work
- [x] `service.completed` processes
- [x] Duplicate completed event is idempotent
- [x] QScore updates after real completion
- [x] Analytics updates after real completion
- [x] Activity history clamps large limits
- [x] Duplicate completed event is stored only once
- [x] All-complete Day 1 has no recovery on Day 2
- [x] All-complete Day 1 statuses are completed
- [x] Payload `userId` cannot write into another user's stream
## Rollback Notes
If the deployed VPS backend must be rolled back to staging:
```bash
cd /opt/growqr/growqr-backend
git fetch origin --prune
git checkout staging
git reset --hard origin/staging
docker compose up -d --build backend
curl -fsS http://127.0.0.1:4000/healthz
```
Revert alternative from the PR branch:
```bash
git revert $(git rev-list --reverse origin/staging..HEAD)
docker compose up -d --build backend
```
## Current Formal Caveat
PRM-71's real API/backend production-slice evidence is satisfied by this PR and the deployed checks above. The Linear parent DoD also says grouped backend child issues must be merged/deployed or explicitly deferred with owner approval. At the time of this evidence pass, the PRM-71 parent has PR #10 attached and several grouped child Linear issues are still not formally marked done in Linear. This PR therefore provides the deployed PRM-71 proof, while final parent closure still depends on the owner's desired handling of those child issue statuses.

View File

@@ -0,0 +1,33 @@
CREATE TABLE IF NOT EXISTS "system_notifications" (
"id" text PRIMARY KEY DEFAULT gen_random_uuid()::text NOT NULL,
"user_id" text NOT NULL,
"kind" text NOT NULL,
"title" text NOT NULL,
"sub" text NOT NULL,
"when_label" text NOT NULL,
"href" text,
"source" text DEFAULT 'system' NOT NULL,
"source_ref" jsonb DEFAULT '{}'::jsonb NOT NULL,
"read_at" timestamp with time zone,
"occurred_at" timestamp with time zone DEFAULT now() NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "system_notifications_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE cascade ON UPDATE no action,
CONSTRAINT "system_notifications_kind_check" CHECK ("kind" IN ('session', 'billing', 'security', 'feature', 'account'))
);
CREATE INDEX IF NOT EXISTS "system_notifications_user_idx" ON "system_notifications" USING btree ("user_id","read_at","occurred_at");
CREATE INDEX IF NOT EXISTS "system_notifications_kind_idx" ON "system_notifications" USING btree ("user_id","kind","occurred_at");
CREATE TABLE IF NOT EXISTS "system_notification_preferences" (
"user_id" text NOT NULL,
"kind" text NOT NULL,
"in_app" boolean DEFAULT true NOT NULL,
"email" boolean DEFAULT true NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "system_notification_preferences_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE cascade ON UPDATE no action,
CONSTRAINT "system_notification_preferences_pk" PRIMARY KEY("user_id","kind"),
CONSTRAINT "system_notification_preferences_kind_check" CHECK ("kind" IN ('session', 'billing', 'security', 'feature', 'account'))
);
CREATE INDEX IF NOT EXISTS "system_notification_preferences_user_idx" ON "system_notification_preferences" USING btree ("user_id");

View File

@@ -85,6 +85,13 @@
"when": 1780481600000,
"tag": "0011_conversation_metadata",
"breakpoints": true
},
{
"idx": 12,
"version": "7",
"when": 1780481700000,
"tag": "0012_system_notifications",
"breakpoints": true
}
]
}

View File

@@ -6,6 +6,10 @@
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc -p tsconfig.json",
"test:onboarding": "tsx scripts/onboarding-ledger.test.ts",
"test:missions": "tsx scripts/mission-lifecycle.test.ts",
"test:passive-actions": "tsx scripts/passive-actions.test.ts",
"test:curator-static": "tsx scripts/curator-static-registry.test.ts",
"start": "node dist/index.js",
"typecheck": "tsc -p tsconfig.json --noEmit",
"workflows:smoke": "tsx src/workflows/smoke-test.ts",

View File

@@ -0,0 +1,128 @@
import { buildServiceCurationPreview } from "../src/v1/curator/curator-store.js";
import type { CuratorIcpId } from "../src/v1/curator/icp-registry.js";
const ICP_IDS: CuratorIcpId[] = [
"student_recent_grad",
"intern",
"fresher_early_professional",
"experienced_professional",
"freelancer",
"founder",
"enterprise",
];
const LIVE_ROUTE_PREFIX: Record<string, string> = {
"courses-service": "/agents/courses",
"resume-service": "/agents/resume",
"interview-service": "/agents/interview/preview",
"roleplay-service": "/agents/roleplay/builder",
"matchmaking-service": "/agents/matchmaking",
};
const EXPECTED_FOCUS: Record<CuratorIcpId, string[]> = {
student_recent_grad: ["Start Your Story", "Discover Your Strengths", "Build Credibility", "Explore Opportunities", "Learn To Grow", "Grow Your Network", "Ready For Tomorrow"],
intern: ["Own Your Journey", "Show Your Growth", "Build Credibility", "Find Better Opportunities", "Strengthen Your Skills", "Expand Your Network", "Step Into Opportunity"],
fresher_early_professional: ["Start Strong", "Build Visibility", "Show Your Potential", "Find Better Opportunities", "Grow Your Skills", "Expand Your Network", "Launch Your Career"],
experienced_professional: ["Lead With Purpose", "Build Executive Presence", "Show Strategic Value", "Expand Your Influence", "Future-Proof Leadership", "Build Strategic Relationships", "Lead The Future"],
freelancer: ["Build Your Brand", "Package Your Value", "Earn Trust", "Find More Clients", "Grow Your Business", "Expand Your Network", "Scale Forward"],
founder: ["Own Your Vision", "Validate Your Idea", "Build Credibility", "Find Your Market", "Grow Smarter", "Build Your Network", "Launch Forward"],
enterprise: ["Know Your Workforce", "Measure Your Talent", "Strengthen Capability", "Build Better Hiring", "Grow Leaders", "Activate Growth", "Future Ready Enterprise"],
};
const EXPECTED_DAY_ONE_TITLES: Record<CuratorIcpId, string[]> = {
student_recent_grad: ["Claim Your Corner", "Know Your Number", "Resume Rescue", "Pitch Perfect"],
intern: ["Claim Your Corner", "Know Your Number", "Resume Reloaded", "Pitch Perfect"],
fresher_early_professional: ["Claim Your Corner", "Know Your Number", "Resume Rescue", "Pitch Perfect"],
experienced_professional: ["Own Your Executive Brand", "Know Your Number", "Executive Resume", "Executive Introduction"],
freelancer: ["Claim Your Corner", "Know Your Number", "Portfolio Rescue", "Client Pitch"],
founder: ["Build In Public", "Know Your Number", "Founder Story", "Founder Pitch"],
enterprise: ["Shape Your Story", "Know Your Workforce", "Capability Blueprint", "Leadership Kickoff"],
};
function assert(condition: unknown, message: string): asserts condition {
if (!condition) throw new Error(message);
}
function routeFor(route: string) {
return new URL(route, "https://app.sai-onchain.me");
}
async function main() {
const startDate = "2026-07-02";
const failures: string[] = [];
for (const icpId of ICP_IDS) {
const preview = await buildServiceCurationPreview({
userId: `curator-static-registry-test-${icpId}`,
startDate,
icpId,
userContext: { targetRole: "Product Manager" },
});
try {
assert(preview.version === "icp-v7-static", `${icpId}: expected static plan version`);
assert(preview.idempotency.promptHash === "static-task-registry", `${icpId}: prompt hash should prove static registry mode`);
assert(preview.plan.days.length === 30, `${icpId}: expected 30 generated days`);
assert(preview.plan.calendarWeeks.length === 5, `${icpId}: expected 5 sprint-relative calendar weeks`);
const firstSevenFocus = preview.plan.days.slice(0, 7).map((day) => day.focus);
assert(JSON.stringify(firstSevenFocus) === JSON.stringify(EXPECTED_FOCUS[icpId]), `${icpId}: first 7 focus labels must match 7-day sprint doc`);
assert(JSON.stringify(firstSevenFocus) === JSON.stringify(preview.plan.days.slice(7, 14).map((day) => day.focus)), `${icpId}: 7-day sprint should repeat after day 7 for now`);
const dayOneTitles = preview.plan.days[0]?.tasks.map((task) => task.title);
assert(JSON.stringify(dayOneTitles) === JSON.stringify(EXPECTED_DAY_ONE_TITLES[icpId]), `${icpId}: day 1 task titles must match docs`);
for (const day of preview.plan.days) {
assert(day.generationStatus === "seeded", `${icpId} day ${day.dayIndex}: generation status must stay seeded`);
assert(!day.adaptationReason, `${icpId} day ${day.dayIndex}: adaptation reason should be absent`);
assert(day.tasks.length === 4, `${icpId} day ${day.dayIndex}: expected exactly 4 task objects`);
const taskIds = new Set(day.tasks.map((task) => task.id));
const stageIds = new Set(day.tasks.map((task) => task.stageId));
assert(taskIds.size === 4, `${icpId} day ${day.dayIndex}: task IDs must be unique`);
assert(stageIds.size === 4, `${icpId} day ${day.dayIndex}: stage IDs must be unique`);
for (const task of day.tasks) {
assert(task.missionId === "curator-sprint", `${icpId} ${task.id}: missionId mismatch`);
assert(task.missionInstanceId?.startsWith("curator-sprint:icp-v7-static:"), `${icpId} ${task.id}: missionInstanceId should use static version`);
assert(task.stageId?.includes(`${task.serviceId?.replace("-service", "")}`), `${icpId} ${task.id}: stageId should include service segment`);
assert(task.id.includes(`${task.serviceId?.replace("-service", "")}`), `${icpId} ${task.id}: taskId should include service segment`);
assert(task.actorName !== "Vera" && task.actorName !== "Kai" && task.actorName !== "Mira" && task.actorName !== "Lyra", `${icpId} ${task.id}: agent persona leaked into actorName`);
const url = routeFor(task.route);
const livePrefix = task.serviceId ? LIVE_ROUTE_PREFIX[task.serviceId] : undefined;
if (livePrefix) {
assert(url.pathname === livePrefix, `${icpId} ${task.id}: route ${url.pathname} does not match ${livePrefix}`);
} else {
assert(url.pathname === "/agents/service-unavailable", `${icpId} ${task.id}: unsupported service must use unavailable route`);
assert(url.searchParams.get("serviceId") === task.serviceId, `${icpId} ${task.id}: unavailable route must preserve serviceId`);
}
assert(url.searchParams.get("source") === "curator-v1", `${icpId} ${task.id}: route missing curator source`);
assert(url.searchParams.get("curatorTaskId") === task.id, `${icpId} ${task.id}: route curatorTaskId mismatch`);
assert(url.searchParams.get("missionId") === task.missionId, `${icpId} ${task.id}: route missionId mismatch`);
assert(url.searchParams.get("missionInstanceId") === task.missionInstanceId, `${icpId} ${task.id}: route missionInstanceId mismatch`);
assert(url.searchParams.get("stageId") === task.stageId, `${icpId} ${task.id}: route stageId mismatch`);
if (task.serviceId === "interview-service" || task.serviceId === "roleplay-service") {
assert(url.searchParams.get("role") === "Product Manager", `${icpId} ${task.id}: practice route should preserve target role`);
assert(url.searchParams.get("duration") === "5", `${icpId} ${task.id}: practice route should carry duration`);
}
}
}
} catch (err) {
failures.push(err instanceof Error ? err.message : String(err));
}
}
if (failures.length) {
console.error(failures.join("\n"));
process.exit(1);
}
console.log(`curator-static-registry tests passed for ${ICP_IDS.length} ICPs, 30 days each, 4 doc-backed tasks per day`);
}
main().catch((err) => {
console.error(err);
process.exit(1);
});

View File

@@ -0,0 +1,46 @@
import assert from "node:assert/strict";
import {
onboardingMissionInstanceId,
selectOnboardingMissionIds,
} from "../src/missions/lifecycle.js";
const userA = "user_abc123";
assert.deepEqual(
selectOnboardingMissionIds({ onboarding: { goal: "I need internship interview prep" } }),
["interview-to-offer", "personal-brand-opportunity-engine"],
"default onboarding should start interview-to-offer plus personal brand",
);
assert.deepEqual(
selectOnboardingMissionIds({ onboarding: { goal: "I want to negotiate my offer and compensation" } }),
["salary-negotiation-war-room", "personal-brand-opportunity-engine"],
"salary/offer context should prioritize the negotiation mission",
);
assert.deepEqual(
selectOnboardingMissionIds({ preferences: { onboarding: { goal: "I need a career transition into product" } } }),
["career-transition", "personal-brand-opportunity-engine"],
"preferences onboarding context should be read when selecting missions",
);
assert.deepEqual(
selectOnboardingMissionIds({ preferences: { onboarding: { goal: "Build LinkedIn visibility and network" } } }),
["personal-brand-opportunity-engine", "interview-to-offer"],
"brand/networking context should not duplicate the personal-brand mission",
);
assert.equal(
onboardingMissionInstanceId(userA, "interview-to-offer"),
onboardingMissionInstanceId(userA, "interview-to-offer"),
"onboarding mission instance ids must be deterministic for idempotent retries",
);
assert.notEqual(
onboardingMissionInstanceId(userA, "interview-to-offer"),
onboardingMissionInstanceId("user_other", "interview-to-offer"),
"onboarding mission instance ids must be scoped by user",
);
console.log("mission-lifecycle tests passed");
process.exit(0);

View File

@@ -0,0 +1,74 @@
import assert from "node:assert/strict";
import {
completedAtFromOnboardingPayload,
isValidOnboardingLedgerEvent,
normalizeOnboardingEventType,
} from "../src/events/onboarding-ledger.js";
const now = "2026-06-28T00:00:00.000Z";
assert.equal(normalizeOnboardingEventType("user_onboarding_completed"), "user.onboarding.completed");
assert.equal(
isValidOnboardingLedgerEvent({
type: "onboarding.completed",
payload: {},
}),
true,
"explicit completion event should satisfy onboarding status",
);
assert.equal(
isValidOnboardingLedgerEvent({
type: "user.onboarding.completed",
payload: {},
}),
true,
"user completion event should satisfy onboarding status",
);
assert.equal(
isValidOnboardingLedgerEvent({
type: "profile.onboarding.completed",
payload: {},
}),
true,
"profile completion event should satisfy onboarding status",
);
assert.equal(
isValidOnboardingLedgerEvent({
type: "onboarding.snapshot.saved",
payload: { onboarding: { current_step: 2 } },
}),
false,
"intermediate snapshots must not bypass onboarding",
);
assert.equal(
isValidOnboardingLedgerEvent({
type: "onboarding.snapshot.saved",
payload: { onboarding: { completed_at: now } },
}),
true,
"completion snapshots should satisfy onboarding status",
);
assert.equal(
completedAtFromOnboardingPayload({
preferences: { onboarding: { completed_at: now } },
}),
now,
"completion timestamp should be extracted from preferences snapshot",
);
assert.equal(
completedAtFromOnboardingPayload({
completedAt: "not-a-date",
})?.endsWith("Z"),
true,
"invalid completion timestamps should normalize to a valid ISO timestamp",
);
console.log("onboarding-ledger tests passed");
process.exit(0);

View File

@@ -0,0 +1,208 @@
import assert from "node:assert/strict";
import { careerTransitionReducer } from "../src/missions/career-transition/reducer.js";
import { interviewToOfferReducer } from "../src/missions/interview-to-offer/reducer.js";
import { personalBrandOpportunityReducer } from "../src/missions/personal-brand-opportunity-engine/reducer.js";
import { promotionReadinessReducer } from "../src/missions/promotion-readiness/reducer.js";
import { salaryNegotiationReducer } from "../src/missions/salary-negotiation-war-room/reducer.js";
import type { GrowActiveMission } from "../src/actors/missions/types.js";
import type { MissionReducer } from "../src/missions/reducer-types.js";
import type { MissionReducerContext } from "../src/missions/reducer-types.js";
function missionFor(missionId: GrowActiveMission["missionId"], actorType: GrowActiveMission["actorType"]): GrowActiveMission {
return {
instanceId: `mission-${missionId}-test`,
missionId,
workflowId: missionId,
title: missionId,
shortTitle: missionId,
status: "active",
progressPercent: 0,
currentStageId: "resume",
actorType,
createdAt: Date.now(),
updatedAt: Date.now(),
};
}
const mission = missionFor("interview-to-offer", "interviewToOfferMissionActor");
function ctxWithMission(activeMission: GrowActiveMission, event: Partial<MissionReducerContext["event"]> & { source: string; type: string; payload?: Record<string, unknown> }): MissionReducerContext {
return {
userId: "user_test",
activeMission,
event: {
id: `event-${activeMission.missionId}-${event.type}`,
userId: "user_test",
orgId: null,
source: event.source,
type: event.type,
category: "service",
occurredAt: new Date(),
receivedAt: new Date(),
mission: event.mission,
subject: null,
correlation: null,
payload: event.payload ?? {},
raw: {},
dedupeKey: null,
processingStatus: "pending",
processingError: null,
processedAt: null,
},
qscoreSignals: [],
insight: {
summary: "test insight",
confidence: "low",
recommendedActions: [],
missionStageHints: [],
},
};
}
function ctx(event: Partial<MissionReducerContext["event"]> & { source: string; type: string; payload?: Record<string, unknown> }): MissionReducerContext {
return ctxWithMission(mission, event);
}
const interviewFeedbackPayload = {
review: {
status: "completed",
overall_score: 72,
weak_areas: ["impact metrics", "ownership clarity"],
proof_gaps: ["no scale numbers"],
story_issues: ["STAR structure is loose"],
summary: "Good direction, but missing measurable proof.",
},
};
const roleplayFeedbackPayload = {
review: {
status: "completed",
weak_areas: ["concision", "objection handling"],
story_gaps: ["needs clearer tradeoff story"],
summary: "Good empathy, but answers need tighter story framing.",
},
};
const reducerCases: Array<{
name: string;
reducer: MissionReducer;
mission: GrowActiveMission;
}> = [
{
name: "interview to offer",
reducer: interviewToOfferReducer,
mission,
},
{
name: "career transition",
reducer: careerTransitionReducer,
mission: missionFor("career-transition", "careerTransitionMissionActor"),
},
{
name: "promotion readiness",
reducer: promotionReadinessReducer,
mission: missionFor("promotion-readiness", "promotionReadinessMissionActor"),
},
{
name: "salary negotiation",
reducer: salaryNegotiationReducer,
mission: missionFor("salary-negotiation-war-room", "salaryNegotiationWarRoomMissionActor"),
},
{
name: "personal brand",
reducer: personalBrandOpportunityReducer,
mission: missionFor("personal-brand-opportunity-engine", "personalBrandOpportunityMissionActor"),
},
];
const resumeResult = interviewToOfferReducer.reduce(ctx({
source: "resume-builder",
type: "resume.analysis.completed",
payload: {
analysis: {
summary: "Strong backend platform project.",
strengths: ["Built an event-driven backend"],
gaps: ["Add impact metrics"],
missing_keywords: ["Kafka", "AWS"],
},
},
}));
const proofPractice = resumeResult.actions.find((action) => action.payload?.passiveAction === "resume_analysis_to_interview_practice");
assert.ok(proofPractice, "resume analysis should create an interview practice passive action");
assert.equal(proofPractice?.serviceId, "interview-service");
assert.equal(proofPractice?.toolName, "interview.configure_practice");
assert.match(String(proofPractice?.payload?.prompt), /event-driven backend/i);
const interviewResult = interviewToOfferReducer.reduce(ctx({
source: "interview-service",
type: "interview.feedback.generated",
payload: interviewFeedbackPayload,
}));
const resumeUpgrade = interviewResult.actions.find((action) => action.payload?.passiveAction === "interview_feedback_to_resume_upgrade");
assert.ok(resumeUpgrade, "interview feedback should create a resume upgrade passive action");
assert.equal(resumeUpgrade?.mode, "approval_required");
assert.equal(resumeUpgrade?.serviceId, "resume-service");
assert.deepEqual(resumeUpgrade?.payload?.missingProof, ["no scale numbers"]);
assert.deepEqual(resumeUpgrade?.payload?.storyIssues, ["STAR structure is loose", "add measurable impact proof"]);
const roleplayResult = interviewToOfferReducer.reduce(ctx({
source: "roleplay-service",
type: "roleplay.feedback.generated",
payload: roleplayFeedbackPayload,
}));
const storyArtifact = roleplayResult.artifacts.find((artifact) => artifact.type === "story_bank_update");
const communicationDrill = roleplayResult.actions.find((action) => action.payload?.passiveAction === "roleplay_feedback_to_communication_drill");
assert.ok(storyArtifact, "roleplay feedback should create a story bank artifact");
assert.ok(communicationDrill, "roleplay feedback should create a communication drill passive action");
assert.equal(communicationDrill?.serviceId, "interview-service");
assert.equal(communicationDrill?.toolName, "interview.configure_practice");
assert.deepEqual(communicationDrill?.payload?.storyIssues, ["needs clearer tradeoff story", "tighten STAR story structure"]);
for (const testCase of reducerCases) {
const reducerResumeResult = testCase.reducer.reduce(ctxWithMission(testCase.mission, {
source: "resume-builder",
type: "resume.analysis.completed",
payload: {
analysis: {
summary: "Strong backend platform project.",
strengths: ["Built an event-driven backend"],
gaps: ["Add impact metrics"],
missing_keywords: ["Kafka", "AWS"],
},
},
}));
assert.ok(
reducerResumeResult.actions.some((action) => action.payload?.passiveAction === "resume_analysis_to_interview_practice"),
`${testCase.name} resume analysis should create an interview practice passive action`,
);
const reducerInterviewResult = testCase.reducer.reduce(ctxWithMission(testCase.mission, {
source: "interview-service",
type: "interview.feedback.generated",
payload: interviewFeedbackPayload,
}));
assert.ok(
reducerInterviewResult.actions.some((action) => action.payload?.passiveAction === "interview_feedback_to_resume_upgrade"),
`${testCase.name} interview feedback should create a resume upgrade passive action`,
);
const reducerRoleplayResult = testCase.reducer.reduce(ctxWithMission(testCase.mission, {
source: "roleplay-service",
type: "roleplay.feedback.generated",
payload: roleplayFeedbackPayload,
}));
assert.ok(
reducerRoleplayResult.actions.some((action) => action.payload?.passiveAction === "roleplay_feedback_to_communication_drill"),
`${testCase.name} roleplay feedback should create a communication drill passive action`,
);
assert.ok(
reducerRoleplayResult.artifacts.some((artifact) => artifact.type === "story_bank_update"),
`${testCase.name} roleplay feedback should create a story bank update artifact`,
);
}
console.log("passive-actions tests passed");
process.exit(0);

View File

@@ -0,0 +1,143 @@
#!/usr/bin/env node
const args = new Map();
for (let i = 2; i < process.argv.length; i += 1) {
const key = process.argv[i];
if (!key.startsWith("--")) continue;
const next = process.argv[i + 1];
args.set(key.slice(2), next && !next.startsWith("--") ? next : "true");
if (next && !next.startsWith("--")) i += 1;
}
const requiredServices = [
"interview-service",
"roleplay-service",
"courses-service",
"assessment-service",
"matchmaking-service",
"resume-service",
"cover-letter-service",
"qscore-service",
"social-branding-service",
];
const registry = await import("../dist/services/service-registry.js");
const capabilities = await import("../dist/workflows/service-capabilities.js");
function assert(condition, message, detail) {
if (condition) return;
const suffix = detail === undefined ? "" : `\n${JSON.stringify(detail, null, 2).slice(0, 3000)}`;
throw new Error(`${message}${suffix}`);
}
function assertEndpoint(serviceId, endpointId, endpoint) {
assert(endpoint, `${serviceId} missing endpoint ${endpointId}`);
assert(["GET", "POST", "PUT", "PATCH", "DELETE"].includes(endpoint.method), `${serviceId}.${endpointId} invalid method`, endpoint);
assert(typeof endpoint.path === "string" && endpoint.path.startsWith("/"), `${serviceId}.${endpointId} invalid path`, endpoint);
assert(typeof endpoint.contract === "string" && endpoint.contract.length > 8, `${serviceId}.${endpointId} missing contract`, endpoint);
assert(typeof endpoint.usage === "string" && endpoint.usage.length > 8, `${serviceId}.${endpointId} missing usage`, endpoint);
}
function assertPage(serviceId, pageId, page) {
assert(page, `${serviceId} missing frontend page ${pageId}`);
assert(typeof page.path === "string" && page.path.startsWith("/"), `${serviceId}.${pageId} invalid frontend path`, page);
assert(Array.isArray(page.queryParams), `${serviceId}.${pageId} queryParams must be an array`, page);
assert(typeof page.usage === "string" && page.usage.length > 8, `${serviceId}.${pageId} missing frontend usage`, page);
}
const services = registry.listServices();
assert(Array.isArray(services), "listServices did not return an array");
assert(new Set(services.map((service) => service.id)).size === services.length, "registry contains duplicate service ids", services.map((s) => s.id));
for (const id of requiredServices) {
const service = registry.getService(id);
assert(service, `missing first-class service ${id}`);
assert(service.id === id, `getService returned wrong id for ${id}`, service);
assert(typeof service.label === "string" && service.label.length > 1, `${id} missing label`, service);
assert(typeof service.description === "string" && service.description.length > 8, `${id} missing description`, service);
assert(typeof service.featureId === "string" && service.featureId.length > 1, `${id} missing featureId`, service);
assert(typeof service.promptModulePath === "string" && service.promptModulePath.length > 1, `${id} missing promptModulePath`, service);
assert(service.backend, `${id} missing backend`);
assert(typeof service.backend.healthPath === "string" && service.backend.healthPath.startsWith("/"), `${id} missing healthPath`, service.backend);
assert(typeof service.backend.usage === "string" && service.backend.usage.length > 8, `${id} missing backend usage`, service.backend);
assert(service.backend.endpoints && Object.keys(service.backend.endpoints).length > 0, `${id} missing backend endpoints`, service.backend);
for (const [endpointId, endpoint] of Object.entries(service.backend.endpoints)) assertEndpoint(id, endpointId, endpoint);
assert(service.frontend, `${id} missing frontend`);
assert(typeof service.frontend.baseUrl === "string" && service.frontend.baseUrl.length > 0, `${id} missing frontend baseUrl`, service.frontend);
assert(typeof service.frontend.usage === "string" && service.frontend.usage.length > 8, `${id} missing frontend usage`, service.frontend);
assert(service.frontend.pages && Object.keys(service.frontend.pages).length > 0, `${id} missing frontend pages`, service.frontend);
for (const [pageId, page] of Object.entries(service.frontend.pages)) assertPage(id, pageId, page);
assert(service.curator, `${id} missing curator`);
assert(service.frontend.pages[service.curator.defaultPage], `${id} curator defaultPage is not a real page`, service.curator);
assert(typeof service.curator.defaultActionLabel === "string" && service.curator.defaultActionLabel.length > 3, `${id} missing default action label`, service.curator);
assert(Array.isArray(service.curator.completionEvents) && service.curator.completionEvents.length > 0, `${id} missing completion events`, service.curator);
assert(typeof service.curator.toolName === "string" && service.curator.toolName.length > 3, `${id} missing curator toolName`, service.curator);
assert(Array.isArray(service.usageDocs) && service.usageDocs.length > 0, `${id} missing usageDocs`, service);
assert(registry.getServiceBackend(id) === service.backend, `${id} getServiceBackend mismatch`);
assert(registry.getServiceFrontend(id) === service.frontend, `${id} getServiceFrontend mismatch`);
assert(registry.getCompletionEvents(id).length === service.curator.completionEvents.length, `${id} getCompletionEvents mismatch`);
assert(registry.getServiceActionLabel(id, "start").length > 0, `${id} action label is empty`);
const endpoint = registry.getServiceEndpoint(id, Object.keys(service.backend.endpoints)[0]);
assert(endpoint, `${id} getServiceEndpoint returned nothing`);
const link = registry.buildServiceLink(id, service.curator.defaultPage, {
source: "acceptance",
missionInstanceId: "mission-acceptance",
curatorTaskId: "task-acceptance",
});
assert(typeof link === "string" && link.startsWith("/"), `${id} buildServiceLink returned invalid link`, { link });
assert(link.includes("source=acceptance"), `${id} buildServiceLink did not preserve state`, { link });
assert(!link.includes("undefined") && !link.includes("null"), `${id} buildServiceLink leaked nullish values`, { link });
}
assert(registry.getService("jobs-service")?.id === "matchmaking-service", "matchmaking alias failed");
assert(registry.getService("coverletter-service")?.id === "cover-letter-service", "cover-letter alias failed");
assert(registry.getService("q-score-service")?.id === "qscore-service", "qscore alias failed");
assert(registry.getService("social-service")?.id === "social-branding-service", "social alias failed");
const catalog = registry.listServicesForCatalog();
assert(catalog.length === services.length, "listServicesForCatalog count mismatch", { catalog: catalog.length, services: services.length });
assert(!catalog.some((service) => service.backend?.baseUrl), "catalog leaks backend.baseUrl", catalog);
const publicCapabilities = capabilities.listServiceCapabilities({ public: true });
const capabilityServices = publicCapabilities.filter((service) => requiredServices.includes(service.id));
assert(publicCapabilities.length === services.length, "public capabilities should only expose canonical registry services", publicCapabilities.map((s) => s.id));
assert(capabilityServices.length === requiredServices.length, "public capabilities missing required services", capabilityServices.map((s) => s.id));
assert(!capabilityServices.some((service) => service.internalUrl || service.backend?.baseUrl), "public capabilities leak internal URL", capabilityServices);
assert(!publicCapabilities.some((service) => service.id === "mission-planning"), "public capabilities leak internal mission-planning module", publicCapabilities);
for (const service of capabilityServices) {
const record = registry.getService(service.id);
assert(record, `capability references unknown registry service ${service.id}`);
assert(JSON.stringify(service.operations) === JSON.stringify(Object.keys(record.backend.endpoints)), `${service.id} operations not derived from endpoints`, {
operations: service.operations,
endpoints: Object.keys(record.backend.endpoints),
});
}
const baseUrl = args.get("base-url") || process.env.BACKEND_BASE_URL;
const serviceToken = process.env.SERVICE_TOKEN;
if (baseUrl) {
assert(serviceToken, "SERVICE_TOKEN is required when --base-url/BACKEND_BASE_URL is provided");
const response = await fetch(`${baseUrl.replace(/\/$/, "")}/services/catalog`, {
headers: {
authorization: `Bearer ${serviceToken}`,
"x-growqr-user": "registry-acceptance",
},
});
const text = await response.text();
assert(response.ok, `live /services/catalog returned HTTP ${response.status}`, text);
const live = JSON.parse(text);
assert(Array.isArray(live.services), "live catalog missing services", live);
assert(live.services.length === services.length, "live catalog should only expose canonical registry services", live.services.map((service) => service.id));
for (const id of requiredServices) {
assert(live.services.some((service) => service.id === id), `live catalog missing ${id}`, live);
}
assert(!live.services.some((service) => service.backend?.baseUrl), "live catalog leaks backend.baseUrl", live);
assert(!live.services.some((service) => service.id === "mission-planning"), "live catalog leaks internal mission-planning module", live);
}
console.log(JSON.stringify({ ok: true, services: services.length, requiredServices: requiredServices.length, liveCatalog: Boolean(baseUrl) }));

View File

@@ -0,0 +1,148 @@
#!/usr/bin/env node
const args = new Map();
for (let i = 2; i < process.argv.length; i += 1) {
const key = process.argv[i];
if (!key.startsWith("--")) continue;
const next = process.argv[i + 1];
args.set(key.slice(2), next && !next.startsWith("--") ? next : "true");
if (next && !next.startsWith("--")) i += 1;
}
const baseUrl = (args.get("base-url") || process.env.BACKEND_BASE_URL || "http://127.0.0.1:4000").replace(/\/$/, "");
const userId = args.get("user-id") || process.env.SMOKE_USER_ID || "registry-content-quality";
const iterations = Number(args.get("iterations") || process.env.SMOKE_ITERATIONS || 1);
const previewTimeoutMs = Number(args.get("preview-timeout-ms") || process.env.SMOKE_PREVIEW_TIMEOUT_MS || 180000);
const serviceToken = process.env.SERVICE_TOKEN;
if (!serviceToken) {
throw new Error("SERVICE_TOKEN is required for authenticated content-quality probes.");
}
const badMarkers = [/placeholder/i, /dummy/i, /not implemented/i, /fallback/i, /lorem/i, /todo/i, /undefined/i];
function assert(condition, message, detail) {
if (condition) return;
const suffix = detail === undefined ? "" : `\n${JSON.stringify(detail, null, 2).slice(0, 3000)}`;
throw new Error(`${message}${suffix}`);
}
function outlineOf(json) {
return Array.isArray(json?.question_outline) ? json.question_outline : json?.prompt_outline;
}
function walk(value, path = "$", strings = [], nulls = []) {
if (value === null) nulls.push(path);
else if (typeof value === "string") strings.push(value);
else if (Array.isArray(value)) value.forEach((item, index) => walk(item, `${path}[${index}]`, strings, nulls));
else if (value && typeof value === "object") Object.entries(value).forEach(([key, item]) => walk(item, `${path}.${key}`, strings, nulls));
return { strings, nulls };
}
async function post(name, path, payload) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), previewTimeoutMs);
const started = Date.now();
try {
const response = await fetch(`${baseUrl}${path}`, {
method: "POST",
signal: controller.signal,
headers: {
authorization: `Bearer ${serviceToken}`,
"x-growqr-user": userId,
"content-type": "application/json",
},
body: JSON.stringify(payload),
});
const text = await response.text();
const durationMs = Date.now() - started;
assert(response.ok, `${name} returned HTTP ${response.status}`, { text, durationMs });
return { json: JSON.parse(text), durationMs };
} catch (error) {
if (error?.name === "AbortError") {
throw new Error(`${name} timed out after ${Date.now() - started}ms`, { cause: error });
}
throw error;
} finally {
clearTimeout(timer);
}
}
function validatePreview(name, json) {
const outline = outlineOf(json);
assert(Array.isArray(outline) && outline.length >= 3, `${name} needs at least 3 outline items`, outline);
const { strings, nulls } = walk(json);
assert(nulls.length === 0, `${name} contains null fields`, nulls.slice(0, 30));
const cleanStrings = strings.map((item) => item.trim()).filter(Boolean);
for (const marker of badMarkers) {
assert(!cleanStrings.some((item) => marker.test(item)), `${name} contains marker ${marker}`, cleanStrings.filter((item) => marker.test(item)).slice(0, 10));
}
const prompts = outline
.map((item) => String(item.question || item.prompt || item.text || "").replace(/\s+/g, " ").trim())
.filter(Boolean);
assert(prompts.length >= 3, `${name} outline prompts are missing text`, outline);
assert(prompts.every((prompt) => prompt.length >= 35), `${name} outline prompts are too shallow`, prompts);
assert(new Set(prompts.map((prompt) => prompt.toLowerCase())).size === prompts.length, `${name} outline prompts duplicate`, prompts);
assert(String(json.opening_prompt || "").trim().length >= 35, `${name} opening prompt too short`, json.opening_prompt);
const briefText = walk(json.candidate_brief).strings.join(" ").replace(/\s+/g, " ").trim();
assert(briefText.length >= 300, `${name} candidate brief too thin`, briefText);
}
async function runIteration(iteration) {
const user = `${userId}-${iteration}`;
const interview = await post(`[content ${iteration}] interview preview`, "/services/interview/preview", {
user_id: user,
org_id: "growqr",
persona_id: "emma",
interview_type: "behavioral",
duration_minutes: 5,
context: {
target_role: "Product Manager",
company_name: "GrowQR Quality",
difficulty: "medium",
source: "registry-content-quality",
personalize: false,
},
});
validatePreview(`[content ${iteration}] interview preview`, interview.json);
const roleplay = await post(`[content ${iteration}] roleplay preview`, "/services/roleplay/preview", {
user_id: user,
org_id: "growqr",
persona_id: "emma",
duration_minutes: 5,
roleplay_type: "custom",
brief: "Practice a concise salary negotiation opening for a product manager offer.",
metadata: {
target_role: "Product Manager",
candidate_role: "Product Manager",
difficulty: "medium",
source: "registry-content-quality",
personalize: false,
},
});
validatePreview(`[content ${iteration}] roleplay preview`, roleplay.json);
assert(roleplay.json.scenario?.candidate_role === "Product Manager", `[content ${iteration}] roleplay did not expose explicit candidate_role`, roleplay.json.scenario);
assert(typeof roleplay.json.scenario?.persona_role === "string" && roleplay.json.scenario.persona_role.length > 0, `[content ${iteration}] roleplay did not expose persona_role`, roleplay.json.scenario);
return {
iteration,
interviewSession: interview.json.session_id,
interviewPreviewMs: interview.durationMs,
roleplaySession: roleplay.json.session_id,
roleplayPreviewMs: roleplay.durationMs,
};
}
const results = [];
for (let i = 1; i <= iterations; i += 1) {
const result = await runIteration(i);
results.push(result);
console.log(JSON.stringify(result));
}
console.log(JSON.stringify({ ok: true, iterations, results }));

View File

@@ -0,0 +1,216 @@
#!/usr/bin/env node
const args = new Map();
for (let i = 2; i < process.argv.length; i += 1) {
const key = process.argv[i];
if (!key.startsWith("--")) continue;
const next = process.argv[i + 1];
args.set(key.slice(2), next && !next.startsWith("--") ? next : "true");
if (next && !next.startsWith("--")) i += 1;
}
const baseUrl = (args.get("base-url") || process.env.BACKEND_BASE_URL || "http://127.0.0.1:4000").replace(/\/$/, "");
const userId = args.get("user-id") || process.env.SMOKE_USER_ID || "registry-smoke";
const iterations = Number(args.get("iterations") || process.env.SMOKE_ITERATIONS || 1);
const previewTimeoutMs = Number(args.get("preview-timeout-ms") || process.env.SMOKE_PREVIEW_TIMEOUT_MS || 180000);
const serviceToken = process.env.SERVICE_TOKEN;
if (!serviceToken) {
throw new Error("SERVICE_TOKEN is required for authenticated backend smoke probes.");
}
const requiredServices = [
"interview-service",
"roleplay-service",
"resume-service",
"cover-letter-service",
"courses-service",
"assessment-service",
"matchmaking-service",
"qscore-service",
"social-branding-service",
];
const directHealth = [
["interview", "http://127.0.0.1:8007/health"],
["roleplay", "http://127.0.0.1:8040/health"],
["resume", "http://127.0.0.1:8002/health"],
["qscore", "http://127.0.0.1:8000/health"],
["courses", "http://127.0.0.1:8060/api/v1/health"],
["assessment", "http://127.0.0.1:8070/api/v1/health"],
["matchmaking", "http://127.0.0.1:8006/api/v1/health"],
["pathways", "http://127.0.0.1:8009/api/v1/health"],
["social", "http://127.0.0.1:8015/health"],
];
function assert(condition, message, detail) {
if (condition) return;
const suffix = detail === undefined ? "" : `\n${JSON.stringify(detail, null, 2).slice(0, 3000)}`;
throw new Error(`${message}${suffix}`);
}
function authHeaders(extra = {}) {
return {
authorization: `Bearer ${serviceToken}`,
"x-growqr-user": userId,
...extra,
};
}
async function request(name, url, init = {}, timeoutMs = 15000) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
const started = Date.now();
try {
const res = await fetch(url, { ...init, signal: controller.signal });
const text = await res.text();
let json;
try {
json = text ? JSON.parse(text) : {};
} catch {
json = undefined;
}
const durationMs = Date.now() - started;
assert(res.ok, `${name} returned HTTP ${res.status}`, { text, durationMs });
return { json, text, durationMs };
} catch (error) {
if (error?.name === "AbortError") {
const durationMs = Date.now() - started;
throw new Error(`${name} timed out after ${durationMs}ms`, { cause: error });
}
throw error;
} finally {
clearTimeout(timer);
}
}
function rejectFallbackLike(name, value) {
if (value && typeof value === "object") {
assert(!("error" in value), `${name} contains error field`, value);
assert(!("detail" in value && /internal|fallback|not implemented/i.test(String(value.detail))), `${name} contains error detail`, value);
}
const text = JSON.stringify(value).toLowerCase();
const bad = ["placeholder", "dummy", "not implemented", "fallback"];
const found = bad.find((needle) => text.includes(needle));
assert(!found, `${name} contains fallback/error-like marker: ${found}`, value);
}
function assertGeneratedPreview(name, json) {
rejectFallbackLike(name, json);
assert(typeof json.session_id === "string" && json.session_id.length > 12, `${name} missing session_id`, json);
assert(json.status === "draft", `${name} should create draft preview`, json);
assert(json.needs_approval === true, `${name} should require approval`, json);
const outline = Array.isArray(json.question_outline) ? json.question_outline : json.prompt_outline;
assert(Array.isArray(outline) && outline.length >= 2, `${name} missing generated outline`, json);
assert(Boolean(json.opening_prompt), `${name} missing opening_prompt`, json);
assert(Boolean(json.candidate_brief), `${name} missing candidate_brief`, json);
}
async function runIteration(iteration) {
const prefix = `[smoke ${iteration}]`;
const health = await request(`${prefix} backend health`, `${baseUrl}/healthz`);
assert(health.json?.ok === true, `${prefix} backend health payload invalid`, health.json);
const catalog = await request(`${prefix} services catalog`, `${baseUrl}/services/catalog`, {
headers: authHeaders(),
});
const services = catalog.json?.services;
assert(Array.isArray(services), `${prefix} catalog missing services`, catalog.json);
for (const id of requiredServices) {
assert(services.some((service) => service.id === id), `${prefix} catalog missing ${id}`, catalog.json);
}
assert(!services.some((service) => service.backend?.baseUrl), `${prefix} catalog leaks internal backend baseUrl`, catalog.json);
assert(
services.find((service) => service.id === "courses-service")?.backend?.healthPath === "/api/v1/health",
`${prefix} courses health path is not canonical`,
catalog.json,
);
for (const [name, url] of directHealth) {
const res = await request(`${prefix} ${name} direct health`, url, {}, 8000);
rejectFallbackLike(`${prefix} ${name} direct health`, res.json ?? res.text);
}
for (const service of ["interview", "roleplay", "resume", "social"]) {
const res = await request(`${prefix} ${service} gateway health`, `${baseUrl}/services/${service}/health`, {
headers: authHeaders(),
});
rejectFallbackLike(`${prefix} ${service} gateway health`, res.json ?? res.text);
}
const interviewState = await request(`${prefix} interview page-state`, `${baseUrl}/services/interview/page-state`, {
headers: authHeaders(),
});
assert(Array.isArray(interviewState.json?.recent_sessions), `${prefix} interview page-state missing recent_sessions`, interviewState.json);
const roleplayState = await request(`${prefix} roleplay page-state`, `${baseUrl}/services/roleplay/page-state`, {
headers: authHeaders(),
});
assert(Array.isArray(roleplayState.json?.recent_sessions), `${prefix} roleplay page-state missing recent_sessions`, roleplayState.json);
const qscore = await request(`${prefix} qscore current`, `${baseUrl}/services/qscore/current`, {
headers: authHeaders(),
});
assert("signals" in qscore.json && Array.isArray(qscore.json.signals), `${prefix} qscore current missing signals`, qscore.json);
const interviewPayload = {
user_id: userId,
org_id: "growqr",
persona_id: "emma",
interview_type: "behavioral",
duration_minutes: 5,
context: {
target_role: "Product Manager",
company_name: "GrowQR Smoke Test",
difficulty: "medium",
source: "registry-smoke",
personalize: false,
},
};
const interviewPreview = await request(`${prefix} interview preview generation`, `${baseUrl}/services/interview/preview`, {
method: "POST",
headers: authHeaders({ "content-type": "application/json" }),
body: JSON.stringify(interviewPayload),
}, previewTimeoutMs);
assertGeneratedPreview(`${prefix} interview preview generation`, interviewPreview.json);
const roleplayPayload = {
user_id: userId,
org_id: "growqr",
persona_id: "emma",
duration_minutes: 5,
roleplay_type: "custom",
brief: "Practice a concise salary negotiation opening for a product manager offer.",
metadata: {
target_role: "Product Manager",
difficulty: "medium",
source: "registry-smoke",
personalize: false,
},
};
const roleplayPreview = await request(`${prefix} roleplay preview generation`, `${baseUrl}/services/roleplay/preview`, {
method: "POST",
headers: authHeaders({ "content-type": "application/json" }),
body: JSON.stringify(roleplayPayload),
}, previewTimeoutMs);
assertGeneratedPreview(`${prefix} roleplay preview generation`, roleplayPreview.json);
return {
iteration,
catalogCount: services.length,
interviewSession: interviewPreview.json.session_id,
interviewPreviewMs: interviewPreview.durationMs,
roleplaySession: roleplayPreview.json.session_id,
roleplayPreviewMs: roleplayPreview.durationMs,
};
}
const results = [];
for (let i = 1; i <= iterations; i += 1) {
const result = await runIteration(i);
results.push(result);
console.log(JSON.stringify(result));
}
console.log(JSON.stringify({ ok: true, iterations, results }));

View File

@@ -0,0 +1,236 @@
#!/usr/bin/env node
const args = new Map();
for (let i = 2; i < process.argv.length; i += 1) {
const key = process.argv[i];
if (!key.startsWith("--")) continue;
const next = process.argv[i + 1];
args.set(key.slice(2), next && !next.startsWith("--") ? next : "true");
if (next && !next.startsWith("--")) i += 1;
}
const baseUrl = (args.get("base-url") || process.env.BACKEND_BASE_URL || "http://127.0.0.1:4000").replace(/\/$/, "");
const userId = args.get("user-id") || process.env.SMOKE_USER_ID || "registry-write-smoke";
const iterations = Number(args.get("iterations") || process.env.SMOKE_ITERATIONS || 1);
const previewTimeoutMs = Number(args.get("preview-timeout-ms") || process.env.SMOKE_PREVIEW_TIMEOUT_MS || 180000);
const serviceToken = process.env.SERVICE_TOKEN;
if (!serviceToken) {
throw new Error("SERVICE_TOKEN is required for authenticated backend write-flow probes.");
}
function assert(condition, message, detail) {
if (condition) return;
const suffix = detail === undefined ? "" : `\n${JSON.stringify(detail, null, 2).slice(0, 3000)}`;
throw new Error(`${message}${suffix}`);
}
function authHeaders(extra = {}) {
return {
authorization: `Bearer ${serviceToken}`,
"x-growqr-user": userId,
...extra,
};
}
async function request(name, path, init = {}, timeoutMs = 90000) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
const started = Date.now();
try {
const res = await fetch(`${baseUrl}${path}`, { ...init, signal: controller.signal });
const text = await res.text();
let json;
try {
json = text ? JSON.parse(text) : {};
} catch {
json = undefined;
}
const durationMs = Date.now() - started;
assert(res.ok, `${name} returned HTTP ${res.status}`, { text, durationMs });
return { json, text, durationMs };
} catch (error) {
if (error?.name === "AbortError") {
const durationMs = Date.now() - started;
throw new Error(`${name} timed out after ${durationMs}ms`, { cause: error });
}
throw error;
} finally {
clearTimeout(timer);
}
}
function rejectFallbackLike(name, value) {
if (value && typeof value === "object") {
assert(!("error" in value), `${name} contains error field`, value);
assert(!("detail" in value && /internal|fallback|not implemented/i.test(String(value.detail))), `${name} contains error detail`, value);
}
const text = JSON.stringify(value).toLowerCase();
const bad = ["placeholder", "dummy", "not implemented", "fallback"];
const found = bad.find((needle) => text.includes(needle));
assert(!found, `${name} contains fallback/error-like marker: ${found}`, value);
}
function outlineOf(json) {
return Array.isArray(json?.question_outline) ? json.question_outline : json?.prompt_outline;
}
function assertDraftPreview(name, json) {
rejectFallbackLike(name, json);
assert(typeof json.session_id === "string" && json.session_id.length > 12, `${name} missing session_id`, json);
assert(json.status === "draft", `${name} should create draft`, json);
assert(json.needs_approval === true, `${name} should require approval`, json);
assert(Array.isArray(outlineOf(json)) && outlineOf(json).length >= 2, `${name} missing generated outline`, json);
assert(Boolean(json.opening_prompt), `${name} missing opening_prompt`, json);
assert(Boolean(json.candidate_brief), `${name} missing candidate_brief`, json);
}
function asInterviewQuestions(preview, iteration) {
return outlineOf(preview).slice(0, 3).map((item, index) => ({
text: `${String(item.question || item.text || "").replace(/\s+/g, " ").trim()} [write-flow ${iteration}.${index + 1}]`,
topic: String(item.topic || `Smoke interview ${index + 1}`),
expected_framework: String(item.expected_framework || "none"),
}));
}
function asRoleplayPrompts(preview, iteration) {
return outlineOf(preview).slice(0, 3).map((item, index) => ({
text: `${String(item.prompt || item.question || item.text || "").replace(/\s+/g, " ").trim()} [write-flow ${iteration}.${index + 1}]`,
topic: String(item.topic || `Smoke roleplay ${index + 1}`),
}));
}
async function runInterviewFlow(iteration) {
const prefix = `[write ${iteration}] interview`;
const previewPayload = {
user_id: userId,
org_id: "growqr",
persona_id: "emma",
interview_type: "behavioral",
duration_minutes: 5,
context: {
target_role: "Product Manager",
company_name: "GrowQR Write Flow",
difficulty: "medium",
source: "registry-write-flow",
personalize: false,
},
};
const preview = await request(`${prefix} preview`, "/services/interview/preview", {
method: "POST",
headers: authHeaders({ "content-type": "application/json" }),
body: JSON.stringify(previewPayload),
}, previewTimeoutMs);
assertDraftPreview(`${prefix} preview`, preview.json);
const questions = asInterviewQuestions(preview.json, iteration);
assert(questions.every((item) => item.text.includes("[write-flow")), `${prefix} question edit payload invalid`, questions);
const edited = await request(`${prefix} questions edit`, "/services/interview/questions", {
method: "POST",
headers: authHeaders({ "content-type": "application/json" }),
body: JSON.stringify({ session_id: preview.json.session_id, questions }),
});
rejectFallbackLike(`${prefix} questions edit`, edited.json);
assert(edited.json?.status === "draft", `${prefix} edit should keep draft status`, edited.json);
assert(edited.json?.questions_edited === true, `${prefix} edit should mark questions_edited`, edited.json);
assert(outlineOf(edited.json)?.[0]?.question?.includes("[write-flow"), `${prefix} edited question not persisted`, edited.json);
const approved = await request(`${prefix} approve`, "/services/interview/approve", {
method: "POST",
headers: authHeaders({ "content-type": "application/json" }),
body: JSON.stringify({ session_id: preview.json.session_id }),
});
rejectFallbackLike(`${prefix} approve`, approved.json);
assert(approved.json?.status === "configured", `${prefix} approve should configure session`, approved.json);
assert(approved.json?.approved === true, `${prefix} approve missing approved flag`, approved.json);
const review = await request(`${prefix} review`, `/services/interview/review/${encodeURIComponent(preview.json.session_id)}`, {
headers: authHeaders(),
}, 15000);
rejectFallbackLike(`${prefix} review`, review.json);
assert(review.json?.status === "processing" || typeof review.json?.overall_score === "number", `${prefix} review shape invalid`, review.json);
return {
sessionId: preview.json.session_id,
reviewStatus: review.json?.status ?? "complete",
durationsMs: {
preview: preview.durationMs,
edit: edited.durationMs,
approve: approved.durationMs,
review: review.durationMs,
},
};
}
async function runRoleplayFlow(iteration) {
const prefix = `[write ${iteration}] roleplay`;
const previewPayload = {
user_id: userId,
org_id: "growqr",
persona_id: "emma",
duration_minutes: 5,
roleplay_type: "custom",
brief: "Practice a concise salary negotiation opening for a product manager offer.",
metadata: {
target_role: "Product Manager",
difficulty: "medium",
source: "registry-write-flow",
personalize: false,
},
};
const preview = await request(`${prefix} preview`, "/services/roleplay/preview", {
method: "POST",
headers: authHeaders({ "content-type": "application/json" }),
body: JSON.stringify(previewPayload),
}, previewTimeoutMs);
assertDraftPreview(`${prefix} preview`, preview.json);
const questions = asRoleplayPrompts(preview.json, iteration);
assert(questions.every((item) => item.text.includes("[write-flow")), `${prefix} prompt edit payload invalid`, questions);
const edited = await request(`${prefix} prompt edit`, "/services/roleplay/questions", {
method: "POST",
headers: authHeaders({ "content-type": "application/json" }),
body: JSON.stringify({ session_id: preview.json.session_id, questions }),
});
rejectFallbackLike(`${prefix} prompt edit`, edited.json);
assert(edited.json?.status === "draft", `${prefix} edit should keep draft status`, edited.json);
assert(edited.json?.questions_edited === true, `${prefix} edit should mark questions_edited`, edited.json);
assert(outlineOf(edited.json)?.[0]?.prompt?.includes("[write-flow"), `${prefix} edited prompt not persisted`, edited.json);
const approved = await request(`${prefix} approve`, "/services/roleplay/approve", {
method: "POST",
headers: authHeaders({ "content-type": "application/json" }),
body: JSON.stringify({ session_id: preview.json.session_id }),
});
rejectFallbackLike(`${prefix} approve`, approved.json);
assert(approved.json?.status === "configured", `${prefix} approve should configure session`, approved.json);
assert(approved.json?.approved === true, `${prefix} approve missing approved flag`, approved.json);
const review = await request(`${prefix} review`, `/services/roleplay/review/${encodeURIComponent(preview.json.session_id)}`, {
headers: authHeaders(),
}, 15000);
rejectFallbackLike(`${prefix} review`, review.json);
assert(review.json?.status === "processing" || typeof review.json?.overall_score === "number", `${prefix} review shape invalid`, review.json);
return {
sessionId: preview.json.session_id,
reviewStatus: review.json?.status ?? "complete",
durationsMs: {
preview: preview.durationMs,
edit: edited.durationMs,
approve: approved.durationMs,
review: review.durationMs,
},
};
}
const results = [];
for (let i = 1; i <= iterations; i += 1) {
const interview = await runInterviewFlow(i);
const roleplay = await runRoleplayFlow(i);
const result = { iteration: i, interview, roleplay };
results.push(result);
console.log(JSON.stringify(result));
}
console.log(JSON.stringify({ ok: true, iterations, results }));

View File

@@ -115,11 +115,9 @@ async function userQscoreAnalytics(userId: string) {
}
async function userActivityAnalytics(userId: string) {
const [events, activeMissions, actions] = await Promise.all([
db.select().from(growEvents).where(eq(growEvents.userId, userId)).orderBy(desc(growEvents.occurredAt)).limit(100),
listActiveMissionsPg(userId),
listMissionActions(userId, { openOnly: false }),
]);
const events = await db.select().from(growEvents).where(eq(growEvents.userId, userId)).orderBy(desc(growEvents.occurredAt)).limit(100);
const activeMissions = await listActiveMissionsPg(userId).catch(() => []);
const actions = await listMissionActions(userId, { openOnly: false }).catch(() => []);
return {
kind: "user-activity",

View File

@@ -7,6 +7,7 @@ import { memoryActor } from "./memory/index.js";
import { growActor } from "./grow/index.js";
import { userEventActor } from "./events/index.js";
import { analyticsActor } from "./analytics/index.js";
import { curatorActor } from "../v1/curator/curator-actor.js";
import {
careerTransitionMissionActor,
interviewToOfferMissionActor,
@@ -20,6 +21,7 @@ export const registry = setup({
growActor,
userEventActor,
analyticsActor,
curatorActor,
conversationActor,
memoryActor,
interviewToOfferMissionActor,

View File

@@ -77,10 +77,28 @@ export const config = {
process.env.USER_SERVICE_URL ?? "http://localhost:8003",
resumePublicUrl:
process.env.RESUME_PUBLIC_URL ?? process.env.RESUME_SERVICE_URL ?? "http://localhost:8002",
coursesServiceUrl:
process.env.COURSES_SERVICE_URL ?? "http://localhost:8060",
coursesPublicUrl:
process.env.COURSES_PUBLIC_URL ?? process.env.COURSES_SERVICE_URL ?? "http://localhost:8060",
assessmentServiceUrl:
process.env.ASSESSMENT_SERVICE_URL ?? "http://localhost:8070",
assessmentPublicUrl:
process.env.ASSESSMENT_PUBLIC_URL ?? process.env.ASSESSMENT_SERVICE_URL ?? "http://localhost:8070",
matchmakingServiceUrl:
process.env.MATCHMAKING_SERVICE_URL ?? "http://localhost:8006",
matchmakingPublicUrl:
process.env.MATCHMAKING_PUBLIC_URL ?? process.env.MATCHMAKING_SERVICE_URL ?? "http://localhost:8006",
pathwaysServiceUrl:
process.env.PATHWAYS_SERVICE_URL ?? "http://localhost:8009",
pathwaysPublicUrl:
process.env.PATHWAYS_PUBLIC_URL ?? process.env.PATHWAYS_SERVICE_URL ?? "http://localhost:8009",
socialBrandingServiceUrl:
process.env.SOCIAL_BRANDING_SERVICE_URL ?? "http://localhost:8005",
socialBrandingPublicUrl:
process.env.SOCIAL_BRANDING_PUBLIC_URL ?? process.env.SOCIAL_BRANDING_SERVICE_URL ?? "http://localhost:8005",
qscorePublicUrl:
process.env.QSCORE_PUBLIC_URL ?? process.env.QSCORE_SERVICE_URL ?? "http://localhost:8000",
workflowsDashboardUrl:
process.env.WORKFLOWS_DASHBOARD_URL ??
process.env.FRONTEND_ORIGIN ??
@@ -126,6 +144,12 @@ export const config = {
.map((s) => s.trim())
.filter(Boolean),
// Passive mission refresh loop. Dedupe keys make this safe across retries and
// multiple staging replicas; set MISSION_PASSIVE_LOOP_ENABLED=false to disable.
missionPassiveLoopEnabled: (process.env.MISSION_PASSIVE_LOOP_ENABLED ?? "true").toLowerCase() !== "false",
missionPassiveLoopIntervalMs: Number(process.env.MISSION_PASSIVE_LOOP_INTERVAL_MS ?? 60 * 60 * 1000),
missionPassiveLoopBatchSize: Number(process.env.MISSION_PASSIVE_LOOP_BATCH_SIZE ?? 100),
// Used by LLM requests.
maxAgentTokens: Number(process.env.MAX_AGENT_TOKENS ?? 4096),

View File

@@ -0,0 +1,48 @@
import { db } from "./client.js";
import { log } from "../log.js";
async function ensureGrowConversationsMetadataColumn() {
await db.execute(`
ALTER TABLE grow_conversations
ADD COLUMN IF NOT EXISTS metadata jsonb NOT NULL DEFAULT '{}'::jsonb
`);
}
async function ensureSystemNotificationsTables() {
await db.execute(`
CREATE TABLE IF NOT EXISTS system_notifications (
id text PRIMARY KEY DEFAULT gen_random_uuid()::text NOT NULL,
user_id text NOT NULL REFERENCES users(id) ON DELETE cascade,
kind text NOT NULL CHECK (kind IN ('session', 'billing', 'security', 'feature', 'account')),
title text NOT NULL,
sub text NOT NULL,
when_label text NOT NULL,
href text,
source text NOT NULL DEFAULT 'system',
source_ref jsonb NOT NULL DEFAULT '{}'::jsonb,
read_at timestamp with time zone,
occurred_at timestamp with time zone NOT NULL DEFAULT now(),
created_at timestamp with time zone NOT NULL DEFAULT now(),
updated_at timestamp with time zone NOT NULL DEFAULT now()
)
`);
await db.execute(`CREATE INDEX IF NOT EXISTS system_notifications_user_idx ON system_notifications (user_id, read_at, occurred_at)`);
await db.execute(`CREATE INDEX IF NOT EXISTS system_notifications_kind_idx ON system_notifications (user_id, kind, occurred_at)`);
await db.execute(`
CREATE TABLE IF NOT EXISTS system_notification_preferences (
user_id text NOT NULL REFERENCES users(id) ON DELETE cascade,
kind text NOT NULL CHECK (kind IN ('session', 'billing', 'security', 'feature', 'account')),
in_app boolean NOT NULL DEFAULT true,
email boolean NOT NULL DEFAULT true,
updated_at timestamp with time zone NOT NULL DEFAULT now(),
PRIMARY KEY (user_id, kind)
)
`);
await db.execute(`CREATE INDEX IF NOT EXISTS system_notification_preferences_user_idx ON system_notification_preferences (user_id)`);
}
export async function ensureRuntimeSchema() {
await ensureGrowConversationsMetadataColumn();
await ensureSystemNotificationsTables();
log.info("runtime schema ensured");
}

View File

@@ -589,6 +589,44 @@ export const growHomeNotifications = pgTable(
}),
);
export const systemNotifications = pgTable(
"system_notifications",
{
id: text("id").primaryKey().default(sql`gen_random_uuid()::text`),
userId: text("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
kind: text("kind", { enum: ["session", "billing", "security", "feature", "account"] }).notNull(),
title: text("title").notNull(),
sub: text("sub").notNull(),
whenLabel: text("when_label").notNull(),
href: text("href"),
source: text("source").notNull().default("system"),
sourceRef: jsonb("source_ref").$type<Record<string, unknown>>().notNull().default(sql`'{}'::jsonb`),
readAt: timestamp("read_at", { withTimezone: true }),
occurredAt: timestamp("occurred_at", { withTimezone: true }).defaultNow().notNull(),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
},
(t) => ({
userIdx: index("system_notifications_user_idx").on(t.userId, t.readAt, t.occurredAt),
kindIdx: index("system_notifications_kind_idx").on(t.userId, t.kind, t.occurredAt),
}),
);
export const systemNotificationPreferences = pgTable(
"system_notification_preferences",
{
userId: text("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
kind: text("kind", { enum: ["session", "billing", "security", "feature", "account"] }).notNull(),
inApp: boolean("in_app").notNull().default(true),
email: boolean("email").notNull().default(true),
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
},
(t) => ({
pk: primaryKey({ columns: [t.userId, t.kind] }),
userIdx: index("system_notification_preferences_user_idx").on(t.userId),
}),
);
export type GrowEventRow = typeof growEvents.$inferSelect;
export type NewGrowEvent = typeof growEvents.$inferInsert;
export type MissionActionRow = typeof missionActions.$inferSelect;
@@ -598,3 +636,6 @@ export type NewMissionSuggestion = typeof missionSuggestions.$inferInsert;
export type MissionCoachRunRow = typeof missionCoachRuns.$inferSelect;
export type GrowHomeNotificationRow = typeof growHomeNotifications.$inferSelect;
export type NewGrowHomeNotification = typeof growHomeNotifications.$inferInsert;
export type SystemNotificationRow = typeof systemNotifications.$inferSelect;
export type NewSystemNotification = typeof systemNotifications.$inferInsert;
export type SystemNotificationPreferenceRow = typeof systemNotificationPreferences.$inferSelect;

View File

@@ -333,6 +333,12 @@ export async function provisionUserStack(userId: string): Promise<UserStack> {
const existing = await db.query.userStacks.findFirst({
where: eq(userStacks.userId, userId),
});
if (existing && existing.status === "provisioning") {
const ageMs = Date.now() - existing.updatedAt.getTime();
if (ageMs < 5 * 60_000) return existing;
log.warn({ userId, updatedAt: existing.updatedAt }, "stale OpenCode provisioning row; retrying");
await stopUserStack(userId);
}
if (existing && existing.status === "running") {
const current =
existing.imageVersion === config.opencodeImageVersion &&
@@ -440,6 +446,8 @@ export async function provisionUserStack(userId: string): Promise<UserStack> {
branch: "main",
});
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
if (message.includes("repository file already exists")) continue;
log.warn({ err, path: file.path }, "failed to init repo file (non-fatal)");
}
}

View File

@@ -8,8 +8,10 @@ export type GrowEventCategory =
| "system";
export type GrowEventSubject = {
kind: string;
id: string;
kind?: string;
id?: string;
serviceId?: string;
externalId?: string;
};
export type GrowEventMissionRef = {

View File

@@ -12,6 +12,9 @@ function normalizeSubject(value: unknown) {
const record = asRecord(value);
const kind = getString(record.kind);
const id = getString(record.id);
const serviceId = getString(record.serviceId ?? record.service_id);
const externalId = getString(record.externalId ?? record.external_id);
if (serviceId || externalId) return { serviceId, externalId };
return kind && id ? { kind, id } : undefined;
}
@@ -48,6 +51,9 @@ export function normalizeGrowEvent(input: unknown, overrides: { userId?: string;
request_id: raw.request_id ?? payload.request_id,
});
const subject = normalizeSubject(raw.subject) ?? (() => {
const serviceId = getString(raw.subject_service_id ?? payload.subject_service_id);
const externalId = getString(raw.subject_external_id ?? payload.subject_external_id);
if (serviceId || externalId) return { serviceId, externalId };
const kind = getString(raw.subject_kind ?? payload.subject_kind);
const id = getString(raw.subject_id ?? payload.subject_id);
return kind && id ? { kind, id } : undefined;

View File

@@ -0,0 +1,199 @@
import { and, desc, eq, inArray } from "drizzle-orm";
import { db } from "../db/client.js";
import { growEvents, type GrowEventRow } from "../db/schema.js";
import { asRecord } from "./envelope.js";
import {
markGrowEventFailed,
markGrowEventProcessed,
markGrowEventProcessing,
recordGrowEvent,
} from "./record-grow-event.js";
import { ensureOnboardingBaselineQscoreForCompletedAt } from "./onboarding-qscore.js";
import {
onboardingCompletedAtFromEvent,
runCuratorOnboardingLoopSafely,
} from "../v1/curator/curator-onboarding-loop.js";
export const ONBOARDING_LEDGER_EVENT_TYPES = [
"onboarding.snapshot.saved",
"onboarding.completed",
"user.onboarding.completed",
"profile.onboarding.completed",
] as const;
export type OnboardingLedgerEventType = (typeof ONBOARDING_LEDGER_EVENT_TYPES)[number];
export const ONBOARDING_LEDGER_QUERY_TYPES = [
...ONBOARDING_LEDGER_EVENT_TYPES,
"onboarding_snapshot_saved",
"onboarding_completed",
"user_onboarding_completed",
"profile_onboarding_completed",
] as const;
export type OnboardingStatusEvent = {
id: string;
type: string;
occurredAt: Date;
processingStatus: string;
};
const COMPLETION_EVENT_TYPES = new Set<string>([
"onboarding.completed",
"user.onboarding.completed",
"profile.onboarding.completed",
]);
export function normalizeOnboardingEventType(type: string) {
return type.toLowerCase().replaceAll("_", ".");
}
export function completedAtFromOnboardingPayload(payload: Record<string, unknown> | null | undefined) {
const data = payload ?? {};
const preferences = asRecord(data.preferences);
const onboarding = asRecord(data.onboarding ?? preferences.onboarding);
const candidate =
data.completedAt ??
data.completed_at ??
data.onboardingCompletedAt ??
data.onboarding_completed_at ??
onboarding.completed_at ??
onboarding.completedAt ??
asRecord(preferences.onboarding).completed_at ??
asRecord(preferences.onboarding).completedAt;
if (candidate instanceof Date) {
return Number.isNaN(candidate.getTime()) ? undefined : candidate.toISOString();
}
if (typeof candidate !== "string" || !candidate.trim()) return undefined;
const parsed = new Date(candidate);
return Number.isNaN(parsed.getTime()) ? new Date().toISOString() : parsed.toISOString();
}
export function isValidOnboardingLedgerEvent(event: Pick<GrowEventRow, "type" | "payload">) {
const normalizedType = normalizeOnboardingEventType(event.type);
if (COMPLETION_EVENT_TYPES.has(normalizedType)) return true;
// Snapshots are status-valid only when they are completion snapshots. Plain
// intermediate step saves must not let a new seeker bypass onboarding.
if (normalizedType === "onboarding.snapshot.saved") {
return Boolean(completedAtFromOnboardingPayload(event.payload));
}
return false;
}
export async function getLatestValidOnboardingLedgerEvent(userId: string): Promise<OnboardingStatusEvent | null> {
const rows = await db
.select({
id: growEvents.id,
type: growEvents.type,
payload: growEvents.payload,
occurredAt: growEvents.occurredAt,
processingStatus: growEvents.processingStatus,
})
.from(growEvents)
.where(
and(
eq(growEvents.userId, userId),
inArray(growEvents.type, [...ONBOARDING_LEDGER_QUERY_TYPES]),
),
)
.orderBy(desc(growEvents.occurredAt))
.limit(25);
const row = rows.find((event) => isValidOnboardingLedgerEvent(event));
if (!row) return null;
const { payload: _payload, ...statusEvent } = row;
return statusEvent;
}
export async function ensureOnboardingBaselineQscoreFromLedger(userId: string) {
const event = await getLatestValidOnboardingLedgerEvent(userId);
if (!event) return false;
return ensureOnboardingBaselineQscoreForCompletedAt(userId, event.occurredAt);
}
function onboardingContextFromInput(context?: Record<string, unknown>) {
const input = context ?? {};
const preferences = asRecord(input.preferences);
const onboarding = asRecord(input.onboarding ?? preferences.onboarding);
return {
...input,
onboarding,
preferences: Object.keys(preferences).length ? preferences : { onboarding },
};
}
export async function ensureOnboardingSideEffectsForEvent(event: GrowEventRow) {
if (!event.userId || !isValidOnboardingLedgerEvent(event)) {
return {
qscoreBaselineSeeded: false,
curatorOnboarding: { status: "skipped" as const, reason: event.userId ? "not_onboarding_completion" : "missing_user_id" },
missions: { status: "skipped" as const, reason: event.userId ? "not_onboarding_completion" : "missing_user_id", started: [], existing: [] },
};
}
const completedAt =
onboardingCompletedAtFromEvent(event) ??
completedAtFromOnboardingPayload(event.payload) ??
event.occurredAt.toISOString();
const qscoreBaselineSeeded = await ensureOnboardingBaselineQscoreForCompletedAt(event.userId, completedAt);
const curatorOnboarding = await runCuratorOnboardingLoopSafely({
userId: event.userId,
completedAt,
sourceEventId: event.id,
source: event.source,
context: onboardingContextFromInput(event.payload),
});
const missions = {
status: "skipped" as const,
reason: "static_curator_sprint_enabled" as const,
started: [],
existing: [],
};
return { qscoreBaselineSeeded, curatorOnboarding, missions };
}
export async function recordAndProcessOnboardingCompletion(input: {
userId: string;
completedAt: string | Date;
source?: string;
context?: Record<string, unknown>;
}) {
const completedAt =
input.completedAt instanceof Date
? input.completedAt.toISOString()
: input.completedAt;
const context = onboardingContextFromInput(input.context);
const event = await recordGrowEvent(
{
source: input.source ?? "onboarding",
type: "onboarding.completed",
category: "usage",
userId: input.userId,
occurredAt: completedAt,
payload: {
completedAt,
...context,
},
dedupeKey: `onboarding:completed:${input.userId}`,
},
{ userId: input.userId, source: input.source ?? "onboarding" },
);
if (event.processingStatus !== "processed") {
await markGrowEventProcessing(event.id);
}
const sideEffects = await ensureOnboardingSideEffectsForEvent(event);
if (sideEffects.curatorOnboarding.status === "skipped" && sideEffects.curatorOnboarding.reason === "loop_failed") {
await markGrowEventFailed(event.id, new Error("curator_onboarding_loop_failed"));
} else if (event.processingStatus !== "processed") {
await markGrowEventProcessed(event.id);
}
return { event, ...sideEffects };
}

View File

@@ -9,12 +9,18 @@ function asRecord(value: unknown): Record<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : {};
}
function parseCompletedAt(value: unknown): Date | null {
if (value instanceof Date) {
return Number.isNaN(value.getTime()) ? null : value;
}
if (typeof value !== "string" || !value.trim()) return null;
const parsed = new Date(value);
return Number.isNaN(parsed.getTime()) ? new Date() : parsed;
}
function onboardingCompletedAt(preferences: Record<string, unknown> | undefined): Date | null {
const onboarding = asRecord(preferences?.onboarding);
const completedAt = onboarding.completed_at;
if (typeof completedAt !== "string" || !completedAt.trim()) return null;
const parsed = new Date(completedAt);
return Number.isNaN(parsed.getTime()) ? new Date() : parsed;
return parseCompletedAt(onboarding.completed_at ?? onboarding.completedAt);
}
/**
@@ -32,7 +38,15 @@ export async function ensureOnboardingBaselineQscore(
): Promise<boolean> {
const completedAt = onboardingCompletedAt(preferences);
if (!completedAt) return false;
return ensureOnboardingBaselineQscoreForCompletedAt(userId, completedAt);
}
export async function ensureOnboardingBaselineQscoreForCompletedAt(
userId: string,
completedAtInput: string | Date,
): Promise<boolean> {
const completedAt = parseCompletedAt(completedAtInput);
if (!completedAt) return false;
const latestSignals = await db
.select({ signalId: growQscoreLatest.signalId, score: growQscoreLatest.score })
.from(growQscoreLatest)

View File

@@ -113,11 +113,58 @@ function extractRoleplaySignals(event: GrowEventRow): QscoreSignal[] {
return signals;
}
function sourceSignalPrefix(source: string) {
return source
.toLowerCase()
.replace(/-service$/, "")
.replace(/[^a-z0-9]+/g, "_")
.replace(/^_+|_+$/g, "") || "service";
}
function extractScoredServiceSignals(event: GrowEventRow): QscoreSignal[] {
const payload = event.payload ?? {};
const review = asRecord(payload.review ?? payload.result ?? payload);
const status = String(review.status ?? payload.status ?? "");
const isCompletion =
event.type.includes("completed") ||
event.type.includes("updated") ||
event.type.includes("signal_projected") ||
event.type.includes("signal.projected") ||
status === "completed";
if (!isCompletion) return [];
const score = getNumber(
payload.score ??
payload.qscore ??
payload.q_score ??
payload.readiness_score ??
payload.overall_score ??
review.score ??
review.qscore ??
review.q_score ??
review.readiness_score ??
review.overall_score,
);
if (score === undefined) return [];
const prefix = sourceSignalPrefix(event.source);
return [
signal(`${prefix}.service_completion_score`, score, {
eventId: event.id,
source: event.source,
type: event.type,
}),
];
}
export function extractQscoreSignals(event: GrowEventRow): QscoreSignal[] {
const source = event.source.toLowerCase();
if (source.includes("resume") || event.type.startsWith("resume.")) return extractResumeSignals(event);
if (source.includes("interview") || event.type.startsWith("interview.")) return extractInterviewSignals(event);
if (source.includes("roleplay") || event.type.startsWith("roleplay.")) return extractRoleplaySignals(event);
if (source.includes("qscore") || event.type.startsWith("qscore.")) return extractScoredServiceSignals(event);
const scoredServiceSignals = extractScoredServiceSignals(event);
if (scoredServiceSignals.length) return scoredServiceSignals;
if (event.type === "mission.interview_to_offer.started") {
return [signal("goals.goals_set", 100, { eventId: event.id })];
}

View File

@@ -22,7 +22,7 @@ function statusFor(event: GrowEventRow): string {
const payload = event.payload ?? {};
const explicit = getString(payload.status);
if (explicit) return explicit;
if (event.type.includes("review_completed") || event.type.includes("completed")) return "completed";
if (event.type.includes("review_completed") || event.type.includes("feedback.generated") || event.type.includes("completed")) return "completed";
if (event.type.includes("failed")) return "failed";
if (event.type.includes("configured") || event.type.includes("created")) return "active";
return "active";

View File

@@ -1,8 +1,15 @@
import { randomUUID } from "node:crypto";
import { config } from "../config.js";
import { log } from "../log.js";
import { recordGrowEvent } from "./record-grow-event.js";
import {
markGrowEventFailed,
markGrowEventProcessed,
markGrowEventProcessing,
recordGrowEvent,
} from "./record-grow-event.js";
import { routeGrowEventToUserActor } from "./route-to-user-actor.js";
import { applyQscoreProjection } from "./projectors/qscore-projector.js";
import { ensureOnboardingSideEffectsForEvent } from "./onboarding-ledger.js";
// This file has two Redis ingestion modes:
// 1. Canonical GrowEvent stream: grow.events.raw — future service event bus.
@@ -100,26 +107,26 @@ function actionToEventType(serviceId: ServiceRedisSpec["serviceId"], action: str
const effective = msgAction || action || "event";
if (serviceId === "interview") {
if (effective === "interview_configured" || action === "configure_interview") return "interview.configured";
if (effective === "interview_configured" || action === "configure_interview") return "interview.session.configured";
if (effective === "review_loaded") {
const data = asRecord(message.data);
return data.status === "completed" ? "interview.review_completed" : "interview.review_processing";
return data.status === "completed" ? "interview.feedback.generated" : "interview.feedback.processing";
}
if (effective === "interview_page_loaded") return "interview.page_state_loaded";
return `interview.${effective.replaceAll("_", ".")}`;
}
if (serviceId === "roleplay") {
if (effective === "roleplay_configured" || action === "configure_roleplay") return "roleplay.configured";
if (effective === "roleplay_configured" || action === "configure_roleplay") return "roleplay.scenario.configured";
if (effective === "roleplay_review_loaded" || effective === "review_loaded") {
const data = asRecord(message.data);
return data.status === "completed" ? "roleplay.review_completed" : "roleplay.review_processing";
return data.status === "completed" ? "roleplay.feedback.generated" : "roleplay.feedback.processing";
}
if (effective === "roleplay_page_loaded") return "roleplay.page_state_loaded";
return `roleplay.${effective.replaceAll("_", ".")}`;
}
if (effective === "ai_analysis_complete" || action === "ai_analyze") return "resume.analysis_completed";
if (effective === "ai_analysis_complete" || action === "ai_analyze") return "resume.analysis.completed";
if (effective === "resume_loaded") return "resume.loaded";
if (effective === "resume_parsed") return "resume.parsed";
return `resume.${effective.replaceAll("_", ".")}`;
@@ -150,6 +157,23 @@ async function recordAndRoute(input: unknown) {
await routeGrowEventToUserActor(event).catch((err) => {
log.warn({ err, eventId: event.id, userId: event.userId }, "failed to route grow event to user actor");
});
if (!event.userId || event.processingStatus === "processed") return event;
await markGrowEventProcessing(event.id);
try {
await applyQscoreProjection(event);
const onboarding = await ensureOnboardingSideEffectsForEvent(event);
if (
onboarding.curatorOnboarding.status === "skipped" &&
onboarding.curatorOnboarding.reason === "loop_failed"
) {
throw new Error("curator_onboarding_loop_failed");
}
await markGrowEventProcessed(event.id);
} catch (err) {
await markGrowEventFailed(event.id, err);
throw err;
}
return event;
}

View File

@@ -1,7 +1,17 @@
import { config } from "../config.js";
import { getService, listServices, type ServiceId } from "../services/service-registry.js";
export type GrowServiceId = "resume-service" | "interview-service" | "roleplay-service" | "qscore-service" | "social-branding-service" | "matchmaking-service";
export type GrowFeatureId = "resume-building" | "mock-interview" | "mock-roleplay" | "q-score" | "social-branding" | "matchmaking";
export type GrowServiceId = ServiceId;
export type GrowFeatureId =
| "resume-building"
| "cover-letter"
| "mock-interview"
| "mock-roleplay"
| "q-score"
| "social-branding"
| "matchmaking"
| "pathways"
| "courses"
| "assessment";
export type GrowFeatureDefinition = {
id: GrowFeatureId;
@@ -16,77 +26,18 @@ export type GrowFeatureDefinition = {
operations: string[];
};
export const featureDefinitions: GrowFeatureDefinition[] = [
{
id: "resume-building",
serviceId: "resume-service",
title: "Resume Building",
label: "Resume",
description: "Build, tailor, analyze, and improve resumes for role fit and ATS readiness.",
promptModulePath: "agents/resume.md",
enabled: Boolean(config.resumeServiceUrl),
internalUrl: config.resumeServiceUrl,
publicUrl: config.resumePublicUrl,
operations: ["resume.state", "resume.templates", "resume.a2aTask", "resume.create", "resume.update", "resume.analyze", "resume.suggestions", "resume.copilot", "resume.optimizeSummary", "resume.optimizeExperience", "resume.suggestSkills", "resume.generateSummary", "resume.versions", "resume.preview"],
},
{
id: "mock-interview",
serviceId: "interview-service",
title: "Mock Interview",
label: "Interview",
description: "Configure, practice, review, and score interview sessions.",
promptModulePath: "agents/interview.md",
enabled: Boolean(config.interviewServiceUrl),
internalUrl: config.interviewServiceUrl,
publicUrl: config.interviewPublicUrl,
operations: ["interview.configure", "interview.preview", "interview.questions", "interview.approve", "interview.assignments", "interview.unassign", "interview.resultsBulk", "interview.review", "interview.leaderboard", "interview.artifacts", "interview.videoUpload", "interview.practice"],
},
{
id: "mock-roleplay",
serviceId: "roleplay-service",
title: "Mock Roleplay",
label: "Roleplay",
description: "Practice negotiations, recruiter calls, manager conversations, and stakeholder roleplays.",
promptModulePath: "agents/roleplay.md",
enabled: Boolean(config.roleplayServiceUrl),
internalUrl: config.roleplayServiceUrl,
publicUrl: config.roleplayPublicUrl,
operations: ["roleplay.configure", "roleplay.preview", "roleplay.questions", "roleplay.approve", "roleplay.assignments", "roleplay.unassign", "roleplay.resultsBulk", "roleplay.review", "roleplay.leaderboard", "roleplay.artifacts", "roleplay.videoUpload", "roleplay.practice"],
},
{
id: "q-score",
serviceId: "qscore-service",
title: "Q Score",
label: "Q Score",
description: "Analyze overall job-market readiness and convert signals into improvement priorities.",
promptModulePath: "agents/qscore.md",
enabled: Boolean(config.qscoreServiceUrl),
internalUrl: config.qscoreServiceUrl,
operations: ["qscore.ingest", "qscore.compute"],
},
{
id: "social-branding",
serviceId: "social-branding-service",
title: "Social Branding",
label: "Branding",
description: "Build and optimize your professional profile, LinkedIn presence, and personal brand.",
promptModulePath: "agents/social-branding.md",
enabled: Boolean(config.socialBrandingServiceUrl),
internalUrl: config.socialBrandingServiceUrl,
operations: ["branding.profile", "branding.linkedin", "branding.content", "branding.analyze"],
},
{
id: "matchmaking",
serviceId: "matchmaking-service",
title: "Matchmaking",
label: "Matchmaking",
description: "Connect with relevant professionals, mentors, and opportunities through curated matching.",
promptModulePath: "agents/matchmaking.md",
enabled: Boolean(config.matchmakingServiceUrl),
internalUrl: config.matchmakingServiceUrl,
operations: ["matchmaking.find", "matchmaking.connect", "matchmaking.schedule", "matchmaking.review"],
},
];
export const featureDefinitions: GrowFeatureDefinition[] = listServices().map((service) => ({
id: service.featureId as GrowFeatureId,
serviceId: service.id,
title: service.label,
label: service.label,
description: service.description,
promptModulePath: service.promptModulePath,
enabled: service.enabled,
internalUrl: service.backend.baseUrl,
publicUrl: service.backend.publicUrl,
operations: Object.keys(service.backend.endpoints),
}));
export const internalWorkflowModules = [
{
@@ -103,7 +54,8 @@ export function listFeatureDefinitions() {
}
export function getFeatureByServiceId(serviceId: string) {
return featureDefinitions.find((feature) => feature.serviceId === serviceId);
const service = getService(serviceId);
return service ? featureDefinitions.find((feature) => feature.serviceId === service.id) : undefined;
}
export function displayLabelForService(serviceId: string | undefined) {

View File

@@ -274,6 +274,20 @@ export async function listActiveMissionsPg(userId: string) {
return rows.map((row) => ({ mission: activeMissionFromRow(row), snapshot: missionSnapshotFromRow(row) }));
}
export async function listActiveMissionsForPassiveReviewPg(opts: { userId?: string; limit?: number } = {}) {
const conditions = [eq(growActiveMissions.status, "active")];
if (opts.userId) conditions.push(eq(growActiveMissions.userId, opts.userId));
const query = db
.select()
.from(growActiveMissions)
.where(and(...conditions))
.orderBy(asc(growActiveMissions.updatedAt));
const rows = typeof opts.limit === "number" && opts.limit > 0
? await query.limit(opts.limit)
: await query;
return rows.map((row) => ({ userId: row.userId, mission: activeMissionFromRow(row), snapshot: missionSnapshotFromRow(row) }));
}
export async function getActiveMissionPg(userId: string, instanceId: string) {
const [row] = await db.select().from(growActiveMissions).where(and(eq(growActiveMissions.userId, userId), eq(growActiveMissions.instanceId, instanceId))).limit(1);
return row ? { mission: activeMissionFromRow(row), snapshot: missionSnapshotFromRow(row) } : null;

View File

@@ -16,14 +16,34 @@ const notificationSchema = z.object({
reason: z.string().max(160).optional(),
});
const rawNotificationSchema = notificationSchema.extend({
moduleId: z.enum(MODULE_IDS as [HomeModuleId, ...HomeModuleId[]]).optional(),
tag: z.string().min(2).max(14).optional(),
href: z.string().min(1).optional(),
source: z.enum(["resume", "interview", "roleplay", "qscore", "mission", "social", "pathways", "rewards", "system"]).optional(),
});
const feedSchema = z.object({
notifications: z.array(notificationSchema).min(6).max(24),
});
const HOME_FEED_AGENT_TIMEOUT_MS = Number(process.env.HOME_FEED_AGENT_TIMEOUT_MS ?? 20000);
const rawFeedSchema = z.object({
notifications: z.array(rawNotificationSchema).min(1).max(24),
});
const HOME_FEED_AGENT_ENABLED = process.env.HOME_FEED_AGENT_ENABLED === "true";
const HOME_FEED_AGENT_TIMEOUT_MS = Number(process.env.HOME_FEED_AGENT_TIMEOUT_MS ?? 5000);
const HOME_FEED_AGENT_ATTEMPTS = Math.max(1, Number(process.env.HOME_FEED_AGENT_ATTEMPTS ?? 1));
export type AgentHomeNotification = z.infer<typeof notificationSchema>;
export class HomeFeedAgentError extends Error {
constructor(message: string, readonly cause?: unknown) {
super(message);
this.name = "HomeFeedAgentError";
}
}
const SYSTEM = `You are GrowQR's Home Feed Agent.
Your job is to rank and rewrite dashboard notifications from real platform context.
Keep them coherent, specific, and action-oriented. Do not invent unavailable products, scores, sessions, deadlines, companies, artifacts, or rewards.
@@ -31,29 +51,122 @@ Every notification must point to one of these real dashboard routes:
- /agents/resume for resume building, resume analysis, ATS, resume suggestions
- /agents/interview for mock interview setup, interview session, interview review
- /agents/roleplay for recruiter/manager/salary/stakeholder roleplay
- /agents/qscore for Q Score/readiness explanations
- /missions for mission progress, approvals, artifacts, next stages
- /social for LinkedIn/social branding
- /pathways for locked/coming-soon pathways
- /agents/social-branding for LinkedIn/social branding
- /agents/matchmaking for Scout/opportunity matching
- /rewards for locked/coming-soon rewards
- /suggestions for broad onboarding/profile suggestions
Every notification object must include:
- moduleId: one of ${MODULE_IDS.join(", ")}
- source: one of resume, interview, roleplay, mission, social, pathways, rewards, system
Use minimal iPhone-notification copy: title <= 72 chars, subtitle <= 110 chars, short tag <= 14 chars.
Use urgency truthfully: now = needs immediate user action, today = useful today, soon = next few days, calm = informational.`;
function sanitizeHref(href: string, moduleId: HomeModuleId) {
if (isAllowedNotificationHref(href)) return href;
if (href.startsWith("/missions")) return "/missions/active";
if (href.startsWith("/social")) return "/social";
if (href.startsWith("/pathways")) return "/pathways";
if (href.startsWith("/agents/social-branding")) return "/agents/social-branding";
if (href.startsWith("/agents/matchmaking")) return "/agents/matchmaking";
if (href.startsWith("/rewards")) return "/rewards";
if (href.startsWith("/productivity")) return "/productivity";
return moduleId === "productivity" ? "/productivity" : `/${moduleId}`;
return moduleId === "productivity" ? "/agents" : `/${moduleId}`;
}
function stableId(prefix: string, index: number) {
return `${prefix}-${index + 1}`;
}
function sourceFromHref(href: string) {
if (href.startsWith("/agents/resume")) return "resume";
if (href.startsWith("/agents/interview")) return "interview";
if (href.startsWith("/agents/roleplay")) return "roleplay";
if (href.startsWith("/missions")) return "mission";
if (href.startsWith("/agents/social-branding")) return "social";
if (href.startsWith("/agents/matchmaking")) return "pathways";
if (href.startsWith("/rewards")) return "rewards";
return "system";
}
function moduleFromSource(source: NonNullable<AgentHomeNotification["source"]>): HomeModuleId {
if (source === "mission") return "missions";
if (source === "social") return "social";
if (source === "pathways") return "pathways";
if (source === "rewards") return "rewards";
if (source === "resume" || source === "interview" || source === "roleplay") return "productivity";
return "suggestions";
}
function tagFromSource(source: NonNullable<AgentHomeNotification["source"]>) {
if (source === "mission") return "Mission";
if (source === "roleplay") return "Roleplay";
if (source === "interview") return "Interview";
if (source === "resume") return "Resume";
if (source === "social") return "Social";
if (source === "pathways") return "Pathways";
if (source === "rewards") return "Rewards";
return "Update";
}
function defaultHrefForSource(source: NonNullable<AgentHomeNotification["source"]>, moduleId: HomeModuleId) {
if (source === "resume") return "/agents/resume";
if (source === "interview") return "/agents/interview";
if (source === "roleplay") return "/agents/roleplay";
if (source === "mission") return "/missions";
if (source === "social") return "/agents/social-branding";
if (source === "pathways") return "/agents/matchmaking";
if (source === "rewards") return "/rewards";
return moduleId === "productivity" ? "/agents" : `/${moduleId}`;
}
function normalizeAgentNotification(
raw: z.infer<typeof rawNotificationSchema>,
seeds: Array<Omit<HomeNotification, "id" | "createdAt"> & { moduleId: HomeModuleId }>,
): AgentHomeNotification {
const seed = (raw.href ? seeds.find((item) => item.href === raw.href) : undefined)
?? seeds.find((item) => item.title.toLowerCase() === raw.title.toLowerCase());
const inferredSource = raw.source ?? seed?.source;
const normalizedSource = inferredSource === "qscore" ? "pathways" : inferredSource;
const moduleId = raw.moduleId ?? seed?.moduleId ?? (normalizedSource ? moduleFromSource(normalizedSource) : "suggestions");
const rawHref = raw.href ?? seed?.href ?? (normalizedSource ? defaultHrefForSource(normalizedSource, moduleId) : `/${moduleId}`);
const href = sanitizeHref(rawHref, moduleId);
const seedSource = seed?.source === "qscore" ? "pathways" : seed?.source;
const source = raw.source ?? seedSource ?? sourceFromHref(href);
return notificationSchema.parse({
...raw,
tag: raw.tag ?? seed?.tag ?? tagFromSource(source),
href,
source,
moduleId,
});
}
function notificationKey(notification: AgentHomeNotification) {
return [
notification.moduleId,
notification.href,
notification.title.trim().toLowerCase(),
].join(":");
}
function completeNotificationsWithSeeds(
notifications: AgentHomeNotification[],
seeds: Array<Omit<HomeNotification, "id" | "createdAt"> & { moduleId: HomeModuleId }>,
) {
const completed = [...notifications];
const seen = new Set(completed.map(notificationKey));
for (const seed of seeds) {
if (completed.length >= 6) break;
const candidateSeed = seed.source === "qscore" ? { ...seed, source: "pathways" as const, href: seed.href?.startsWith("/agents/qscore") ? "/agents/matchmaking" : seed.href } : seed;
const candidate = normalizeAgentNotification(candidateSeed, seeds);
const key = notificationKey(candidate);
if (seen.has(key)) continue;
completed.push(candidate);
seen.add(key);
}
return feedSchema.parse({ notifications: completed }).notifications;
}
function parseJsonObject(text: string) {
const cleaned = text.trim().replace(/^```(?:json)?/i, "").replace(/```$/i, "").trim();
try {
@@ -71,36 +184,48 @@ export async function refineHomeNotificationsWithAgent(input: {
context: Record<string, unknown>;
seeds: Array<Omit<HomeNotification, "id" | "createdAt"> & { moduleId: HomeModuleId }>;
}): Promise<Array<AgentHomeNotification & { id: string; createdAt: string }>> {
if (!HOME_FEED_AGENT_ENABLED) return [];
if (!config.llmApiKey && config.nodeEnv === "production") {
throw new HomeFeedAgentError("home_feed_agent_missing_llm_api_key");
}
if (!config.llmApiKey) return [];
try {
const result = await generateText({
model: getConversationModel(),
system: [
SYSTEM,
"Return JSON only. Shape: {\"notifications\": [...]}. Do not use markdown.",
"Use ASCII punctuation only.",
].join("\n"),
timeout: HOME_FEED_AGENT_TIMEOUT_MS,
prompt: JSON.stringify({
task: "Create coherent GrowQR home dashboard notifications from the provided service context and deterministic candidates.",
userId: input.userId,
serviceContext: input.context,
deterministicCandidates: input.seeds,
}),
});
let lastError: unknown;
for (let attempt = 1; attempt <= HOME_FEED_AGENT_ATTEMPTS; attempt += 1) {
try {
const result = await generateText({
model: getConversationModel(),
system: [
SYSTEM,
"Return JSON only. Shape: {\"notifications\": [...]}. Do not use markdown.",
"Use ASCII punctuation only.",
].join("\n"),
timeout: HOME_FEED_AGENT_TIMEOUT_MS,
prompt: JSON.stringify({
task: "Create coherent GrowQR home dashboard notifications from the provided service context and deterministic candidates.",
userId: input.userId,
serviceContext: input.context,
deterministicCandidates: input.seeds,
}),
});
const parsed = feedSchema.parse(parseJsonObject(result.text));
const now = new Date().toISOString();
return parsed.notifications.map((n, index) => ({
...n,
href: sanitizeHref(n.href, n.moduleId),
urgency: n.urgency as HomeUrgency,
id: stableId("agent-home", index),
createdAt: now,
}));
} catch (err) {
log.warn({ err, userId: input.userId }, "home feed agent failed; using deterministic notifications");
return [];
const parsed = rawFeedSchema.parse(parseJsonObject(result.text));
const notifications = completeNotificationsWithSeeds(
parsed.notifications.map((item) => normalizeAgentNotification(item, input.seeds)),
input.seeds,
);
const now = new Date().toISOString();
return notifications.map((n, index) => ({
...n,
urgency: n.urgency as HomeUrgency,
id: stableId("agent-home", index),
createdAt: now,
}));
} catch (err) {
lastError = err;
log.debug({ err, userId: input.userId, attempt, attempts: HOME_FEED_AGENT_ATTEMPTS }, "home feed agent attempt failed");
}
}
throw new HomeFeedAgentError("home_feed_agent_generation_failed", lastError);
}

View File

@@ -1,5 +1,6 @@
import { and, desc, eq, gt, inArray, isNull, or, sql } from "drizzle-orm";
import { db } from "../db/client.js";
import { log } from "../log.js";
import {
growActiveMissions,
growEvents,
@@ -15,10 +16,12 @@ import {
type NewGrowHomeNotification,
} from "../db/schema.js";
import { interviewService, resumeService, roleplayService } from "../services/product-service-clients.js";
import { ensureOnboardingBaselineQscore } from "../events/onboarding-qscore.js";
import { refineHomeNotificationsWithAgent } from "./home-feed-agent.js";
import { buildServiceLink } from "../services/service-registry.js";
import { ensureOnboardingBaselineQscoreFromLedger } from "../events/onboarding-ledger.js";
import { HomeFeedAgentError, refineHomeNotificationsWithAgent } from "./home-feed-agent.js";
import { listAvailableMissionDefinitions } from "../missions/registry.js";
import { listServiceCapabilities } from "../workflows/service-capabilities.js";
import { buildCuratorSprint } from "../v1/curator/curator-store.js";
import {
isAllowedNotificationHref,
MODULE_IDS,
@@ -33,18 +36,17 @@ import {
const FRESH_MS = 10 * 60 * 1000;
const EXPIRY_MS = 24 * 60 * 60 * 1000;
const MISSION_MODULE_LIMIT = 4;
const SERVICE_HREFS = {
resume: "/agents/resume",
interview: "/agents/interview",
roleplay: "/agents/roleplay",
qscore: "/agents/qscore",
resume: buildServiceLink("resume-service", "workspace") ?? "/agents/resume",
interview: buildServiceLink("interview-service", "discovery") ?? "/agents/interview",
roleplay: buildServiceLink("roleplay-service", "discovery") ?? "/agents/roleplay",
mission: "/missions/active",
social: "/social",
pathways: "/pathways",
pathways: buildServiceLink("matchmaking-service", "jobs") ?? "/agents/matchmaking",
rewards: "/rewards",
suggestions: "/suggestions",
productivity: "/productivity",
productivity: buildServiceLink("courses-service", "catalog") ?? "/agents/courses",
} as const;
type SeedNotification = Omit<HomeNotification, "id" | "createdAt"> & { moduleId: HomeModuleId; priority: number };
@@ -99,22 +101,24 @@ function profileFromPreferences(preferences: Record<string, unknown>) {
};
}
function serviceHref(service: "resume" | "interview" | "roleplay" | "qscore", ctx: HomeContext, mission?: { instanceId?: string; missionId?: string; stageId?: string | null }) {
function serviceHref(service: "resume" | "interview" | "roleplay", ctx: HomeContext, mission?: { instanceId?: string; missionId?: string; stageId?: string | null }) {
const profile = profileFromPreferences(ctx.preferences);
const params = new URLSearchParams({ source: "home" });
if (mission?.instanceId) params.set("missionInstanceId", mission.instanceId);
if (mission?.missionId) params.set("missionId", mission.missionId);
if (mission?.stageId) params.set("stageId", mission.stageId);
params.set("targetRole", profile.targetRole);
if (profile.targetCompany !== "target company") params.set("targetCompany", profile.targetCompany);
if (profile.industry) params.set("industry", profile.industry);
if (profile.focusAreas.length) params.set("focusAreas", profile.focusAreas.slice(0, 4).join(","));
if (profile.weakSpots.length) params.set("weakSpots", profile.weakSpots.slice(0, 3).join(","));
if (profile.jobDescription) params.set("jobDescription", profile.jobDescription.slice(0, 900));
if (service === "interview") return `/agents/interview/setup?${params.toString()}`;
if (service === "roleplay") return `/agents/roleplay/setup?${params.toString()}`;
if (service === "resume") return `/agents/resume?${params.toString()}`;
return `/agents/qscore?${params.toString()}`;
const serviceId = `${service}-service`;
const pageId = service === "resume" ? "workspace" : "setup";
return buildServiceLink(serviceId, pageId, {
source: "home",
missionInstanceId: mission?.instanceId,
missionId: mission?.missionId,
stageId: mission?.stageId ?? undefined,
targetRole: profile.targetRole,
role: profile.targetRole,
targetCompany: profile.targetCompany !== "target company" ? profile.targetCompany : undefined,
industry: profile.industry,
focusAreas: profile.focusAreas.length ? profile.focusAreas.slice(0, 4).join(",") : undefined,
weakSpots: profile.weakSpots.length ? profile.weakSpots.slice(0, 3).join(",") : undefined,
jobDescription: profile.jobDescription?.slice(0, 900),
type: service === "interview" ? "behavioral" : undefined,
}) ?? SERVICE_HREFS[service];
}
function sourceFromSuggestionRole(role: string): HomeSource {
@@ -122,7 +126,7 @@ function sourceFromSuggestionRole(role: string): HomeSource {
if (value.includes("resume")) return "resume";
if (value.includes("roleplay")) return "roleplay";
if (value.includes("interview")) return "interview";
if (value.includes("q")) return "qscore";
if (value.includes("match") || value.includes("pathway") || value.includes("job")) return "pathways";
return "mission";
}
@@ -189,9 +193,8 @@ function buildDayOneSeeds(): SeedNotification[] {
{ id: "resume-service", moduleId: "productivity" as const, href: SERVICE_HREFS.resume, source: "resume" as const, urgency: "today" as const },
{ id: "interview-service", moduleId: "productivity" as const, href: SERVICE_HREFS.interview, source: "interview" as const, urgency: "today" as const },
{ id: "roleplay-service", moduleId: "productivity" as const, href: SERVICE_HREFS.roleplay, source: "roleplay" as const, urgency: "soon" as const },
{ id: "qscore-service", moduleId: "suggestions" as const, href: SERVICE_HREFS.qscore, source: "qscore" as const, urgency: "now" as const },
{ id: "social-branding-service", moduleId: "social" as const, href: SERVICE_HREFS.social, source: "social" as const, urgency: "soon" as const },
{ id: "matchmaking-service", moduleId: "pathways" as const, href: SERVICE_HREFS.pathways, source: "pathways" as const, urgency: "calm" as const },
{ id: "courses-service", moduleId: "productivity" as const, href: SERVICE_HREFS.productivity, source: "system" as const, urgency: "soon" as const },
{ id: "matchmaking-service", moduleId: "pathways" as const, href: SERVICE_HREFS.pathways, source: "pathways" as const, urgency: "today" as const },
];
for (const [index, card] of serviceCards.entries()) {
@@ -229,7 +232,14 @@ function buildDynamicSeeds(ctx: HomeContext): SeedNotification[] {
const roleplayReview = serviceEvent(ctx, "roleplay.", "review");
const resumeAnalysis = serviceEvent(ctx, "resume.", "analysis");
for (const suggestion of ctx.missionSuggestions.slice(0, 5)) {
const visibleMissionSuggestions = ctx.missionSuggestions
.filter((suggestion) => {
const haystack = `${suggestion.role} ${suggestion.title} ${suggestion.body} ${suggestion.ctaLabel} ${suggestion.ctaHref}`.toLowerCase();
return !haystack.includes("qscore") && !haystack.includes("q score") && !haystack.includes("assessment") && !haystack.includes("/agents/qscore");
})
.slice(0, 5);
for (const suggestion of visibleMissionSuggestions) {
const mission = ctx.activeMissions.find((item) => item.instanceId === suggestion.missionInstanceId);
const source = sourceFromSuggestionRole(suggestion.role);
const href = sanitizeHref(suggestion.ctaHref, mission ? `/missions/active?missionInstanceId=${encodeURIComponent(mission.instanceId)}` : SERVICE_HREFS.mission);
@@ -272,13 +282,13 @@ function buildDynamicSeeds(ctx: HomeContext): SeedNotification[] {
if (ctx.qscore || ctx.qscoreSignals.length) {
pushSeed(seeds, {
moduleId: "suggestions",
title: qscore >= 80 ? "Protect your Q Score momentum" : "Raise your Q Score next",
subtitle: qscore >= 80 ? `Readiness is trending at ${qscore}. Keep one proof action moving for ${profile.targetRole}.` : `Current estimate is ${qscore || 64}. Resume + mock practice are fastest for ${profile.targetRole}.`,
tag: "Q Score",
moduleId: "pathways",
title: qscore >= 80 ? "Review your best job matches" : "Find better-fit job matches",
subtitle: qscore >= 80 ? `Your profile signals are strong enough to compare matched roles for ${profile.targetRole}.` : `Use resume and interview signals to surface roles that fit ${profile.targetRole}.`,
tag: "Matches",
urgency: qscore >= 80 ? "today" : "now",
href: serviceHref("qscore", ctx),
source: "qscore",
href: SERVICE_HREFS.pathways,
source: "pathways",
priority: 95,
});
}
@@ -296,7 +306,7 @@ function buildDynamicSeeds(ctx: HomeContext): SeedNotification[] {
});
}
for (const mission of ctx.activeMissions.slice(0, 3)) {
for (const mission of ctx.activeMissions.slice(0, MISSION_MODULE_LIMIT)) {
pushSeed(seeds, {
moduleId: "missions",
title: `${mission.title}${mission.progressPercent}%`,
@@ -323,17 +333,6 @@ function buildDynamicSeeds(ctx: HomeContext): SeedNotification[] {
});
}
pushSeed(seeds, {
moduleId: "social",
title: "Turn proof into LinkedIn updates",
subtitle: ctx.artifacts.length ? `${ctx.artifacts.length} artifact${ctx.artifacts.length === 1 ? "" : "s"} can feed headline, featured, or post ideas.` : `Connect LinkedIn and use ${profile.targetRole} proof to improve your profile.`,
tag: ctx.artifacts.length ? "Proof" : "Setup",
urgency: ctx.artifacts.length ? "today" : "soon",
href: SERVICE_HREFS.social,
source: "social",
priority: 70,
});
if (resumeAnalysis || resumeSession || ats !== undefined) {
pushSeed(seeds, {
moduleId: "productivity",
@@ -377,7 +376,7 @@ function buildDynamicSeeds(ctx: HomeContext): SeedNotification[] {
}
if (!ctx.activeMissions.length) {
pushSeed(seeds, { moduleId: "missions", title: "Start Interview-to-Offer", subtitle: `Bundle resume fit, mock practice, and Q Score deltas for ${profile.targetRole}.`, tag: "Begin", urgency: "today", href: "/missions/available", source: "mission", priority: 80 });
pushSeed(seeds, { moduleId: "missions", title: "Start Interview-to-Offer", subtitle: `Bundle resume fit, mock practice, and matched roles for ${profile.targetRole}.`, tag: "Begin", urgency: "today", href: "/missions/available", source: "mission", priority: 80 });
}
return seeds;
@@ -395,7 +394,7 @@ async function collectContext(userId: string, input: { userProfile?: Record<stri
const activeMissions = await db
.select({ instanceId: growActiveMissions.instanceId, missionId: growActiveMissions.missionId, title: growActiveMissions.title, status: growActiveMissions.status, progressPercent: growActiveMissions.progressPercent, currentStageId: growActiveMissions.currentStageId, updatedAt: growActiveMissions.updatedAt })
.from(growActiveMissions)
.where(eq(growActiveMissions.userId, userId))
.where(and(eq(growActiveMissions.userId, userId), eq(growActiveMissions.status, "active")))
.orderBy(desc(growActiveMissions.updatedAt))
.limit(6);
const suggestions = await db
@@ -497,7 +496,6 @@ function hasLegacyMockSeed(rows: GrowHomeNotificationRow[]) {
"Complete your QX self-check",
"Create your interview room",
"Browse 1 career pathway",
"Start with your Q Score",
"Explore Interview-to-Offer",
"Pathways are warming up",
"Open Resume Builder",
@@ -553,8 +551,7 @@ function moduleCount(moduleId: HomeModuleId, notifications: HomeNotification[],
return String(notifications.length);
}
if (moduleId === "missions") {
if (ctx.activeMissions.length) return `${ctx.activeMissions.length} active`;
return mode === "day1" ? "0" : String(notifications.length);
return mode === "day1" && notifications.length === 0 ? "0" : String(Math.min(notifications.length, MISSION_MODULE_LIMIT));
}
if (moduleId === "productivity") {
const active = ctx.sessions.filter((s) => s.status === "active" || s.status === "configured" || s.status === "processing").length;
@@ -576,10 +573,11 @@ function buildModules(rows: GrowHomeNotificationRow[], ctx: HomeContext, mode: H
return MODULE_IDS.map((moduleId) => {
const notifications = byModule.get(moduleId) ?? [];
const visibleNotifications = moduleId === "missions" ? notifications.slice(0, MISSION_MODULE_LIMIT) : notifications;
return {
...MODULE_META[moduleId],
count: moduleCount(moduleId, notifications, ctx, mode),
notifications,
count: moduleCount(moduleId, visibleNotifications, ctx, mode),
notifications: visibleNotifications,
};
});
}
@@ -611,7 +609,8 @@ async function buildIdentity(ctx: HomeContext) {
}
export async function getHomeFeed(userId: string, opts: { refresh?: boolean; userProfile?: Record<string, unknown>; preferences?: Record<string, unknown> } = {}): Promise<HomeFeedResponse> {
await ensureOnboardingBaselineQscore(userId, opts.preferences);
await ensureOnboardingBaselineQscoreFromLedger(userId);
await buildCuratorSprint(userId);
const ctx = await collectContext(userId, { userProfile: opts.userProfile, preferences: opts.preferences });
const persisted = await readPersistedNotifications(userId);
const newest = persisted[0]?.createdAt?.getTime() ?? 0;
@@ -631,23 +630,29 @@ export async function getHomeFeed(userId: string, opts: { refresh?: boolean; use
const dayOneSeeds = buildDayOneSeeds();
const deterministic = hasAnyRealActivity(ctx) ? buildDynamicSeeds(ctx) : dayOneSeeds;
const agentNotifications = await refineHomeNotificationsWithAgent({
userId,
context: {
qscore: ctx.qscore,
qscoreSignals: ctx.qscoreSignals,
activeMissions: ctx.activeMissions,
sessions: ctx.sessions,
artifacts: ctx.artifacts,
recentEvents: ctx.events,
serviceStates: ctx.serviceStates,
missionSuggestions: ctx.missionSuggestions,
userProfile: ctx.userProfile,
preferences: ctx.preferences,
routeRules: SERVICE_HREFS,
},
seeds: deterministic,
});
let agentNotifications: Awaited<ReturnType<typeof refineHomeNotificationsWithAgent>> = [];
try {
agentNotifications = await refineHomeNotificationsWithAgent({
userId,
context: {
qscore: ctx.qscore,
qscoreSignals: ctx.qscoreSignals,
activeMissions: ctx.activeMissions,
sessions: ctx.sessions,
artifacts: ctx.artifacts,
recentEvents: ctx.events,
serviceStates: ctx.serviceStates,
missionSuggestions: ctx.missionSuggestions,
userProfile: ctx.userProfile,
preferences: ctx.preferences,
routeRules: SERVICE_HREFS,
},
seeds: deterministic,
});
} catch (err) {
if (!(err instanceof HomeFeedAgentError)) throw err;
log.info({ userId }, "home feed agent unavailable, using deterministic notifications");
}
const generatedBy = agentNotifications.length ? "agent" : "deterministic";
const generatedSeeds: SeedNotification[] = agentNotifications.length

View File

@@ -25,12 +25,12 @@ const demoNotifications: DemoNotification[] = [
{ moduleId: "missions", title: "2 approvals pending", subtitle: "Resume v6 and Mock #4 feedback need your confirmation.", tag: "Action", urgency: "now", href: "/missions", source: "mission", priority: 113 },
{ moduleId: "missions", title: "Final readiness unlock", subtitle: "Complete one roleplay recovery drill to generate the final checklist.", tag: "Next", urgency: "soon", href: "/missions", source: "mission", priority: 106 },
{ moduleId: "social", title: "LinkedIn headline v3 ready", subtitle: "Clearer target: Product Intern · FinTech · Growth Systems.", tag: "Ready", urgency: "today", href: "/social", source: "social", priority: 104 },
{ moduleId: "social", title: "Featured section has 3 proof pins", subtitle: "Use resume scan, mock review, and Q Score delta as credibility blocks.", tag: "Proof", urgency: "soon", href: "/social", source: "social", priority: 100 },
{ moduleId: "social", title: "Banner options queued", subtitle: "Three calm orange/blue layouts are waiting for review.", tag: "Brand", urgency: "soon", href: "/social", source: "social", priority: 96 },
{ moduleId: "social", title: "LinkedIn headline v3 ready", subtitle: "Clearer target: Product Intern · FinTech · Growth Systems.", tag: "Ready", urgency: "today", href: "/agents/social-branding", source: "social", priority: 104 },
{ moduleId: "social", title: "Featured section has 3 proof pins", subtitle: "Use resume scan, mock review, and Q Score delta as credibility blocks.", tag: "Proof", urgency: "soon", href: "/agents/social-branding", source: "social", priority: 100 },
{ moduleId: "social", title: "Banner options queued", subtitle: "Three calm orange/blue layouts are waiting for review.", tag: "Brand", urgency: "soon", href: "/agents/social-branding", source: "social", priority: 96 },
{ moduleId: "pathways", title: "Pathways stay locked for demo", subtitle: "Resume + interview data is enough; pathway service is not enabled yet.", tag: "Soon", urgency: "calm", href: "/pathways", source: "pathways", priority: 70 },
{ moduleId: "pathways", title: "PM SaaS route predicted", subtitle: "Directional only until the pathways service is connected.", tag: "Preview", urgency: "calm", href: "/pathways", source: "pathways", priority: 68 },
{ moduleId: "pathways", title: "Pathways stay locked for demo", subtitle: "Resume + interview data is enough; pathway service is not enabled yet.", tag: "Soon", urgency: "calm", href: "/agents/matchmaking", source: "pathways", priority: 70 },
{ moduleId: "pathways", title: "PM SaaS route predicted", subtitle: "Directional only until the pathways service is connected.", tag: "Preview", urgency: "calm", href: "/agents/matchmaking", source: "pathways", priority: 68 },
{ moduleId: "productivity", title: "Resume ATS 86", subtitle: "Keyword relevance +9 after JD tailoring. Open Resume Builder.", tag: "Resume", urgency: "today", href: "/agents/resume", source: "resume", priority: 118 },
{ moduleId: "productivity", title: "Interview review is ready", subtitle: "Overall 82 · storytelling and concise examples need one more drill.", tag: "Mock", urgency: "now", href: "/agents/interview", source: "interview", priority: 117 },

View File

@@ -42,8 +42,8 @@ export type HomeFeedResponse = {
export const MODULE_META: Record<HomeModuleId, Omit<HomeModule, "count" | "notifications">> = {
suggestions: { id: "suggestions", label: "Today's Queue", href: "/suggestions", accent: "orange" },
missions: { id: "missions", label: "Missions", href: "/missions", accent: "orange" },
social: { id: "social", label: "Social Branding", href: "/social", accent: "blue" },
pathways: { id: "pathways", label: "Pathways", href: "/pathways", accent: "teal" },
social: { id: "social", label: "Social Branding", href: "/agents/social-branding", accent: "blue" },
pathways: { id: "pathways", label: "Pathways", href: "/agents/matchmaking", accent: "teal" },
productivity: { id: "productivity", label: "Interview · Roleplay · Resume", href: "/agents", accent: "orange" },
rewards: { id: "rewards", label: "Rewards", href: "/rewards", accent: "amber" },
};
@@ -62,9 +62,9 @@ export const ALLOWED_NOTIFICATION_HREFS = new Set([
"/missions",
"/missions/active",
"/missions/available",
"/social",
"/pathways",
"/productivity",
"/agents/social-branding",
"/agents/matchmaking",
"/agents",
"/rewards",
"/agents/resume",
"/agents/interview",

View File

@@ -23,13 +23,16 @@ import { analyticsRoutes } from "./routes/analytics.js";
import { logRoutes } from "./routes/logs.js";
import { v1Routes } from "./v1/index.js";
import { startGrowEventsRedisConsumer } from "./events/redis-consumer.js";
import { startPassiveMissionReviewLoop } from "./missions/passive-runner.js";
import { db } from "./db/client.js";
import { ensureRuntimeSchema } from "./db/ensure-runtime-schema.js";
import { hydratePortAllocator, reconcileOnBoot, ensureCentralGiteaReady } from "./docker/manager.js";
import { initCatalog } from "./agents/catalog.js";
async function main() {
// Boot-time DB sanity + reconcile + central Gitea readiness.
await db.execute("select 1");
await ensureRuntimeSchema();
await hydratePortAllocator();
// Ensure central Gitea is reachable before accepting traffic (changes.md §2A).
@@ -45,6 +48,7 @@ async function main() {
await reconcileOnBoot();
startGrowEventsRedisConsumer().catch((err) => log.error({ err }, "failed to start grow events redis consumer"));
startPassiveMissionReviewLoop();
const app = new Hono();

View File

@@ -4,7 +4,8 @@ import { missionActions, missionSuggestions } from "../db/schema.js";
import type { GrowActiveMission } from "../actors/missions/types.js";
import type { MissionActionPatch } from "./reducer-types.js";
import { defaultMissionActionStatus, type MissionActionDto, type MissionActionRow, type MissionActionStatus, type NewMissionActionInput } from "./action-types.js";
import { missionDetailHref, serviceHref } from "./reducer-helpers.js";
import { missionDetailHref } from "./reducer-helpers.js";
import { buildServiceLink, getService, getServiceActionLabel } from "../services/service-registry.js";
const OPEN_STATUSES: MissionActionStatus[] = ["queued", "running", "waiting_approval", "waiting_user_input", "failed"];
const DONE_STATUSES: MissionActionStatus[] = ["done", "dismissed", "snoozed"];
@@ -48,26 +49,30 @@ export function actionToDto(row: MissionActionRow): MissionActionDto {
function ctaForAction(action: MissionActionRow | NewMissionActionInput) {
const payload = action.payload && typeof action.payload === "object" && !Array.isArray(action.payload) ? action.payload as Record<string, unknown> : {};
const hrefFromPayload = typeof payload.href === "string" ? payload.href : undefined;
const serviceId = action.serviceId ?? "";
const missionHref = missionDetailHref(action.missionInstanceId);
const href = hrefFromPayload ??
(serviceId.includes("interview") ? serviceHref("interview", action.missionInstanceId, action.missionId, action.stageId ?? undefined) :
serviceId.includes("roleplay") ? serviceHref("roleplay", action.missionInstanceId, action.missionId, action.stageId ?? undefined) :
serviceId.includes("resume") ? serviceHref("resume", action.missionInstanceId, action.missionId, action.stageId ?? undefined) : missionHref);
const service = getService(action.serviceId);
const href = hrefFromPayload ?? (
service
? buildServiceLink(service.id, service.curator.defaultPage, {
source: "mission",
missionInstanceId: action.missionInstanceId,
missionId: action.missionId,
stageId: action.stageId ?? undefined,
}) ?? missionHref
: missionHref
);
if (action.mode === "approval_required") return { ctaLabel: "Review", ctaHref: missionHref };
if (action.mode === "user_input_required") return { ctaLabel: "Answer", ctaHref: missionHref };
if (serviceId.includes("interview")) return { ctaLabel: "Start mock", ctaHref: href };
if (serviceId.includes("roleplay")) return { ctaLabel: "Run drill", ctaHref: href };
if (serviceId.includes("resume")) return { ctaLabel: "Open resume", ctaHref: href };
return { ctaLabel: "Open", ctaHref: href };
return { ctaLabel: service ? getServiceActionLabel(service.id, "start") : "Open", ctaHref: href };
}
function suggestionTypeForAction(action: MissionActionRow | NewMissionActionInput) {
if (action.mode === "user_input_required") return "blocked" as const;
if (action.mode === "approval_required") return "review" as const;
if ((action.serviceId ?? "").includes("interview") || (action.serviceId ?? "").includes("roleplay")) return "practice" as const;
if ((action.serviceId ?? "").includes("resume")) return "artifact" as const;
const category = getService(action.serviceId)?.category;
if (category === "practice") return "practice" as const;
if (category === "document") return "artifact" as const;
return "action" as const;
}

View File

@@ -1,5 +1,19 @@
import type { MissionReducer, MissionReduction, MissionStagePatch } from "../reducer-types.js";
import { actionForAgent, extractResumeSignals, extractWeakAreas, isInterviewEvent, isRelevantServiceEvent, isResumeEvent, isRoleplayEvent, missionExplicitlyMatches, serviceHref } from "../reducer-helpers.js";
import {
actionForAgent,
extractResumeSignals,
extractWeakAreas,
isFeedbackEvent,
isInterviewEvent,
isRelevantServiceEvent,
isResumeEvent,
isRoleplayEvent,
missionExplicitlyMatches,
passiveInterviewFeedbackResumeUpgrade,
passiveResumeAnalysisInterviewPractice,
passiveRoleplayFeedbackStoryBank,
serviceHref,
} from "../reducer-helpers.js";
export const careerTransitionReducer: MissionReducer = {
missionId: "career-transition",
@@ -48,6 +62,14 @@ export const careerTransitionReducer: MissionReducer = {
priority: 95,
urgency: "today",
}));
actions.push(passiveResumeAnalysisInterviewPractice({
missionId: "career-transition",
activeMission,
eventId: event.id,
payload,
stageId: "interview",
priority: 98,
}));
eventMessage = "Transferable skills map created; repositioned resume action is ready.";
}
@@ -56,7 +78,7 @@ export const careerTransitionReducer: MissionReducer = {
eventMessage = "Adjacent-role interview practice started.";
}
if (isInterviewEvent(event.source, type) && type.includes("review_completed")) {
if (isInterviewEvent(event.source, type) && isFeedbackEvent(type)) {
const weakAreas = extractWeakAreas(payload);
stagePatches.push({ stageId: "interview", status: "done", progressPercent: 100, outputSummary: "Adjacent-role credibility checked." });
stagePatches.push({ stageId: "roleplay", status: "ready", progressPercent: 0, outputSummary: "Practice the 'why I am switching' narrative next." });
@@ -74,12 +96,30 @@ export const careerTransitionReducer: MissionReducer = {
priority: 92,
urgency: "today",
}));
actions.push(passiveInterviewFeedbackResumeUpgrade({
missionId: "career-transition",
activeMission,
eventId: event.id,
payload,
stageId: "resume",
priority: 104,
}));
eventMessage = "Career transition interview feedback produced the next pitch-practice action.";
}
if (isRoleplayEvent(event.source, type) && type.includes("review_completed")) {
if (isRoleplayEvent(event.source, type) && isFeedbackEvent(type)) {
const passive = passiveRoleplayFeedbackStoryBank({
missionId: "career-transition",
activeMission,
eventId: event.id,
payload,
stageId: "roleplay",
priority: 94,
});
stagePatches.push({ stageId: "roleplay", status: "done", progressPercent: 100, outputSummary: "Transition pitch practice reviewed." });
stagePatches.push({ stageId: "qscore", status: "in_progress", progressPercent: 70, outputSummary: "Transition confidence signals updated." });
artifacts.push(passive.artifact);
actions.push(passive.action);
eventMessage = "Transition narrative practice completed.";
}

View File

@@ -2,8 +2,11 @@ import { getString } from "../../events/envelope.js";
import type { MissionReducer, MissionReduction, MissionStagePatch } from "../reducer-types.js";
import {
actionForAgent,
extractMissingProof,
extractOverallScore,
extractResumeProofPoints,
extractResumeSignals,
extractStoryIssues,
extractWeakAreas,
isInterviewEvent,
isQscoreEvent,
@@ -27,6 +30,10 @@ function reviewSummary(payload: Record<string, unknown>) {
return summary ?? "Mock interview review completed.";
}
function isFeedbackEvent(type: string) {
return type.includes("review_completed") || type.includes("review.completed") || type.includes("feedback.generated");
}
export const interviewToOfferReducer: MissionReducer = {
missionId: "interview-to-offer",
accepts: acceptsMission,
@@ -47,6 +54,7 @@ export const interviewToOfferReducer: MissionReducer = {
if (isResumeEvent(event.source, type) && (type.includes("analysis_completed") || type.includes("analysis.complete") || type.includes("analyzed"))) {
const signals = extractResumeSignals(payload);
const proofPoints = extractResumeProofPoints(payload);
stagePatches.push({ stageId: "resume", status: "done", progressPercent: 100, outputSummary: "Resume talking points and fit scan are ready." });
stagePatches.push({ stageId: "interview", status: "ready", progressPercent: 0 });
artifacts.push({
@@ -69,6 +77,29 @@ export const interviewToOfferReducer: MissionReducer = {
priority: 92,
urgency: "today",
}));
actions.push(actionForAgent("interview-to-offer", "interview", {
stageId: "interview",
serviceId: "interview-service",
toolName: "interview.configure_practice",
mode: "suggestion",
title: "Practice explaining your strongest resume proof",
body: proofPoints.strengths.length
? `Run a mock focused on ${proofPoints.strengths.slice(0, 2).join(" and ")} so your resume turns into interview-ready stories.`
: "Run a resume-led mock interview so your strongest proof turns into interview-ready stories.",
payload: {
passiveAction: "resume_analysis_to_interview_practice",
resumeSignals: signals,
proofPoints,
prompt: proofPoints.strengths[0]
? `Practice explaining ${proofPoints.strengths[0]} with clear ownership, impact, and tradeoffs.`
: "Practice explaining your strongest resume project with clear ownership, impact, and tradeoffs.",
href: serviceHref("interview", activeMission.instanceId, activeMission.missionId, "interview"),
},
sourceEventId: event.id,
idempotencyKey: `${activeMission.instanceId}:resume-analysis:proof-interview:${event.id}`,
priority: 98,
urgency: "today",
}));
eventMessage = "Resume fit scan completed; mock interview is ready to run.";
}
@@ -82,8 +113,10 @@ export const interviewToOfferReducer: MissionReducer = {
eventMessage = "Mock interview completed; waiting for review.";
}
if (isInterviewEvent(event.source, type) && (type.includes("review_completed") || type.includes("review.completed"))) {
if (isInterviewEvent(event.source, type) && isFeedbackEvent(type)) {
const weakAreas = extractWeakAreas(payload);
const missingProof = extractMissingProof(payload);
const storyIssues = extractStoryIssues(payload);
stagePatches.push({ stageId: "interview", status: "done", progressPercent: 100, outputSummary: reviewSummary(payload) });
stagePatches.push({ stageId: "roleplay", status: "ready", progressPercent: 0, outputSummary: "Practice the communication gaps surfaced by interview feedback." });
stagePatches.push({ stageId: "qscore", status: "in_progress", progressPercent: 60, outputSummary: "Readiness signals updated from interview review." });
@@ -99,14 +132,27 @@ export const interviewToOfferReducer: MissionReducer = {
serviceId: "resume-service",
toolName: "resume.create_version_prompt_draft",
mode: "approval_required",
title: "Create a tailored resume version from this interview feedback?",
body: weakAreas.length
? `The interview exposed ${weakAreas.slice(0, 3).join(", ")}. Approve a Resume Agent draft that turns this feedback into targeted bullets and talking points.`
: "Approve a Resume Agent draft that turns the interview feedback into targeted bullets and talking points.",
payload: { weakAreas, sourceReviewEventId: event.id, href: serviceHref("resume", activeMission.instanceId, activeMission.missionId, "resume") },
title: "Draft stronger resume bullets from interview feedback?",
body: [...weakAreas, ...missingProof, ...storyIssues].length
? `Approve a Resume Agent draft that fixes ${[...weakAreas, ...missingProof, ...storyIssues].slice(0, 3).join(", ")} with stronger bullets and proof.`
: "Approve a Resume Agent draft that turns the interview feedback into stronger bullets and talking points.",
prompt: "Create a resume upgrade draft from this interview feedback. Focus on measurable impact, ownership, missing proof, and reusable talking points.",
payload: {
passiveAction: "interview_feedback_to_resume_upgrade",
weakAreas,
missingProof,
storyIssues,
sourceReviewEventId: event.id,
draftInstructions: [
"Rewrite weak bullets with action, scope, metric, and result.",
"Add proof for any interview gaps that lacked evidence.",
"Create talking points that match the feedback themes.",
],
href: serviceHref("resume", activeMission.instanceId, activeMission.missionId, "resume"),
},
sourceEventId: event.id,
idempotencyKey: `${activeMission.instanceId}:interview-review:tailor-resume:${event.id}`,
priority: 100,
priority: 108,
urgency: "now",
}));
if (weakAreas.some((area) => /communication|story|clarity|confidence|concise/i.test(area))) {
@@ -127,9 +173,45 @@ export const interviewToOfferReducer: MissionReducer = {
eventMessage = "Interview review completed; resume and roleplay next actions were created.";
}
if (isRoleplayEvent(event.source, type) && type.includes("review_completed")) {
if (isRoleplayEvent(event.source, type) && isFeedbackEvent(type)) {
const weakAreas = extractWeakAreas(payload);
const storyIssues = extractStoryIssues(payload);
stagePatches.push({ stageId: "roleplay", status: "done", progressPercent: 100, outputSummary: "Communication drill reviewed." });
stagePatches.push({ stageId: "qscore", status: "in_progress", progressPercent: 75, outputSummary: "Communication readiness updated." });
artifacts.push({
type: "story_bank_update",
title: "Story bank updates from roleplay feedback",
stageId: "roleplay",
summary: [...weakAreas, ...storyIssues].length
? `Turn these into reusable STAR stories: ${[...weakAreas, ...storyIssues].slice(0, 5).join(", ")}`
: "Roleplay feedback captured story bank improvements for future interviews.",
metadata: { sourceEventId: event.id, weakAreas, storyIssues, payload },
});
actions.push(actionForAgent("interview-to-offer", "interview", {
stageId: "interview",
serviceId: "interview-service",
toolName: "interview.configure_practice",
mode: "suggestion",
title: "Run a story-bank recovery mock",
body: [...weakAreas, ...storyIssues].length
? `Practice the communication gaps from roleplay: ${[...weakAreas, ...storyIssues].slice(0, 3).join(", ")}.`
: "Run a targeted mock interview that converts roleplay feedback into reusable STAR stories.",
payload: {
passiveAction: "roleplay_feedback_to_communication_drill",
weakAreas,
storyIssues,
storyBankInstructions: [
"Convert each weak communication moment into a STAR story prompt.",
"Practice the answer in an interview setting.",
"Save the strongest version as reusable story-bank material.",
],
href: serviceHref("interview", activeMission.instanceId, activeMission.missionId, "interview"),
},
sourceEventId: event.id,
idempotencyKey: `${activeMission.instanceId}:roleplay-review:story-bank-interview:${event.id}`,
priority: 96,
urgency: "today",
}));
eventMessage = "Roleplay review improved interview communication readiness.";
}

354
src/missions/lifecycle.ts Normal file
View File

@@ -0,0 +1,354 @@
import crypto from "node:crypto";
import { createClient, type Client } from "rivetkit/client";
import { eq } from "drizzle-orm";
import { config } from "../config.js";
import { db } from "../db/client.js";
import { growEvents, users } from "../db/schema.js";
import {
completeMissionCoachRunPg,
createMissionCoachRunPg,
getActiveMissionPg,
listActiveMissionsForPassiveReviewPg,
listActiveMissionsPg,
replaceMissionSuggestionsPg,
upsertActiveMissionPg,
} from "../grow/persistence.js";
import { recordGrowEvent, markGrowEventFailed, markGrowEventProcessed, markGrowEventProcessing } from "../events/record-grow-event.js";
import { routeGrowEventToUserActor } from "../events/route-to-user-actor.js";
import { getPersistedMissionDefinition } from "./postgres-registry.js";
import { buildDeterministicMissionSuggestions } from "./suggestions.js";
import type { Registry } from "../actors/registry.js";
import type { GrowActiveMission, MissionActorType, MissionId, MissionSnapshot } from "../actors/missions/types.js";
import { log } from "../log.js";
const ONBOARDING_MISSION_LIMIT = 2;
const PASSIVE_REVIEW_SOURCE = "growqr-backend:mission-passive";
let _client: Client<Registry> | null = null;
function getClient(): Client<Registry> {
return (_client ??= createClient<Registry>(config.rivetClientEndpoint));
}
function missionActorFor(userId: string, instanceId: string, actorType: MissionActorType) {
const client = getClient();
switch (actorType) {
case "interviewToOfferMissionActor":
return client.interviewToOfferMissionActor.getOrCreate([userId, instanceId]);
case "careerTransitionMissionActor":
return client.careerTransitionMissionActor.getOrCreate([userId, instanceId]);
case "salaryNegotiationWarRoomMissionActor":
return client.salaryNegotiationWarRoomMissionActor.getOrCreate([userId, instanceId]);
case "promotionReadinessMissionActor":
return client.promotionReadinessMissionActor.getOrCreate([userId, instanceId]);
case "personalBrandOpportunityEngineMissionActor":
return client.personalBrandOpportunityEngineMissionActor.getOrCreate([userId, instanceId]);
}
}
function activeMissionFromSnapshot(snapshot: MissionSnapshot): GrowActiveMission {
return {
instanceId: snapshot.instanceId,
missionId: snapshot.missionId,
workflowId: snapshot.workflowId,
title: snapshot.title,
shortTitle: snapshot.shortTitle,
status: snapshot.status,
progressPercent: snapshot.progressPercent,
currentStageId: snapshot.currentStageId,
goal: snapshot.goal,
actorType: actorTypeFor(snapshot.missionId),
createdAt: new Date(snapshot.createdAt).getTime(),
updatedAt: new Date(snapshot.updatedAt).getTime(),
};
}
function actorTypeFor(missionId: MissionId): MissionActorType | undefined {
if (missionId === "interview-to-offer") return "interviewToOfferMissionActor";
if (missionId === "career-transition") return "careerTransitionMissionActor";
if (missionId === "salary-negotiation-war-room") return "salaryNegotiationWarRoomMissionActor";
if (missionId === "promotion-readiness") return "promotionReadinessMissionActor";
if (missionId === "personal-brand-opportunity-engine") return "personalBrandOpportunityEngineMissionActor";
return undefined;
}
function hashUser(userId: string) {
return crypto.createHash("sha256").update(userId).digest("hex").slice(0, 12);
}
export function onboardingMissionInstanceId(userId: string, missionId: MissionId) {
return `mission-${missionId}-${hashUser(userId)}`;
}
function asRecord(value: unknown): Record<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
}
function stringValues(value: unknown): string[] {
if (Array.isArray(value)) return value.filter((item): item is string => typeof item === "string" && Boolean(item.trim())).map((item) => item.trim());
if (typeof value === "string" && value.trim()) return [value.trim()];
return [];
}
function onboardingText(context?: Record<string, unknown>) {
const source = asRecord(context);
const preferences = asRecord(source.preferences);
const onboarding = asRecord(source.onboarding ?? preferences.onboarding);
const mission = asRecord(preferences.mission_preferences);
const resume = asRecord(preferences.resume_preferences);
const interview = asRecord(preferences.interview_preferences);
const values = [
...stringValues(onboarding.goal),
...stringValues(onboarding.target_role ?? onboarding.targetRole ?? onboarding.role ?? onboarding.current_role),
...stringValues(onboarding.timeline),
...stringValues(mission.active_goal),
...stringValues(resume.target_title),
...stringValues(interview.job_description),
...stringValues(preferences.target_roles),
...stringValues(preferences.target_companies),
];
return values.join(" ").toLowerCase();
}
export function selectOnboardingMissionIds(context?: Record<string, unknown>, limit = ONBOARDING_MISSION_LIMIT): MissionId[] {
const text = onboardingText(context);
const primary: MissionId =
/salary|compensation|negotiat/.test(text) ? "salary-negotiation-war-room" :
/promot|manager|leadership|level up|level-up/.test(text) ? "promotion-readiness" :
/transition|switch|pivot|career change|new field/.test(text) ? "career-transition" :
/brand|linkedin|network|visibility|opportunit/.test(text) ? "personal-brand-opportunity-engine" :
"interview-to-offer";
const ordered: MissionId[] = [
primary,
"personal-brand-opportunity-engine",
"interview-to-offer",
"career-transition",
"promotion-readiness",
"salary-negotiation-war-room",
];
return Array.from(new Set(ordered)).slice(0, Math.max(1, limit));
}
async function ensureUser(userId: string) {
await db
.insert(users)
.values({ id: userId, email: `${userId}@service.local`, displayName: userId })
.onConflictDoNothing();
}
export async function ensureOnboardingActiveMissions(input: {
userId: string;
context?: Record<string, unknown>;
completedAt?: string | Date | null;
sourceEventId?: string;
source?: string;
limit?: number;
}) {
const userId = input.userId.trim();
if (!userId) return { status: "skipped" as const, reason: "missing_user_id" as const, started: [], existing: [] };
await ensureUser(userId);
const missionIds = selectOnboardingMissionIds(input.context, input.limit ?? ONBOARDING_MISSION_LIMIT);
const activeRows = await listActiveMissionsPg(userId);
const started: GrowActiveMission[] = [];
const existing: GrowActiveMission[] = [];
for (const missionId of missionIds) {
const alreadyActive = activeRows.find((item) => item.mission.missionId === missionId && ["active", "paused"].includes(item.mission.status));
if (alreadyActive) {
existing.push(alreadyActive.mission);
continue;
}
const mission = await getPersistedMissionDefinition(missionId);
if (!mission?.actorType) continue;
const instanceId = onboardingMissionInstanceId(userId, missionId);
const existingInstance = await getActiveMissionPg(userId, instanceId);
if (existingInstance) {
existing.push(existingInstance.mission);
continue;
}
const actor = missionActorFor(userId, instanceId, mission.actorType);
const completedAt = input.completedAt instanceof Date ? input.completedAt.toISOString() : input.completedAt ?? new Date().toISOString();
const snapshot = await actor.init({
userId,
instanceId,
missionId,
goal: onboardingText(input.context) || undefined,
input: {
source: "onboarding",
sourceEventId: input.sourceEventId,
completedAt,
context: input.context ?? {},
},
});
const activeMission = activeMissionFromSnapshot(snapshot);
await upsertActiveMissionPg(userId, activeMission, snapshot);
started.push(activeMission);
const event = await recordGrowEvent({
source: input.source ?? "onboarding",
type: "mission.started",
category: "mission",
userId,
occurredAt: completedAt,
mission: { instanceId, missionId, stageId: snapshot.currentStageId },
correlation: { sourceEventId: input.sourceEventId },
payload: {
trigger: "onboarding",
title: snapshot.title,
goal: snapshot.goal,
selectedMissionIds: missionIds,
},
dedupeKey: `mission-onboarding-start:${userId}:${missionId}`,
}, { userId, source: input.source ?? "onboarding" });
await routeGrowEventToUserActor(event).catch((err) => log.warn({ err, userId, missionId }, "failed to route onboarding mission start event"));
}
return {
status: started.length ? "started" as const : "already_ready" as const,
selectedMissionIds: missionIds,
started,
existing,
};
}
function passiveReviewDate(input?: string | Date) {
const date = input instanceof Date ? input : input ? new Date(input) : new Date();
return Number.isNaN(date.getTime()) ? new Date().toISOString().slice(0, 10) : date.toISOString().slice(0, 10);
}
async function passiveReviewAlreadyRan(instanceId: string, date: string) {
const [existing] = await db
.select({ id: growEvents.id, processingStatus: growEvents.processingStatus })
.from(growEvents)
.where(eq(growEvents.dedupeKey, `mission-passive-review:${instanceId}:${date}`))
.limit(1);
return existing ?? null;
}
export async function runPassiveMissionReviewForMission(input: {
userId: string;
mission: GrowActiveMission;
snapshot?: MissionSnapshot | null;
date?: string | Date;
force?: boolean;
}) {
const date = passiveReviewDate(input.date);
const existing = input.force ? null : await passiveReviewAlreadyRan(input.mission.instanceId, date);
if (existing) {
return { status: "skipped" as const, reason: "already_ran" as const, eventId: existing.id, mission: input.mission };
}
if (!input.mission.actorType) {
return { status: "skipped" as const, reason: "missing_actor" as const, mission: input.mission };
}
const dedupeKey = input.force
? `mission-passive-review:${input.mission.instanceId}:${date}:${Date.now()}`
: `mission-passive-review:${input.mission.instanceId}:${date}`;
const event = await recordGrowEvent({
source: PASSIVE_REVIEW_SOURCE,
type: "mission.passive_review.completed",
category: "mission",
userId: input.userId,
occurredAt: new Date().toISOString(),
mission: { instanceId: input.mission.instanceId, missionId: input.mission.missionId, stageId: input.mission.currentStageId },
payload: {
reviewDate: date,
status: "started",
},
dedupeKey,
}, { userId: input.userId, source: PASSIVE_REVIEW_SOURCE });
await markGrowEventProcessing(event.id);
try {
const actor = missionActorFor(input.userId, input.mission.instanceId, input.mission.actorType);
const scrum = await actor.runDailyScrum({ trigger: "nightly" });
const snapshot = scrum.snapshot ?? input.snapshot;
if (!snapshot) {
await markGrowEventFailed(event.id, new Error("mission_passive_review_missing_snapshot"));
return { status: "skipped" as const, reason: "missing_snapshot" as const, eventId: event.id, mission: input.mission };
}
const activeMission = activeMissionFromSnapshot(snapshot);
await upsertActiveMissionPg(input.userId, activeMission, snapshot);
const windowEnd = new Date(`${date}T23:59:59.999Z`);
const windowStart = new Date(`${date}T00:00:00.000Z`);
const run = await createMissionCoachRunPg({
userId: input.userId,
missionInstanceId: activeMission.instanceId,
missionId: activeMission.missionId,
windowStart,
windowEnd,
skillVersion: snapshot.skillVersion,
inputDigest: {
passive: true,
reviewDate: date,
trigger: "nightly",
stageCount: snapshot.stages.length,
currentStageId: snapshot.currentStageId,
progressPercent: snapshot.progressPercent,
artifactCount: snapshot.artifacts.length,
eventCount: snapshot.events.length,
},
});
const snapshotContext = asRecord(snapshot.input?.context);
const suggestions = await replaceMissionSuggestionsPg({
userId: input.userId,
missionInstanceId: activeMission.instanceId,
missionId: activeMission.missionId,
coachRunId: run.id,
suggestions: buildDeterministicMissionSuggestions(snapshot, { preferences: asRecord(snapshotContext.preferences) }),
});
const summary = suggestions[0]
? `Passive mission review refreshed ${suggestions.length} suggestion${suggestions.length === 1 ? "" : "s"}. Top action: ${suggestions[0].title}`
: "Passive mission review found no open action.";
await completeMissionCoachRunPg({ id: run.id, summary, output: { suggestions, passive: true, reviewDate: date } });
await db.update(growEvents).set({
mission: { instanceId: activeMission.instanceId, missionId: activeMission.missionId, stageId: activeMission.currentStageId },
payload: {
reviewDate: date,
status: "completed",
coachRunId: run.id,
suggestionIds: suggestions.map((item) => item.id),
summary,
},
}).where(eq(growEvents.id, event.id));
await markGrowEventProcessed(event.id);
return { status: "reviewed" as const, eventId: event.id, coachRunId: run.id, mission: activeMission, suggestionCount: suggestions.length, summary };
} catch (err) {
await markGrowEventFailed(event.id, err);
throw err;
}
}
export async function runPassiveMissionReviews(input: { userId?: string; date?: string | Date; force?: boolean; limit?: number } = {}) {
const rows = await listActiveMissionsForPassiveReviewPg({ userId: input.userId, limit: input.limit });
const results = [];
for (const row of rows) {
try {
results.push(await runPassiveMissionReviewForMission({
userId: row.userId,
mission: row.mission,
snapshot: row.snapshot,
date: input.date,
force: input.force,
}));
} catch (err) {
log.error({ err, userId: row.userId, missionInstanceId: row.mission.instanceId }, "passive mission review failed");
results.push({ status: "failed" as const, mission: row.mission, error: err instanceof Error ? err.message : String(err) });
}
}
return {
date: passiveReviewDate(input.date),
reviewed: results.filter((item) => item.status === "reviewed").length,
skipped: results.filter((item) => item.status === "skipped").length,
failed: results.filter((item) => item.status === "failed").length,
results,
};
}

View File

@@ -0,0 +1,42 @@
import { config } from "../config.js";
import { log } from "../log.js";
import { runPassiveMissionReviews } from "./lifecycle.js";
let timer: NodeJS.Timeout | undefined;
let running = false;
async function runOnce() {
if (running) return;
running = true;
try {
const result = await runPassiveMissionReviews({ limit: config.missionPassiveLoopBatchSize });
if (result.reviewed || result.failed) {
log.info({
reviewed: result.reviewed,
skipped: result.skipped,
failed: result.failed,
date: result.date,
}, "passive mission review loop completed");
}
} catch (err) {
log.error({ err }, "passive mission review loop failed");
} finally {
running = false;
}
}
export function startPassiveMissionReviewLoop() {
if (!config.missionPassiveLoopEnabled) {
log.info("passive mission review loop disabled");
return;
}
if (timer) return;
const intervalMs = Math.max(5 * 60 * 1000, config.missionPassiveLoopIntervalMs);
const firstDelayMs = Math.min(60_000, intervalMs);
const first = setTimeout(() => void runOnce(), firstDelayMs);
first.unref?.();
timer = setInterval(() => void runOnce(), intervalMs);
timer.unref?.();
log.info({ intervalMs, batchSize: config.missionPassiveLoopBatchSize }, "passive mission review loop scheduled");
}

View File

@@ -1,5 +1,20 @@
import type { MissionReducer, MissionReduction, MissionStagePatch } from "../reducer-types.js";
import { actionForAgent, extractResumeSignals, extractWeakAreas, isInterviewEvent, isRelevantServiceEvent, isResumeEvent, isRoleplayEvent, missionDetailHref, missionExplicitlyMatches, serviceHref } from "../reducer-helpers.js";
import {
actionForAgent,
extractResumeSignals,
extractWeakAreas,
isFeedbackEvent,
isInterviewEvent,
isRelevantServiceEvent,
isResumeEvent,
isRoleplayEvent,
missionDetailHref,
missionExplicitlyMatches,
passiveInterviewFeedbackResumeUpgrade,
passiveResumeAnalysisInterviewPractice,
passiveRoleplayFeedbackStoryBank,
serviceHref,
} from "../reducer-helpers.js";
export const personalBrandOpportunityReducer: MissionReducer = {
missionId: "personal-brand-opportunity-engine",
@@ -48,14 +63,31 @@ export const personalBrandOpportunityReducer: MissionReducer = {
priority: 92,
urgency: "today",
}));
actions.push(passiveResumeAnalysisInterviewPractice({
missionId: "personal-brand-opportunity-engine",
activeMission,
eventId: event.id,
payload,
stageId: "interview",
priority: 90,
}));
eventMessage = "Resume proof points created a profile positioning action.";
}
if (isRoleplayEvent(event.source, type) && type.includes("review_completed")) {
if (isRoleplayEvent(event.source, type) && isFeedbackEvent(type)) {
const weakAreas = extractWeakAreas(payload);
const passive = passiveRoleplayFeedbackStoryBank({
missionId: "personal-brand-opportunity-engine",
activeMission,
eventId: event.id,
payload,
stageId: "roleplay",
priority: 92,
});
stagePatches.push({ stageId: "roleplay", status: "done", progressPercent: 100, outputSummary: "Networking pitch reviewed." });
stagePatches.push({ stageId: "qscore", status: "in_progress", progressPercent: 70, outputSummary: "Brand voice/readiness signals updated." });
artifacts.push({ type: "networking_scripts", title: "Networking script improvements", stageId: "roleplay", summary: weakAreas.length ? `Improve: ${weakAreas.join(", ")}` : "Networking pitch practice completed.", metadata: { sourceEventId: event.id, weakAreas } });
artifacts.push(passive.artifact);
actions.push(actionForAgent("personal-brand-opportunity-engine", "planner", {
stageId: "positioning",
mode: "suggestion",
@@ -67,13 +99,22 @@ export const personalBrandOpportunityReducer: MissionReducer = {
priority: 82,
urgency: "soon",
}));
actions.push(passive.action);
eventMessage = "Networking pitch review created brand content next steps.";
}
if (isInterviewEvent(event.source, type) && type.includes("review_completed")) {
if (isInterviewEvent(event.source, type) && isFeedbackEvent(type)) {
const weakAreas = extractWeakAreas(payload);
stagePatches.push({ stageId: "interview", status: "done", progressPercent: 100, outputSummary: "Credibility signals mined from interview review." });
artifacts.push({ type: "credibility_signal_map", title: "Credibility signal map", stageId: "interview", summary: weakAreas.length ? `Recurring gaps/themes: ${weakAreas.join(", ")}` : "Interview review mined for positioning signals.", metadata: { sourceEventId: event.id, weakAreas } });
actions.push(passiveInterviewFeedbackResumeUpgrade({
missionId: "personal-brand-opportunity-engine",
activeMission,
eventId: event.id,
payload,
stageId: "resume",
priority: 98,
}));
eventMessage = "Interview feedback was mined for brand positioning signals.";
}

View File

@@ -1,5 +1,19 @@
import type { MissionReducer, MissionReduction, MissionStagePatch } from "../reducer-types.js";
import { actionForAgent, extractResumeSignals, extractWeakAreas, isInterviewEvent, isRelevantServiceEvent, isResumeEvent, isRoleplayEvent, missionExplicitlyMatches, serviceHref } from "../reducer-helpers.js";
import {
actionForAgent,
extractResumeSignals,
extractWeakAreas,
isFeedbackEvent,
isInterviewEvent,
isRelevantServiceEvent,
isResumeEvent,
isRoleplayEvent,
missionExplicitlyMatches,
passiveInterviewFeedbackResumeUpgrade,
passiveResumeAnalysisInterviewPractice,
passiveRoleplayFeedbackStoryBank,
serviceHref,
} from "../reducer-helpers.js";
export const promotionReadinessReducer: MissionReducer = {
missionId: "promotion-readiness",
@@ -48,14 +62,31 @@ export const promotionReadinessReducer: MissionReducer = {
priority: 94,
urgency: "today",
}));
actions.push(passiveResumeAnalysisInterviewPractice({
missionId: "promotion-readiness",
activeMission,
eventId: event.id,
payload,
stageId: "interview",
priority: 91,
}));
eventMessage = "Promotion evidence packet is ready; manager conversation practice is next.";
}
if (isRoleplayEvent(event.source, type) && type.includes("review_completed")) {
if (isRoleplayEvent(event.source, type) && isFeedbackEvent(type)) {
const weakAreas = extractWeakAreas(payload);
const passive = passiveRoleplayFeedbackStoryBank({
missionId: "promotion-readiness",
activeMission,
eventId: event.id,
payload,
stageId: "roleplay",
priority: 95,
});
stagePatches.push({ stageId: "roleplay", status: "done", progressPercent: 100, outputSummary: "Manager conversation drill reviewed." });
stagePatches.push({ stageId: "interview", status: "ready", progressPercent: 0, outputSummary: "Practice leadership narratives next if gaps remain." });
artifacts.push({ type: "manager_conversation_script", title: "Manager conversation script", stageId: "roleplay", summary: weakAreas.length ? `Follow-up focus: ${weakAreas.join(", ")}` : "Manager conversation review completed.", metadata: { sourceEventId: event.id, weakAreas } });
artifacts.push(passive.artifact);
actions.push(actionForAgent("promotion-readiness", "interview", {
stageId: "interview",
serviceId: "interview-service",
@@ -69,14 +100,23 @@ export const promotionReadinessReducer: MissionReducer = {
priority: 86,
urgency: "soon",
}));
actions.push(passive.action);
eventMessage = "Manager conversation review updated promotion readiness.";
}
if (isInterviewEvent(event.source, type) && type.includes("review_completed")) {
if (isInterviewEvent(event.source, type) && isFeedbackEvent(type)) {
const weakAreas = extractWeakAreas(payload);
stagePatches.push({ stageId: "interview", status: "done", progressPercent: 100, outputSummary: "Leadership communication gap check completed." });
stagePatches.push({ stageId: "qscore", status: "in_progress", progressPercent: 75, outputSummary: "Leadership readiness signals updated." });
artifacts.push({ type: "leadership_gap_map", title: "Leadership gap map", stageId: "interview", summary: weakAreas.length ? weakAreas.join(", ") : "Leadership practice review completed.", metadata: { sourceEventId: event.id, weakAreas } });
actions.push(passiveInterviewFeedbackResumeUpgrade({
missionId: "promotion-readiness",
activeMission,
eventId: event.id,
payload,
stageId: "resume",
priority: 102,
}));
eventMessage = "Leadership practice review updated the promotion gap map.";
}

View File

@@ -1,5 +1,7 @@
import { asRecord, getNumber, getString } from "../events/envelope.js";
import type { MissionActionPatch } from "./reducer-types.js";
import { buildServiceLink } from "../services/service-registry.js";
import type { GrowActiveMission } from "../actors/missions/types.js";
import type { MissionActionPatch, MissionArtifactPatch } from "./reducer-types.js";
export function isResumeEvent(source: string, type: string) {
const value = source.toLowerCase();
@@ -21,6 +23,10 @@ export function isQscoreEvent(source: string, type: string) {
return value.includes("qscore") || type.startsWith("qscore.");
}
export function isFeedbackEvent(type: string) {
return type.includes("review_completed") || type.includes("review.completed") || type.includes("feedback.generated");
}
export function reviewRecord(payload: Record<string, unknown>) {
return asRecord(payload.review ?? payload.result ?? payload.data ?? payload);
}
@@ -65,6 +71,64 @@ export function extractWeakAreas(payload: Record<string, unknown>): string[] {
return Array.from(new Set(areas)).slice(0, 5);
}
function extractStringListFromKeys(record: Record<string, unknown>, keys: string[]) {
const values: string[] = [];
for (const key of keys) {
const value = record[key];
if (Array.isArray(value)) {
for (const item of value) {
if (typeof item === "string" && item.trim()) values.push(item.trim());
else if (item && typeof item === "object" && !Array.isArray(item)) {
const row = item as Record<string, unknown>;
const text = getString(row.title ?? row.name ?? row.label ?? row.summary ?? row.description ?? row.text);
if (text) values.push(text);
}
}
} else if (typeof value === "string" && value.trim()) {
values.push(...value.split(/[;\n]/).map((part) => part.trim()).filter(Boolean));
}
}
return Array.from(new Set(values)).slice(0, 8);
}
export function extractMissingProof(payload: Record<string, unknown>): string[] {
const review = reviewRecord(payload);
return extractStringListFromKeys(review, [
"missing_proof",
"missingProof",
"proof_gaps",
"proofGaps",
"evidence_gaps",
"evidenceGaps",
"missing_evidence",
"missingEvidence",
"gaps",
]);
}
export function extractStoryIssues(payload: Record<string, unknown>): string[] {
const review = reviewRecord(payload);
const values = extractStringListFromKeys(review, [
"story_issues",
"storyIssues",
"story_gaps",
"storyGaps",
"star_gaps",
"starGaps",
"communication_gaps",
"communicationGaps",
"recommendations",
]);
const summary = getString(review.summary ?? review.feedback_summary ?? review.overall_feedback);
if (summary) {
const lower = summary.toLowerCase();
if (lower.includes("star") || lower.includes("story")) values.push("tighten STAR story structure");
if (lower.includes("metric") || lower.includes("impact") || lower.includes("measurable")) values.push("add measurable impact proof");
if (lower.includes("ownership")) values.push("clarify ownership and scope");
}
return Array.from(new Set(values)).slice(0, 8);
}
export function extractResumeSignals(payload: Record<string, unknown>): string[] {
const analysis = asRecord(payload.analysis ?? payload.result ?? payload.data ?? payload);
const signals: string[] = [];
@@ -79,6 +143,14 @@ export function extractResumeSignals(payload: Record<string, unknown>): string[]
return signals.slice(0, 8);
}
export function extractResumeProofPoints(payload: Record<string, unknown>) {
const analysis = asRecord(payload.analysis ?? payload.result ?? payload.data ?? payload);
const strengths = extractStringListFromKeys(analysis, ["strengths", "top_strengths", "strong_projects", "projects", "achievements"]);
const gaps = extractStringListFromKeys(analysis, ["gaps", "recommendations", "missing_keywords", "keyword_gaps", "weak_bullets"]);
const keywords = extractStringListFromKeys(analysis, ["keywords", "matched_keywords", "missing_keywords", "keyword_gaps"]);
return { strengths, gaps, keywords };
}
export function missionExplicitlyMatches(eventMission: unknown, missionId: string) {
const mission = asRecord(eventMission);
const explicit = getString(mission.missionId ?? mission.mission_id);
@@ -134,14 +206,136 @@ export function actionForAgent(missionId: string, agent: "planner" | "resume" |
}
export function serviceHref(service: "resume" | "interview" | "roleplay" | "qscore", missionInstanceId: string, missionId: string, stageId?: string) {
const params = new URLSearchParams({ source: "mission", missionInstanceId, missionId });
if (stageId) params.set("stageId", stageId);
if (service === "interview") return `/agents/interview/setup?${params.toString()}`;
if (service === "roleplay") return `/agents/roleplay/setup?${params.toString()}`;
if (service === "resume") return `/agents/resume?${params.toString()}`;
return `/agents/qscore?${params.toString()}`;
const serviceId = service === "qscore" ? "qscore-service" : `${service}-service`;
const pageId = service === "resume" ? "workspace" : service === "qscore" ? "dashboard" : "setup";
return buildServiceLink(serviceId, pageId, { source: "mission", missionInstanceId, missionId, stageId })
?? missionDetailHref(missionInstanceId);
}
export function missionDetailHref(missionInstanceId: string) {
return `/missions/${encodeURIComponent(missionInstanceId)}`;
}
export function passiveResumeAnalysisInterviewPractice(input: {
missionId: string;
activeMission: GrowActiveMission;
eventId: string;
payload: Record<string, unknown>;
stageId?: string;
priority?: number;
}): MissionActionPatch {
const signals = extractResumeSignals(input.payload);
const proofPoints = extractResumeProofPoints(input.payload);
return actionForAgent(input.missionId, "interview", {
stageId: input.stageId ?? "interview",
serviceId: "interview-service",
toolName: "interview.configure_practice",
mode: "suggestion",
title: "Practice explaining your strongest resume proof",
body: proofPoints.strengths.length
? `Run a mock focused on ${proofPoints.strengths.slice(0, 2).join(" and ")} so your resume turns into interview-ready stories.`
: "Run a resume-led mock interview so your strongest proof turns into interview-ready stories.",
payload: {
passiveAction: "resume_analysis_to_interview_practice",
resumeSignals: signals,
proofPoints,
prompt: proofPoints.strengths[0]
? `Practice explaining ${proofPoints.strengths[0]} with clear ownership, impact, and tradeoffs.`
: "Practice explaining your strongest resume project with clear ownership, impact, and tradeoffs.",
href: serviceHref("interview", input.activeMission.instanceId, input.activeMission.missionId, input.stageId ?? "interview"),
},
sourceEventId: input.eventId,
idempotencyKey: `${input.activeMission.instanceId}:resume-analysis:proof-interview:${input.eventId}`,
priority: input.priority ?? 98,
urgency: "today",
});
}
export function passiveInterviewFeedbackResumeUpgrade(input: {
missionId: string;
activeMission: GrowActiveMission;
eventId: string;
payload: Record<string, unknown>;
stageId?: string;
priority?: number;
}): MissionActionPatch {
const weakAreas = extractWeakAreas(input.payload);
const missingProof = extractMissingProof(input.payload);
const storyIssues = extractStoryIssues(input.payload);
return actionForAgent(input.missionId, "resume", {
stageId: input.stageId ?? "resume",
serviceId: "resume-service",
toolName: "resume.create_version_prompt_draft",
mode: "approval_required",
title: "Draft stronger resume bullets from interview feedback?",
body: [...weakAreas, ...missingProof, ...storyIssues].length
? `Approve a Resume Agent draft that fixes ${[...weakAreas, ...missingProof, ...storyIssues].slice(0, 3).join(", ")} with stronger bullets and proof.`
: "Approve a Resume Agent draft that turns the interview feedback into stronger bullets and talking points.",
prompt: "Create a resume upgrade draft from this interview feedback. Focus on measurable impact, ownership, missing proof, and reusable talking points.",
payload: {
passiveAction: "interview_feedback_to_resume_upgrade",
weakAreas,
missingProof,
storyIssues,
sourceReviewEventId: input.eventId,
draftInstructions: [
"Rewrite weak bullets with action, scope, metric, and result.",
"Add proof for any interview gaps that lacked evidence.",
"Create talking points that match the feedback themes.",
],
href: serviceHref("resume", input.activeMission.instanceId, input.activeMission.missionId, input.stageId ?? "resume"),
},
sourceEventId: input.eventId,
idempotencyKey: `${input.activeMission.instanceId}:interview-review:tailor-resume:${input.eventId}`,
priority: input.priority ?? 108,
urgency: "now",
});
}
export function passiveRoleplayFeedbackStoryBank(input: {
missionId: string;
activeMission: GrowActiveMission;
eventId: string;
payload: Record<string, unknown>;
stageId?: string;
priority?: number;
}): { artifact: MissionArtifactPatch; action: MissionActionPatch } {
const weakAreas = extractWeakAreas(input.payload);
const storyIssues = extractStoryIssues(input.payload);
return {
artifact: {
type: "story_bank_update",
title: "Story bank updates from roleplay feedback",
stageId: input.stageId ?? "roleplay",
summary: [...weakAreas, ...storyIssues].length
? `Turn these into reusable STAR stories: ${[...weakAreas, ...storyIssues].slice(0, 5).join(", ")}`
: "Roleplay feedback captured story bank improvements for future interviews.",
metadata: { sourceEventId: input.eventId, weakAreas, storyIssues, payload: input.payload },
},
action: actionForAgent(input.missionId, "interview", {
stageId: "interview",
serviceId: "interview-service",
toolName: "interview.configure_practice",
mode: "suggestion",
title: "Run a story-bank recovery mock",
body: [...weakAreas, ...storyIssues].length
? `Practice the communication gaps from roleplay: ${[...weakAreas, ...storyIssues].slice(0, 3).join(", ")}.`
: "Run a targeted mock interview that converts roleplay feedback into reusable STAR stories.",
payload: {
passiveAction: "roleplay_feedback_to_communication_drill",
weakAreas,
storyIssues,
storyBankInstructions: [
"Convert each weak communication moment into a STAR story prompt.",
"Practice the answer in an interview setting.",
"Save the strongest version as reusable story-bank material.",
],
href: serviceHref("interview", input.activeMission.instanceId, input.activeMission.missionId, "interview"),
},
sourceEventId: input.eventId,
idempotencyKey: `${input.activeMission.instanceId}:roleplay-review:story-bank-interview:${input.eventId}`,
priority: input.priority ?? 96,
urgency: "today",
}),
};
}

View File

@@ -1,5 +1,19 @@
import type { MissionReducer, MissionReduction, MissionStagePatch } from "../reducer-types.js";
import { actionForAgent, extractResumeSignals, extractWeakAreas, isInterviewEvent, isRelevantServiceEvent, isResumeEvent, isRoleplayEvent, missionExplicitlyMatches, serviceHref } from "../reducer-helpers.js";
import {
actionForAgent,
extractResumeSignals,
extractWeakAreas,
isFeedbackEvent,
isInterviewEvent,
isRelevantServiceEvent,
isResumeEvent,
isRoleplayEvent,
missionExplicitlyMatches,
passiveInterviewFeedbackResumeUpgrade,
passiveResumeAnalysisInterviewPractice,
passiveRoleplayFeedbackStoryBank,
serviceHref,
} from "../reducer-helpers.js";
export const salaryNegotiationReducer: MissionReducer = {
missionId: "salary-negotiation-war-room",
@@ -48,6 +62,14 @@ export const salaryNegotiationReducer: MissionReducer = {
priority: 96,
urgency: "today",
}));
actions.push(passiveResumeAnalysisInterviewPractice({
missionId: "salary-negotiation-war-room",
activeMission,
eventId: event.id,
payload,
stageId: "interview",
priority: 88,
}));
eventMessage = "Value evidence is ready for negotiation practice.";
}
@@ -56,11 +78,20 @@ export const salaryNegotiationReducer: MissionReducer = {
eventMessage = "Negotiation drill started.";
}
if (isRoleplayEvent(event.source, type) && type.includes("review_completed")) {
if (isRoleplayEvent(event.source, type) && isFeedbackEvent(type)) {
const weakAreas = extractWeakAreas(payload);
const passive = passiveRoleplayFeedbackStoryBank({
missionId: "salary-negotiation-war-room",
activeMission,
eventId: event.id,
payload,
stageId: "roleplay",
priority: 93,
});
stagePatches.push({ stageId: "roleplay", status: "done", progressPercent: 100, outputSummary: "Negotiation drill reviewed." });
stagePatches.push({ stageId: "qscore", status: "in_progress", progressPercent: 70, outputSummary: "Confidence signals updated." });
artifacts.push({ type: "negotiation_objection_map", title: "Objection handling map", stageId: "roleplay", summary: weakAreas.length ? `Practice objections: ${weakAreas.join(", ")}` : "Negotiation practice review completed.", metadata: { sourceEventId: event.id, weakAreas } });
artifacts.push(passive.artifact);
actions.push(actionForAgent("salary-negotiation-war-room", "roleplay", {
stageId: "roleplay",
serviceId: "roleplay-service",
@@ -74,11 +105,20 @@ export const salaryNegotiationReducer: MissionReducer = {
priority: 94,
urgency: "today",
}));
actions.push(passive.action);
eventMessage = "Negotiation drill review created the next objection-handling action.";
}
if (isInterviewEvent(event.source, type) && type.includes("review_completed")) {
if (isInterviewEvent(event.source, type) && isFeedbackEvent(type)) {
stagePatches.push({ stageId: "interview", status: "done", progressPercent: 100, outputSummary: "Communication confidence signal captured from interview review." });
actions.push(passiveInterviewFeedbackResumeUpgrade({
missionId: "salary-negotiation-war-room",
activeMission,
eventId: event.id,
payload,
stageId: "resume",
priority: 99,
}));
eventMessage = "Interview feedback updated negotiation confidence signals.";
}

View File

@@ -0,0 +1,224 @@
import { and, desc, eq, inArray, sql } from "drizzle-orm";
import { db } from "../db/client.js";
import {
systemNotificationPreferences,
systemNotifications,
type SystemNotificationPreferenceRow,
type SystemNotificationRow,
} from "../db/schema.js";
export const SYSTEM_NOTIFICATION_KINDS = ["session", "billing", "security", "feature", "account"] as const;
export type SystemNotificationKind = typeof SYSTEM_NOTIFICATION_KINDS[number];
export type SystemNotificationPreference = { inApp: boolean; email: boolean };
export type SystemNotificationPreferences = Record<SystemNotificationKind, SystemNotificationPreference>;
export type PublicSystemNotification = {
id: string;
kind: SystemNotificationKind;
title: string;
sub: string;
when: string;
read: boolean;
href?: string;
occurredAt: string;
};
const DEFAULT_PREF: SystemNotificationPreference = { inApp: true, email: true };
export const DEFAULT_SYSTEM_NOTIFICATION_PREFERENCES = Object.fromEntries(
SYSTEM_NOTIFICATION_KINDS.map((kind) => [kind, { ...DEFAULT_PREF }]),
) as SystemNotificationPreferences;
type SeedSystemNotification = {
id: string;
kind: SystemNotificationKind;
title: string;
sub: string;
whenLabel: string;
href?: string;
source: string;
sourceRef?: Record<string, unknown>;
};
function seedRows(userId: string): SeedSystemNotification[] {
return [
{
id: `system:${userId}:session-current-device`,
kind: "session",
title: "Session active on this device",
sub: "You are signed in securely on the current browser.",
whenLabel: "Now",
href: "/settings",
source: "auth",
},
{
id: `system:${userId}:security-profile-review`,
kind: "security",
title: "Security review available",
sub: "Review connected sources and account access from settings.",
whenLabel: "Today",
href: "/settings",
source: "security",
},
{
id: `system:${userId}:feature-dashboard-refresh`,
kind: "feature",
title: "Dashboard experience updated",
sub: "The home dashboard now separates agent inbox items from account notifications.",
whenLabel: "Today",
href: "/",
source: "release",
},
{
id: `system:${userId}:account-preferences-ready`,
kind: "account",
title: "Notification preferences are ready",
sub: "Control in-app and email alerts by category in Settings.",
whenLabel: "Today",
href: "/settings",
source: "account",
},
];
}
function isSystemNotificationKind(value: string): value is SystemNotificationKind {
return (SYSTEM_NOTIFICATION_KINDS as readonly string[]).includes(value);
}
function normalizePreferences(rows: SystemNotificationPreferenceRow[]): SystemNotificationPreferences {
const preferences = { ...DEFAULT_SYSTEM_NOTIFICATION_PREFERENCES };
for (const row of rows) {
if (!isSystemNotificationKind(row.kind)) continue;
preferences[row.kind] = { inApp: row.inApp, email: row.email };
}
return preferences;
}
function publicNotification(row: SystemNotificationRow): PublicSystemNotification {
return {
id: row.id,
kind: row.kind as SystemNotificationKind,
title: row.title,
sub: row.sub,
when: row.whenLabel,
read: Boolean(row.readAt),
href: row.href ?? undefined,
occurredAt: row.occurredAt.toISOString(),
};
}
export async function ensureDefaultSystemNotifications(userId: string) {
const now = new Date();
const rows = seedRows(userId).map((item) => ({
...item,
userId,
sourceRef: item.sourceRef ?? {},
updatedAt: now,
}));
await db
.insert(systemNotifications)
.values(rows)
.onConflictDoUpdate({
target: systemNotifications.id,
set: {
title: sql`excluded.title`,
sub: sql`excluded.sub`,
whenLabel: sql`excluded.when_label`,
href: sql`excluded.href`,
source: sql`excluded.source`,
sourceRef: sql`excluded.source_ref`,
updatedAt: now,
},
});
}
export async function ensureSystemNotificationPreferences(userId: string) {
const now = new Date();
await db
.insert(systemNotificationPreferences)
.values(SYSTEM_NOTIFICATION_KINDS.map((kind) => ({ userId, kind, ...DEFAULT_PREF, updatedAt: now })))
.onConflictDoNothing();
}
export async function getSystemNotificationPreferences(userId: string) {
await ensureSystemNotificationPreferences(userId);
const rows = await db
.select()
.from(systemNotificationPreferences)
.where(eq(systemNotificationPreferences.userId, userId));
return normalizePreferences(rows);
}
export async function updateSystemNotificationPreferences(userId: string, preferences: Partial<Record<SystemNotificationKind, Partial<SystemNotificationPreference>>>) {
await ensureSystemNotificationPreferences(userId);
const now = new Date();
const current = await getSystemNotificationPreferences(userId);
for (const [kind, value] of Object.entries(preferences)) {
if (!isSystemNotificationKind(kind)) continue;
const next = {
inApp: value?.inApp ?? current[kind].inApp,
email: value?.email ?? current[kind].email,
};
await db
.insert(systemNotificationPreferences)
.values({
userId,
kind,
inApp: next.inApp,
email: next.email,
updatedAt: now,
})
.onConflictDoUpdate({
target: [systemNotificationPreferences.userId, systemNotificationPreferences.kind],
set: {
inApp: next.inApp,
email: next.email,
updatedAt: now,
},
});
}
return getSystemNotificationPreferences(userId);
}
export async function listSystemNotifications(userId: string) {
await ensureDefaultSystemNotifications(userId);
const preferences = await getSystemNotificationPreferences(userId);
const enabledKinds = SYSTEM_NOTIFICATION_KINDS.filter((kind) => preferences[kind].inApp);
if (!enabledKinds.length) {
return { notifications: [], preferences };
}
const rows = await db
.select()
.from(systemNotifications)
.where(and(eq(systemNotifications.userId, userId), inArray(systemNotifications.kind, enabledKinds)))
.orderBy(desc(systemNotifications.occurredAt), desc(systemNotifications.createdAt))
.limit(50);
return {
notifications: rows.map(publicNotification),
preferences,
};
}
export async function markSystemNotificationRead(userId: string, notificationId: string) {
const [row] = await db
.update(systemNotifications)
.set({ readAt: new Date(), updatedAt: new Date() })
.where(and(eq(systemNotifications.userId, userId), eq(systemNotifications.id, notificationId)))
.returning();
return row ? publicNotification(row) : null;
}
export async function markAllSystemNotificationsRead(userId: string) {
await db
.update(systemNotifications)
.set({ readAt: new Date(), updatedAt: new Date() })
.where(and(eq(systemNotifications.userId, userId), sql`${systemNotifications.readAt} is null`));
return listSystemNotifications(userId);
}

View File

@@ -1,8 +1,13 @@
import { Hono } from "hono";
import { createClient, type Client } from "rivetkit/client";
import { desc, eq } from "drizzle-orm";
import { config } from "../config.js";
import { requireUser, type AuthContext } from "../auth/clerk.js";
import type { Registry } from "../actors/registry.js";
import { db } from "../db/client.js";
import { growEvents } from "../db/schema.js";
import { listActiveMissionsPg } from "../grow/persistence.js";
import { listMissionActions } from "../missions/actions.js";
let _client: Client<Registry> | null = null;
function getClient(): Client<Registry> {
@@ -24,7 +29,23 @@ export function analyticsRoutes() {
app.get("/user/activity", async (c) => {
const userId = c.get("userId");
return c.json(await getClient().analyticsActor.getOrCreate(["user", userId]).getUserActivity({ userId }));
const events = await db
.select()
.from(growEvents)
.where(eq(growEvents.userId, userId))
.orderBy(desc(growEvents.occurredAt))
.limit(100);
const activeMissions = await listActiveMissionsPg(userId).catch(() => []);
const actions = await listMissionActions(userId, { openOnly: false }).catch(() => []);
return c.json({
kind: "user-activity",
userId,
generatedAt: new Date().toISOString(),
events,
activeMissions: activeMissions.map((item) => item.mission),
actions,
});
});
return app;

View File

@@ -4,8 +4,15 @@ import { config } from "../config.js";
import { db } from "../db/client.js";
import { growEvents } from "../db/schema.js";
import { requireUser, type AuthContext } from "../auth/clerk.js";
import { recordGrowEvent } from "../events/record-grow-event.js";
import {
markGrowEventFailed,
markGrowEventProcessed,
markGrowEventProcessing,
recordGrowEvent,
} from "../events/record-grow-event.js";
import { routeGrowEventToUserActor } from "../events/route-to-user-actor.js";
import { applyQscoreProjection } from "../events/projectors/qscore-projector.js";
import { ensureOnboardingSideEffectsForEvent } from "../events/onboarding-ledger.js";
function serviceAuthorized(auth: string | undefined) {
const token = (auth ?? "").replace(/^Bearer\s+/i, "").trim();
@@ -20,7 +27,41 @@ async function ingest(body: unknown, userId?: string, source?: string) {
routed: false as const,
reason: err instanceof Error ? err.message : String(err),
}));
return { event, route };
if (event.processingStatus === "processed") {
return {
event,
processingStatus: "processed" as const,
route,
qscore: { signals: [], score: undefined, idempotent: true },
onboarding: { qscoreBaselineSeeded: false, curatorOnboarding: { status: "already_processed" } },
};
}
if (!event.userId) {
return {
event,
processingStatus: event.processingStatus,
route,
qscore: { signals: [], score: undefined, skipped: "missing_user_id" },
onboarding: { qscoreBaselineSeeded: false, curatorOnboarding: { status: "skipped", reason: "missing_user_id" } },
};
}
await markGrowEventProcessing(event.id);
try {
const qscore = await applyQscoreProjection(event);
const onboarding = await ensureOnboardingSideEffectsForEvent(event);
if (
onboarding.curatorOnboarding.status === "skipped" &&
onboarding.curatorOnboarding.reason === "loop_failed"
) {
throw new Error("curator_onboarding_loop_failed");
}
await markGrowEventProcessed(event.id);
return { event, processingStatus: "processed" as const, route, qscore, onboarding };
} catch (err) {
await markGrowEventFailed(event.id, err);
throw err;
}
}
export function eventRoutes() {
@@ -30,8 +71,8 @@ export function eventRoutes() {
app.post("/ingest", requireUser, async (c) => {
const userId = c.get("userId");
const body = await c.req.json().catch(() => ({}));
const { event, route } = await ingest(body, userId);
return c.json({ eventId: event.id, processingStatus: event.processingStatus, route }, 202);
const { event, processingStatus, route, qscore, onboarding } = await ingest(body, userId);
return c.json({ eventId: event.id, processingStatus, route, qscore, onboarding }, 202);
});
// Service-to-service ingress. Services may include userId directly, or we resolve it from session correlation.
@@ -41,8 +82,8 @@ export function eventRoutes() {
}
const body = await c.req.json().catch(() => ({}));
const source = c.req.header("x-growqr-source") ?? undefined;
const { event, route } = await ingest(body, undefined, source);
return c.json({ eventId: event.id, processingStatus: event.processingStatus, route }, 202);
const { event, processingStatus, route, qscore, onboarding } = await ingest(body, undefined, source);
return c.json({ eventId: event.id, processingStatus, route, qscore, onboarding }, 202);
});
app.get("/", requireUser, async (c) => {

View File

@@ -2,39 +2,50 @@ import { Hono } from "hono";
import { config } from "../config.js";
import { requireUser, type AuthContext } from "../auth/clerk.js";
import { dismissHomeNotification, getHomeFeed, getHomeFeedDebugCounts } from "../home/home-feed.js";
import { HomeFeedAgentError } from "../home/home-feed-agent.js";
import { seedDemoHome } from "../home/seed-demo-home.js";
import { getRequestUserProfile } from "../services/user-context.js";
import { log } from "../log.js";
import {
getSystemNotificationPreferences,
listSystemNotifications,
markAllSystemNotificationsRead,
markSystemNotificationRead,
updateSystemNotificationPreferences,
type SystemNotificationKind,
type SystemNotificationPreference,
} from "../notifications/system-notifications.js";
function canSeedDemo(userId: string) {
return config.nodeEnv !== "production" || config.adminUserIds.includes(userId);
}
async function getUserServiceProfile(req: Request): Promise<{ userProfile?: Record<string, unknown>; preferences?: Record<string, unknown> }> {
const target = new URL("/api/v1/users/me", config.userServiceUrl.replace(/\/$/, ""));
const headers = new Headers(req.headers);
headers.delete("host");
headers.delete("cookie");
const res = await fetch(target, { method: "GET", headers });
if (!res.ok) return {};
const userProfile = await res.json().catch(() => null) as Record<string, unknown> | null;
const preferences = userProfile?.preferences;
return {
userProfile: userProfile ?? undefined,
preferences: preferences && typeof preferences === "object" && !Array.isArray(preferences) ? preferences as Record<string, unknown> : {},
};
}
export function homeRoutes() {
const app = new Hono<AuthContext>();
app.use("*", requireUser);
app.get("/feed", async (c) => {
const refresh = c.req.query("refresh") === "1" || c.req.query("refresh") === "true";
const profile = await getUserServiceProfile(c.req.raw).catch((err) => {
log.warn({ err, userId: c.get("userId") }, "home feed continuing without user-service profile");
const userId = c.get("userId");
const profile = await getRequestUserProfile(c.req.raw, userId).catch((err) => {
log.warn({ err, userId }, "home feed continuing without user-service profile");
return {};
});
return c.json(await getHomeFeed(c.get("userId"), { refresh, ...profile }));
try {
return c.json(await getHomeFeed(userId, { refresh, ...profile }));
} catch (err) {
if (err instanceof HomeFeedAgentError) {
log.warn({ err, userId }, "home feed generation unavailable");
return c.json(
{
error: "home_feed_generation_unavailable",
message: "Home feed generation is temporarily unavailable. Please retry.",
},
503,
);
}
throw err;
}
});
app.post("/notifications/:id/dismiss", async (c) => {
@@ -42,6 +53,32 @@ export function homeRoutes() {
return c.json({ ok: true });
});
app.get("/system-notifications", async (c) => {
return c.json(await listSystemNotifications(c.get("userId")));
});
app.post("/system-notifications/:id/read", async (c) => {
const notification = await markSystemNotificationRead(c.get("userId"), c.req.param("id"));
if (!notification) return c.json({ error: "notification_not_found" }, 404);
return c.json({ notification });
});
app.post("/system-notifications/read-all", async (c) => {
return c.json(await markAllSystemNotificationsRead(c.get("userId")));
});
app.get("/system-notifications/preferences", async (c) => {
return c.json({ preferences: await getSystemNotificationPreferences(c.get("userId")) });
});
app.put("/system-notifications/preferences", async (c) => {
const body = await c.req.json().catch(() => ({}));
const preferences = typeof body === "object" && body !== null && "preferences" in body
? (body as { preferences?: Partial<Record<SystemNotificationKind, Partial<SystemNotificationPreference>>> }).preferences ?? {}
: {};
return c.json({ preferences: await updateSystemNotificationPreferences(c.get("userId"), preferences) });
});
app.get("/debug/counts", async (c) => c.json(await getHomeFeedDebugCounts(c.get("userId"))));
app.post("/demo/seed", async (c) => {

View File

@@ -10,7 +10,6 @@ const LOG_CONTAINERS = {
interview: "interview-service-api-1",
roleplay: "roleplay-service-api-1",
social: "growqr_social_api",
pathways: "pathways-service-api-1",
courses: "courses_service-api-1",
assessment: "assessment-service-api-1",
matchmaking: "matchmaking-service-api-1",

View File

@@ -12,7 +12,9 @@ import { buildDeterministicMissionSuggestions } from "../missions/suggestions.js
import { createMissionAction, getMissionAction, listMissionActions, updateMissionActionStatus } from "../missions/actions.js";
import { recordGrowEvent } from "../events/record-grow-event.js";
import { routeGrowEventToUserActor } from "../events/route-to-user-actor.js";
import { getRequestUserPreferences } from "../services/user-context.js";
import { missionDetailHref } from "../missions/reducer-helpers.js";
import { runPassiveMissionReviews } from "../missions/lifecycle.js";
let _client: Client<Registry> | null = null;
function getClient(): Client<Registry> {
@@ -68,6 +70,12 @@ const snoozeActionSchema = z.object({
until: z.string().datetime().optional(),
});
const passiveReviewSchema = z.object({
date: z.string().optional(),
force: z.boolean().optional(),
limit: z.number().int().min(1).max(50).optional(),
});
const createInstanceId = (missionId: string) =>
`${missionId}-${Date.now()}-${Math.random().toString(16).slice(2)}`;
@@ -105,18 +113,6 @@ async function getMissionSnapshot(userId: string, active: GrowActiveMission): Pr
return missionActorFor(userId, active.instanceId, active.actorType).getState();
}
async function getUserPreferences(req: Request): Promise<Record<string, unknown>> {
const target = new URL("/api/v1/users/me", config.userServiceUrl.replace(/\/$/, ""));
const headers = new Headers(req.headers);
headers.delete("host");
headers.delete("cookie");
const res = await fetch(target, { method: "GET", headers });
if (!res.ok) return {};
const user = await res.json().catch(() => null) as Record<string, unknown> | null;
const preferences = user?.preferences;
return preferences && typeof preferences === "object" && !Array.isArray(preferences) ? preferences as Record<string, unknown> : {};
}
export function missionRoutes() {
const app = new Hono<AuthContext>();
app.use("*", requireUser);
@@ -183,7 +179,7 @@ export function missionRoutes() {
const windowEnd = new Date();
const windowStart = new Date(windowEnd.getTime() - 24 * 60 * 60 * 1000);
const preferences = await getUserPreferences(c.req.raw);
const preferences = await getRequestUserPreferences(c.req.raw, userId) ?? {};
const run = await createMissionCoachRunPg({
userId,
missionInstanceId: active.mission.instanceId,
@@ -296,6 +292,17 @@ export function missionRoutes() {
return c.json({ action });
});
app.post("/passive/run", async (c) => {
const userId = c.get("userId");
const body = passiveReviewSchema.parse(await c.req.json().catch(() => ({})));
return c.json(await runPassiveMissionReviews({
userId,
date: body.date,
force: body.force,
limit: body.limit,
}));
});
app.post("/:missionId/start", async (c) => {
const userId = c.get("userId");
const missionId = c.req.param("missionId");

View File

@@ -1,14 +1,17 @@
import { Hono } from "hono";
import { HTTPException } from "hono/http-exception";
import { createHash } from "node:crypto";
import { desc, eq } from "drizzle-orm";
import { requireUser, type AuthContext } from "../auth/clerk.js";
import { config } from "../config.js";
import { listServiceCapabilities } from "../workflows/service-capabilities.js";
import { interviewService, resumeService, roleplayService, type JsonObject } from "../services/product-service-clients.js";
import { interviewService, ProductServiceError, resumeService, roleplayService, type JsonObject } from "../services/product-service-clients.js";
import { db } from "../db/client.js";
import { events, growQscoreLatest, growQscoreProjectionState } from "../db/schema.js";
import { recordGrowEvent } from "../events/record-grow-event.js";
import { routeGrowEventToUserActor } from "../events/route-to-user-actor.js";
import { ensureOnboardingBaselineQscore } from "../events/onboarding-qscore.js";
import { ensureOnboardingBaselineQscoreFromLedger } from "../events/onboarding-ledger.js";
import { getRequestUserPreferences, getRequestUserProfile } from "../services/user-context.js";
import { log } from "../log.js";
const LANDING_AGENTS = [
@@ -63,7 +66,10 @@ function missionFromRequest(req: Request, body?: JsonObject): Record<string, unk
}
function curatorTaskIdFromRequest(req: Request, body?: JsonObject) {
const fromBody = body ? getString((body as Record<string, unknown>).curatorTaskId) : undefined;
const params = body && isRecord(body.params) ? body.params : undefined;
const fromBody = body
? getString((body as Record<string, unknown>).curatorTaskId ?? (body as Record<string, unknown>).taskId ?? params?.curatorTaskId ?? params?.taskId)
: undefined;
if (fromBody) return fromBody;
const url = new URL(req.url);
return getString(url.searchParams.get("curatorTaskId"));
@@ -75,6 +81,90 @@ function stripMissionFromBody(body: JsonObject): JsonObject {
return rest;
}
function canonicalSubjectServiceId(source: string) {
if (source.includes("interview")) return "interview";
if (source.includes("roleplay")) return "roleplay";
if (source.includes("resume")) return "resume";
if (source.includes("qscore")) return "qscore";
if (source.includes("matchmaking")) return "matchmaking";
if (source.includes("analytics")) return "analytics";
return source;
}
function externalIdFromPayload(payload: Record<string, unknown>, correlation?: Record<string, unknown>) {
const result = isRecord(payload.result) ? payload.result : {};
return getString(
correlation?.externalId ??
correlation?.sessionId ??
correlation?.resumeId ??
result.task_id ??
result.taskId ??
result.session_id ??
result.sessionId ??
result.scenario_id ??
result.scenarioId ??
result.id,
);
}
function dedupeSegment(value: unknown) {
return getString(value)?.replace(/[^A-Za-z0-9_.:-]+/g, "-");
}
function stableStringify(value: unknown): string {
if (value === null || typeof value !== "object") return JSON.stringify(value);
if (Array.isArray(value)) return `[${value.map((item) => stableStringify(item)).join(",")}]`;
const record = value as Record<string, unknown>;
return `{${Object.keys(record).sort().map((key) => `${JSON.stringify(key)}:${stableStringify(record[key])}`).join(",")}}`;
}
function stableDedupeFingerprint(input: {
userId: string;
source: string;
type: string;
payload: Record<string, unknown>;
correlation?: Record<string, unknown>;
subject?: Record<string, unknown>;
}) {
return createHash("sha256")
.update(stableStringify({
userId: input.userId,
source: input.source,
type: input.type,
subject: input.subject,
correlation: input.correlation,
payload: input.payload,
}))
.digest("hex")
.slice(0, 24);
}
function gatewayDedupeKey(input: {
userId: string;
source: string;
type: string;
payload: Record<string, unknown>;
correlation?: Record<string, unknown>;
subject?: Record<string, unknown>;
}) {
const result = isRecord(input.payload.result) ? input.payload.result : {};
const request = isRecord(input.payload.request) ? input.payload.request : {};
const subject = input.subject ?? {};
const externalId = externalIdFromPayload(input.payload, input.correlation);
const anchor =
dedupeSegment(request.request_id ?? request.requestId) ??
dedupeSegment(input.correlation?.requestId) ??
dedupeSegment(input.correlation?.taskId) ??
dedupeSegment(input.correlation?.curatorTaskId) ??
dedupeSegment(input.correlation?.sessionId) ??
dedupeSegment(input.correlation?.resumeId) ??
dedupeSegment(input.correlation?.externalId) ??
dedupeSegment(subject.externalId) ??
dedupeSegment(externalId) ??
dedupeSegment(result.task_id ?? result.taskId ?? result.session_id ?? result.sessionId ?? result.id);
return `${input.source}:${input.type}:${input.userId}:${anchor ?? stableDedupeFingerprint(input)}`;
}
async function recordGatewayEvent(input: {
userId: string;
source: string;
@@ -82,6 +172,8 @@ async function recordGatewayEvent(input: {
payload: Record<string, unknown>;
correlation?: Record<string, unknown>;
mission?: Record<string, unknown>;
subject?: Record<string, unknown>;
dedupeKey?: string;
}) {
await db.insert(events).values({
userId: input.userId,
@@ -96,7 +188,12 @@ async function recordGatewayEvent(input: {
category: "service",
userId: input.userId,
occurredAt: new Date().toISOString(),
dedupeKey: input.dedupeKey ?? gatewayDedupeKey(input),
mission: input.mission,
subject: input.subject ?? {
serviceId: canonicalSubjectServiceId(input.source),
externalId: externalIdFromPayload(input.payload, input.correlation) ?? `${canonicalSubjectServiceId(input.source)}:${input.type}`,
},
correlation: input.correlation,
payload: input.payload,
});
@@ -106,21 +203,107 @@ async function recordGatewayEvent(input: {
function eventTypeForReview(prefix: "interview" | "roleplay", result: Record<string, unknown>) {
const status = getString(result.status);
if (status === "completed") return `${prefix}.review_completed`;
if (status === "failed") return `${prefix}.review_failed`;
return `${prefix}.review_processing`;
if (status === "completed") return prefix === "interview" ? "interview.feedback.generated" : "roleplay.feedback.generated";
if (status === "failed") return prefix === "interview" ? "interview.feedback.failed" : "roleplay.feedback.failed";
return prefix === "interview" ? "interview.feedback.processing" : "roleplay.feedback.processing";
}
function resumeEventTypeForRest(method: string, rest: string, ok: boolean) {
if (!ok) return "resume.request_failed";
if (method === "POST" && /^resumes\/upload/.test(rest)) return "resume.uploaded";
if (method === "POST" && /^parse\/resume\/[^/]+\/parse/.test(rest)) return "resume.parsed";
if (method === "POST" && /^ai\/analyze\//.test(rest)) return "resume.analysis_completed";
if (method === "POST" && /^ai\/analyze\//.test(rest)) return "resume.analysis.completed";
if ((method === "POST" || method === "PUT" || method === "PATCH") && /versions?/.test(rest)) return "resume.version_created";
if (method !== "GET") return "resume.updated";
return "resume.loaded";
}
function agentDataAction(result: Record<string, unknown>) {
const messages = Array.isArray(result.messages) ? result.messages : [];
for (const item of messages) {
const message = isRecord(item) ? item : {};
const action = getString(message.action);
if (message.type === "agent_data" && action) return action;
}
return undefined;
}
function resultHasAgentError(result: Record<string, unknown>) {
if (getString(result.status) === "error") return true;
const messages = Array.isArray(result.messages) ? result.messages : [];
return messages.some((item) => isRecord(item) && item.type === "agent_error");
}
function resumeEventTypeForA2a(action: string | undefined, result: Record<string, unknown>) {
if (resultHasAgentError(result)) return "resume.request_failed";
const effective = agentDataAction(result) ?? action ?? "";
if (["ai_analyze", "analyze_resume", "bg_parse_analyze", "ai_analysis_complete"].includes(effective)) return "resume.analysis.completed";
if (["parse_resume", "resume_parsed"].includes(effective)) return "resume.parsed";
if (["export_pdf", "export_analysis_pdf", "resume_exported"].includes(effective)) return "resume.exported";
if (["create_resume", "resume_created", "save_version", "update_resume_meta"].includes(effective)) return "resume.updated";
return "resume.updated";
}
function resumeIdFromA2a(body: JsonObject, result: Record<string, unknown>) {
const params = isRecord(body.params) ? body.params : {};
const messages = Array.isArray(result.messages) ? result.messages : [];
for (const item of messages) {
const message = isRecord(item) ? item : {};
const data = isRecord(message.data) ? message.data : {};
const resume = isRecord(data.resume) ? data.resume : {};
const id = getString(data.resume_id ?? data.resumeId ?? resume.id);
if (id) return id;
}
return getString(params.resume_id ?? params.resumeId ?? result.task_id ?? result.taskId);
}
function serviceErrorResponse(err: unknown): never {
if (err instanceof ProductServiceError) {
let detail: unknown = err.body;
try {
detail = err.body ? JSON.parse(err.body) : {};
} catch {
detail = { detail: err.body };
}
throw new HTTPException(err.status as never, { message: JSON.stringify(detail) });
}
throw err;
}
function matchmakingEventType(action: string, response: Record<string, unknown>) {
if (response.status === "error") return "matchmaking.request.failed";
if (action === "run_search" || action === "generate_matches") return "matchmaking.matches.generated";
if (action === "get_scout_feed" || action === "get_feed" || action === "session_start") return "matchmaking.feed.viewed";
if (action === "get_opportunity_detail" || action === "mark_viewed") return "matchmaking.match.viewed";
if (action === "mark_saved" || action === "record_feedback") return "matchmaking.match.saved";
if (action === "dismiss_opportunity") return "matchmaking.match.dismissed";
if (action === "tailor_resume") return "matchmaking.application.started";
if (action === "submit_application") return "matchmaking.application.completed";
return "matchmaking.workflow.completed";
}
async function callMatchmakingA2a(body: Record<string, unknown>, userId: string) {
const target = new URL("/a2a/tasks", config.matchmakingServiceUrl.replace(/\/$/, ""));
const action = getString(body.action) ?? (body.session_start ? "session_start" : "");
const payload = {
...body,
action: action === "get_feed" ? "get_scout_feed" : action,
user_id: getString(body.user_id) ?? userId,
};
const res = await fetch(target, {
method: "POST",
headers: {
"content-type": "application/json",
...(config.a2aAllowedKey ? { authorization: `Bearer ${config.a2aAllowedKey}` } : {}),
},
body: JSON.stringify(payload),
});
const text = await res.text();
const result = text ? JSON.parse(text) as Record<string, unknown> : {};
if (!res.ok) throw new HTTPException(res.status as never, { message: text || "matchmaking request failed" });
return { action: String(payload.action || action), result };
}
function parseJsonBody(body: ArrayBuffer | undefined, headers: Headers): JsonObject {
if (!body || !headers.get("content-type")?.includes("application/json")) return {};
try {
@@ -208,25 +391,6 @@ function stringArray(value: unknown): string[] {
return Array.isArray(value) ? value.map((item) => String(item).trim()).filter(Boolean) : [];
}
async function getUserServiceProfile(req: Request): Promise<{ userProfile?: Record<string, unknown>; preferences?: Record<string, unknown> }> {
const target = new URL("/api/v1/users/me", config.userServiceUrl.replace(/\/$/, ""));
const headers = new Headers(req.headers);
headers.delete("host");
headers.delete("cookie");
const res = await fetch(target, { method: "GET", headers });
if (!res.ok) return {};
const userProfile = await res.json().catch(() => null) as Record<string, unknown> | null;
const preferences = userProfile?.preferences;
return {
userProfile: userProfile ?? undefined,
preferences: isRecord(preferences) ? preferences : {},
};
}
async function getUserServicePreferences(req: Request): Promise<Record<string, unknown> | undefined> {
return (await getUserServiceProfile(req)).preferences;
}
async function getServiceState(baseUrl: string, path: string): Promise<Record<string, unknown> | undefined> {
const target = new URL(path, baseUrl.replace(/\/$/, ""));
const res = await fetch(target, {
@@ -253,7 +417,7 @@ function mergeUniqueSkills(existing: unknown, incoming: unknown): string[] {
}
async function resolveGrowUserContext(req: Request, userId: string): Promise<Record<string, unknown>> {
const { userProfile } = await getUserServiceProfile(req);
const { userProfile } = await getRequestUserProfile(req, userId);
const userContext: Record<string, unknown> = { ...(userProfile ?? {}) };
userContext.clerk_id = String(userContext.clerk_id ?? userId);
@@ -442,7 +606,7 @@ export function serviceRoutes() {
const app = new Hono<AuthContext>();
app.use("*", requireUser);
app.get("/catalog", (c) => c.json({ services: listServiceCapabilities() }));
app.get("/catalog", (c) => c.json({ services: listServiceCapabilities({ public: true }) }));
app.get("/agents", async (c) => {
const userId = c.get("userId");
@@ -491,7 +655,7 @@ export function serviceRoutes() {
app.get("/qscore/current", async (c) => {
const userId = c.get("userId");
try {
await ensureOnboardingBaselineQscore(userId, await getUserServicePreferences(c.req.raw));
await ensureOnboardingBaselineQscoreFromLedger(userId);
} catch (err) {
log.warn({ err, userId }, "failed to seed onboarding Q Score baseline before current Q Score read");
}
@@ -535,7 +699,7 @@ export function serviceRoutes() {
? Math.round(signals.reduce((sum, signal) => sum + signal.score, 0) / signals.length)
: null;
return c.json({
const response = {
qscore: score === null ? null : {
score,
signalCount: projection?.signalCount ?? signals.length,
@@ -551,7 +715,17 @@ export function serviceRoutes() {
occurredAt: signal.occurredAt.toISOString(),
updatedAt: signal.updatedAt.toISOString(),
})),
});
};
await recordGatewayEvent({
userId,
source: "qscore-service",
type: "qscore.review.opened",
payload: { score, signalCount: signals.length, source: "services.qscore.current" },
correlation: { taskId: curatorTaskIdFromRequest(c.req.raw) },
}).catch((err) => log.warn({ err, userId }, "failed to record qscore review event"));
return c.json(response);
});
app.get("/interview/page-state", async (c) => {
@@ -574,19 +748,23 @@ export function serviceRoutes() {
const body = await c.req.json<JsonObject>();
const mission = missionFromRequest(c.req.raw, body);
const payload = await buildPersonalizedConfigurePayload(c.req.raw, body, userId);
const result = await interviewService.configure(payload);
const result = await interviewService.configure(payload).catch(serviceErrorResponse);
const resultObj = result as Record<string, unknown>;
await recordGatewayEvent({
userId,
source: "interview-service",
type: "interview.configured",
type: "interview.session.configured",
payload: { request: payload, result: resultObj },
correlation: { sessionId: getSessionId(resultObj), requestId: getString(body.request_id), taskId: curatorTaskIdFromRequest(c.req.raw, body) },
mission,
}).catch((err) => log.warn({ err }, "failed to record interview configured event"));
return c.json(result);
});
app.post("/interview/preview", async (c) => c.json(await interviewService.preview(await c.req.json<JsonObject>())));
app.post("/interview/preview", async (c) => {
const body = await c.req.json<JsonObject>();
const payload = await buildPersonalizedConfigurePayload(c.req.raw, body, c.get("userId"));
return c.json(await interviewService.preview(payload));
});
app.post("/interview/questions", async (c) => c.json(await interviewService.editQuestions(await c.req.json())));
app.post("/interview/approve", async (c) => {
const body = await c.req.json<{ session_id: string }>();
@@ -599,7 +777,7 @@ export function serviceRoutes() {
app.get("/interview/review/:sessionId", async (c) => {
const userId = c.get("userId");
const sessionId = c.req.param("sessionId");
const result = await interviewService.review(sessionId);
const result = await interviewService.review(sessionId, userId).catch(serviceErrorResponse);
const resultObj = result as Record<string, unknown>;
await recordGatewayEvent({
userId,
@@ -611,9 +789,9 @@ export function serviceRoutes() {
return c.json(result);
});
app.get("/interview/leaderboard", async (c) => c.json(await interviewService.leaderboard()));
app.get("/interview/artifacts/:sessionId/:artifactType", async (c) => c.json(await interviewService.artifact(c.req.param("sessionId"), c.req.param("artifactType"))));
app.post("/interview/sessions/:sessionId/video/upload-url", async (c) => c.json(await interviewService.createVideoUploadUrl(c.req.param("sessionId"))));
app.post("/interview/sessions/:sessionId/video/uploaded", async (c) => c.json(await interviewService.markVideoUploaded(c.req.param("sessionId"))));
app.get("/interview/artifacts/:sessionId/:artifactType", async (c) => c.json(await interviewService.artifact(c.req.param("sessionId"), c.req.param("artifactType"), c.get("userId"))));
app.post("/interview/sessions/:sessionId/video/upload-url", async (c) => c.json(await interviewService.createVideoUploadUrl(c.req.param("sessionId"), c.get("userId"))));
app.post("/interview/sessions/:sessionId/video/uploaded", async (c) => c.json(await interviewService.markVideoUploaded(c.req.param("sessionId"), c.get("userId"))));
app.get("/roleplay/page-state", async (c) => {
const userId = c.get("userId");
@@ -635,19 +813,23 @@ export function serviceRoutes() {
const body = await c.req.json<JsonObject>();
const mission = missionFromRequest(c.req.raw, body);
const payload = await buildPersonalizedRoleplayConfigurePayload(c.req.raw, body, userId);
const result = await roleplayService.configure(payload);
const result = await roleplayService.configure(payload).catch(serviceErrorResponse);
const resultObj = result as Record<string, unknown>;
await recordGatewayEvent({
userId,
source: "roleplay-service",
type: "roleplay.configured",
type: "roleplay.scenario.configured",
payload: { request: payload, result: resultObj },
correlation: { sessionId: getSessionId(resultObj), requestId: getString(body.request_id), taskId: curatorTaskIdFromRequest(c.req.raw, body) },
mission,
}).catch((err) => log.warn({ err }, "failed to record roleplay configured event"));
return c.json(result);
});
app.post("/roleplay/preview", async (c) => c.json(await roleplayService.preview(await c.req.json<JsonObject>())));
app.post("/roleplay/preview", async (c) => {
const body = await c.req.json<JsonObject>();
const payload = await buildPersonalizedRoleplayConfigurePayload(c.req.raw, body, c.get("userId"));
return c.json(await roleplayService.preview(payload));
});
app.post("/roleplay/questions", async (c) => c.json(await roleplayService.editQuestions(await c.req.json())));
app.post("/roleplay/approve", async (c) => {
const body = await c.req.json<{ session_id: string }>();
@@ -660,7 +842,7 @@ export function serviceRoutes() {
app.get("/roleplay/review/:sessionId", async (c) => {
const userId = c.get("userId");
const sessionId = c.req.param("sessionId");
const result = await roleplayService.review(sessionId);
const result = await roleplayService.review(sessionId, userId).catch(serviceErrorResponse);
const resultObj = result as Record<string, unknown>;
await recordGatewayEvent({
userId,
@@ -672,14 +854,29 @@ export function serviceRoutes() {
return c.json(result);
});
app.get("/roleplay/leaderboard", async (c) => c.json(await roleplayService.leaderboard()));
app.get("/roleplay/artifacts/:sessionId/:artifactType", async (c) => c.json(await roleplayService.artifact(c.req.param("sessionId"), c.req.param("artifactType"))));
app.post("/roleplay/sessions/:sessionId/video/upload-url", async (c) => c.json(await roleplayService.createVideoUploadUrl(c.req.param("sessionId"))));
app.post("/roleplay/sessions/:sessionId/video/uploaded", async (c) => c.json(await roleplayService.markVideoUploaded(c.req.param("sessionId"))));
app.get("/roleplay/artifacts/:sessionId/:artifactType", async (c) => c.json(await roleplayService.artifact(c.req.param("sessionId"), c.req.param("artifactType"), c.get("userId"))));
app.post("/roleplay/sessions/:sessionId/video/upload-url", async (c) => c.json(await roleplayService.createVideoUploadUrl(c.req.param("sessionId"), c.get("userId"))));
app.post("/roleplay/sessions/:sessionId/video/uploaded", async (c) => c.json(await roleplayService.markVideoUploaded(c.req.param("sessionId"), c.get("userId"))));
app.get("/resume/state/:clerkId", async (c) => c.json(await resumeService.state(c.req.param("clerkId"))));
app.post("/resume/tasks", async (c) => {
const userId = c.get("userId");
const body = await c.req.json<JsonObject>();
return c.json(await resumeService.task({ ...body, user_id: String(body.user_id ?? c.get("userId")) }));
const result = await resumeService.task({ ...body, user_id: String(body.user_id ?? userId) });
const resultObj = result as Record<string, unknown>;
const resumeId = resumeIdFromA2a(body, resultObj);
await recordGatewayEvent({
userId,
source: "resume-builder",
type: resumeEventTypeForA2a(getString(body.action), resultObj),
payload: { request: body, result: resultObj },
correlation: {
taskId: curatorTaskIdFromRequest(c.req.raw, body),
resumeId,
externalId: resumeId,
},
}).catch((err) => log.warn({ err, userId, action: body.action }, "failed to record resume A2A workflow event"));
return c.json(result);
});
// Frontend Resume Builder routes should preserve the user's Clerk bearer token
@@ -699,5 +896,23 @@ export function serviceRoutes() {
return proxySocialRequest(c.req.raw, rest, c.get("userId"));
});
app.post("/matchmaking/a2a", async (c) => {
const userId = c.get("userId");
const body = await c.req.json<JsonObject>().catch(() => ({}));
const { action, result } = await callMatchmakingA2a(body, userId);
await recordGatewayEvent({
userId,
source: "matchmaking-v2",
type: matchmakingEventType(action, result),
payload: { request: body, result },
correlation: {
taskId: curatorTaskIdFromRequest(c.req.raw, body),
externalId: getString(result.task_id ?? result.taskId),
},
mission: missionFromRequest(c.req.raw, body),
}).catch((err) => log.warn({ err, userId, action }, "failed to record matchmaking workflow event"));
return c.json(result);
});
return app;
}

View File

@@ -3,10 +3,15 @@ import { requireUser, type AuthContext } from "../auth/clerk.js";
import { db } from "../db/client.js";
import { users, userStacks, type UserStack } from "../db/schema.js";
import { eq } from "drizzle-orm";
import { provisionUserStack } from "../docker/manager.js";
import { log } from "../log.js";
import { config } from "../config.js";
import { ensureOnboardingBaselineQscore } from "../events/onboarding-qscore.js";
import {
onboardingCompletedAtFromPreferences,
} from "../v1/curator/curator-onboarding-loop.js";
import {
getLatestValidOnboardingLedgerEvent,
recordAndProcessOnboardingCompletion,
} from "../events/onboarding-ledger.js";
function publicStack(stack: UserStack | null | undefined) {
if (!stack) return stack;
@@ -79,12 +84,6 @@ export function userRoutes() {
where: eq(userStacks.userId, userId),
});
if (!stack || stack.status !== "running") {
void provisionUserStack(userId).catch((err) =>
log.error({ err, userId }, "background provision failed"),
);
}
return c.json({
user: userServiceUser,
backendUser: userRow,
@@ -93,6 +92,25 @@ export function userRoutes() {
});
});
app.get("/onboarding-status", async (c) => {
const userId = c.get("userId");
const event = await getLatestValidOnboardingLedgerEvent(userId);
return c.json({
userId,
hasOnboardingEvent: Boolean(event),
onboardingEvent: event
? {
id: event.id,
type: event.type,
occurredAt: event.occurredAt.toISOString(),
processingStatus: event.processingStatus,
}
: null,
needsOnboarding: !event,
});
});
app.get("/me", async (c) => proxyUserService(c.req.raw, "/me"));
app.patch("/me", async (c) => {
const res = await fetchUserService(c.req.raw, "/me");
@@ -102,14 +120,23 @@ export function userRoutes() {
try {
const userProfile = JSON.parse(text) as Record<string, unknown>;
const preferences = userProfile.preferences;
await ensureOnboardingBaselineQscore(
c.get("userId"),
preferences && typeof preferences === "object" && !Array.isArray(preferences)
? (preferences as Record<string, unknown>)
: undefined,
);
const normalizedPreferences = preferences && typeof preferences === "object" && !Array.isArray(preferences)
? (preferences as Record<string, unknown>)
: undefined;
const completedAt = onboardingCompletedAtFromPreferences(normalizedPreferences);
if (completedAt) {
await recordAndProcessOnboardingCompletion({
userId: c.get("userId"),
completedAt,
source: "user-service-profile",
context: {
preferences: normalizedPreferences,
profile: userProfile,
},
});
}
} catch (err) {
log.warn({ err, userId: c.get("userId") }, "failed to seed onboarding Q Score baseline after user update");
log.warn({ err, userId: c.get("userId") }, "failed to run onboarding side effects after user update");
}
}

View File

@@ -249,7 +249,7 @@ async function runModulesUntilGate(input: {
}
function extractQScore(output: Record<string, unknown>): number | undefined {
const direct = output.q_score ?? output.estimated_q_score;
const direct = output.q_score;
if (typeof direct === "number") return Math.round(direct);
const compute = output.compute as Record<string, unknown> | undefined;
if (typeof compute?.q_score === "number") return Math.round(compute.q_score);

View File

@@ -9,9 +9,31 @@ export type ServiceCallOptions = {
timeoutMs?: number;
};
export class ProductServiceError extends Error {
constructor(
message: string,
readonly status: number,
readonly body: string,
readonly path: string,
) {
super(message);
this.name = "ProductServiceError";
}
}
const DEFAULT_SERVICE_TIMEOUT_MS = Number(process.env.PRODUCT_SERVICE_TIMEOUT_MS ?? 3500);
const INTERACTIVE_SERVICE_TIMEOUT_MS = Number(process.env.PRODUCT_INTERACTIVE_SERVICE_TIMEOUT_MS ?? 120000);
function userHeader(userId?: string): Record<string, string> | undefined {
return userId ? { "x-growqr-user": userId } : undefined;
}
function resolveUserPayload(userIdOrPayload?: string | JsonObject, payload?: JsonObject) {
return typeof userIdOrPayload === "string"
? { userId: userIdOrPayload, payload }
: { userId: undefined, payload: userIdOrPayload };
}
async function serviceJson<T = JsonObject>(
baseUrl: string,
path: string,
@@ -28,13 +50,16 @@ async function serviceJson<T = JsonObject>(
signal: AbortSignal.timeout(opts.timeoutMs ?? DEFAULT_SERVICE_TIMEOUT_MS),
});
const text = await res.text();
if (!res.ok) throw new Error(`${path} returned HTTP ${res.status}: ${text}`);
if (!res.ok) throw new ProductServiceError(`${path} returned HTTP ${res.status}: ${text}`, res.status, text, path);
return (text ? JSON.parse(text) : {}) as T;
}
export const interviewService = {
health: () => serviceJson(config.interviewServiceUrl, "/health"),
pageState: (userId: string) => serviceJson(config.interviewServiceUrl, `/api/v1/interviews/page-state?${new URLSearchParams({ user_id: userId })}`),
pageState: (userId: string) =>
serviceJson(config.interviewServiceUrl, `/api/v1/interviews/page-state?${new URLSearchParams({ user_id: userId })}`, {
headers: userHeader(userId),
}),
configure: (payload: JsonObject) => serviceJson(config.interviewServiceUrl, "/api/v1/configure", { body: payload, timeoutMs: INTERACTIVE_SERVICE_TIMEOUT_MS }),
preview: (payload: JsonObject) => serviceJson(config.interviewServiceUrl, "/api/v1/configure/preview", { body: payload, timeoutMs: INTERACTIVE_SERVICE_TIMEOUT_MS }),
editQuestions: (payload: { session_id: string; questions: Array<JsonObject | string> }) =>
@@ -49,25 +74,39 @@ export const interviewService = {
serviceJson(config.interviewServiceUrl, "/api/v1/interviews/assignments/unassign", { body: payload }),
resultsBulk: (payload: JsonObject) =>
serviceJson(config.interviewServiceUrl, "/api/v1/interviews/results:bulk", { body: payload }),
review: (sessionId: string) => serviceJson(config.interviewServiceUrl, `/api/v1/review/${encodeURIComponent(sessionId)}`),
review: (sessionId: string, userId?: string) =>
serviceJson(config.interviewServiceUrl, `/api/v1/review/${encodeURIComponent(sessionId)}`, {
headers: userHeader(userId),
}),
leaderboard: () => serviceJson(config.interviewServiceUrl, "/api/v1/leaderboard"),
artifact: (sessionId: string, artifactType: string) =>
serviceJson(config.interviewServiceUrl, `/api/v1/artifacts/${encodeURIComponent(sessionId)}/${encodeURIComponent(artifactType)}`),
createVideoUploadUrl: (sessionId: string, payload?: JsonObject) =>
serviceJson(config.interviewServiceUrl, `/api/v1/sessions/${encodeURIComponent(sessionId)}/video/upload-url`, {
method: "POST",
...(payload === undefined ? {} : { body: payload }),
artifact: (sessionId: string, artifactType: string, userId?: string) =>
serviceJson(config.interviewServiceUrl, `/api/v1/artifacts/${encodeURIComponent(sessionId)}/${encodeURIComponent(artifactType)}`, {
headers: userHeader(userId),
}),
markVideoUploaded: (sessionId: string, payload?: JsonObject) =>
serviceJson(config.interviewServiceUrl, `/api/v1/sessions/${encodeURIComponent(sessionId)}/video/uploaded`, {
createVideoUploadUrl: (sessionId: string, userIdOrPayload?: string | JsonObject, payloadInput?: JsonObject) => {
const { userId, payload } = resolveUserPayload(userIdOrPayload, payloadInput);
return serviceJson(config.interviewServiceUrl, `/api/v1/sessions/${encodeURIComponent(sessionId)}/video/upload-url`, {
method: "POST",
headers: userHeader(userId),
...(payload === undefined ? {} : { body: payload }),
}),
});
},
markVideoUploaded: (sessionId: string, userIdOrPayload?: string | JsonObject, payloadInput?: JsonObject) => {
const { userId, payload } = resolveUserPayload(userIdOrPayload, payloadInput);
return serviceJson(config.interviewServiceUrl, `/api/v1/sessions/${encodeURIComponent(sessionId)}/video/uploaded`, {
method: "POST",
headers: userHeader(userId),
...(payload === undefined ? {} : { body: payload }),
});
},
};
export const roleplayService = {
health: () => serviceJson(config.roleplayServiceUrl, "/health"),
pageState: (userId: string) => serviceJson(config.roleplayServiceUrl, `/api/v1/roleplays/page-state?${new URLSearchParams({ user_id: userId })}`),
pageState: (userId: string) =>
serviceJson(config.roleplayServiceUrl, `/api/v1/roleplays/page-state?${new URLSearchParams({ user_id: userId })}`, {
headers: userHeader(userId),
}),
configure: (payload: JsonObject) => serviceJson(config.roleplayServiceUrl, "/api/v1/roleplays/configure", { body: payload, timeoutMs: INTERACTIVE_SERVICE_TIMEOUT_MS }),
preview: (payload: JsonObject) => serviceJson(config.roleplayServiceUrl, "/api/v1/roleplays/configure/preview", { body: payload, timeoutMs: INTERACTIVE_SERVICE_TIMEOUT_MS }),
editQuestions: (payload: { session_id: string; questions: Array<JsonObject | string> }) =>
@@ -82,20 +121,31 @@ export const roleplayService = {
serviceJson(config.roleplayServiceUrl, "/api/v1/roleplays/assignments/unassign", { body: payload }),
resultsBulk: (payload: JsonObject) =>
serviceJson(config.roleplayServiceUrl, "/api/v1/roleplays/results:bulk", { body: payload }),
review: (sessionId: string) => serviceJson(config.roleplayServiceUrl, `/api/v1/roleplays/review/${encodeURIComponent(sessionId)}`),
review: (sessionId: string, userId?: string) =>
serviceJson(config.roleplayServiceUrl, `/api/v1/roleplays/review/${encodeURIComponent(sessionId)}`, {
headers: userHeader(userId),
}),
leaderboard: () => serviceJson(config.roleplayServiceUrl, "/api/v1/roleplays/leaderboard"),
artifact: (sessionId: string, artifactType: string) =>
serviceJson(config.roleplayServiceUrl, `/api/v1/artifacts/${encodeURIComponent(sessionId)}/${encodeURIComponent(artifactType)}`),
createVideoUploadUrl: (sessionId: string, payload?: JsonObject) =>
serviceJson(config.roleplayServiceUrl, `/api/v1/sessions/${encodeURIComponent(sessionId)}/video/upload-url`, {
method: "POST",
...(payload === undefined ? {} : { body: payload }),
artifact: (sessionId: string, artifactType: string, userId?: string) =>
serviceJson(config.roleplayServiceUrl, `/api/v1/artifacts/${encodeURIComponent(sessionId)}/${encodeURIComponent(artifactType)}`, {
headers: userHeader(userId),
}),
markVideoUploaded: (sessionId: string, payload?: JsonObject) =>
serviceJson(config.roleplayServiceUrl, `/api/v1/sessions/${encodeURIComponent(sessionId)}/video/uploaded`, {
createVideoUploadUrl: (sessionId: string, userIdOrPayload?: string | JsonObject, payloadInput?: JsonObject) => {
const { userId, payload } = resolveUserPayload(userIdOrPayload, payloadInput);
return serviceJson(config.roleplayServiceUrl, `/api/v1/sessions/${encodeURIComponent(sessionId)}/video/upload-url`, {
method: "POST",
headers: userHeader(userId),
...(payload === undefined ? {} : { body: payload }),
}),
});
},
markVideoUploaded: (sessionId: string, userIdOrPayload?: string | JsonObject, payloadInput?: JsonObject) => {
const { userId, payload } = resolveUserPayload(userIdOrPayload, payloadInput);
return serviceJson(config.roleplayServiceUrl, `/api/v1/sessions/${encodeURIComponent(sessionId)}/video/uploaded`, {
method: "POST",
headers: userHeader(userId),
...(payload === undefined ? {} : { body: payload }),
});
},
};
export const resumeService = {

View File

@@ -236,18 +236,12 @@ async function runQScoreService(ctx: ServiceAgentContext): Promise<ServiceAgentR
},
);
} catch (err) {
// Graceful fallback: formula store unavailable → use static estimate
const avgSignalScore = Math.round(
signals.reduce((sum, s) => sum + s.score, 0) / signals.length,
);
return {
status: "ok",
summary: `Q Score estimated Q Score ~${avgSignalScore} (service compute unavailable: formula store may not be seeded). Based on ${signals.length} signals.`,
status: "unavailable",
summary: `Q Score compute failed; no score was generated: ${err instanceof Error ? err.message : String(err)}`,
detail: {
ingest,
estimated_q_score: avgSignalScore,
signal_scores: signals.map(s => ({ id: s.signal_id, score: s.score })),
compute_fallback: true,
compute_error: err instanceof Error ? err.message : String(err),
},
};

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,102 @@
import { eq } from "drizzle-orm";
import { config } from "../config.js";
import { db } from "../db/client.js";
import { users } from "../db/schema.js";
export type UserProfileContext = {
userProfile?: Record<string, unknown>;
preferences?: Record<string, unknown>;
};
function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === "object" && !Array.isArray(value);
}
function bearerToken(req: Request): string {
return (req.headers.get("authorization") ?? "").replace(/^Bearer\s+/i, "").trim();
}
function isTrustedServiceToken(token: string): boolean {
return Boolean(token && (token === config.serviceToken || token === config.a2aAllowedKey));
}
function splitDisplayName(displayName: string | null | undefined) {
const parts = (displayName ?? "").trim().split(/\s+/).filter(Boolean);
return {
firstName: parts[0] || undefined,
lastName: parts.length > 1 ? parts.slice(1).join(" ") : undefined,
};
}
function mergeProfile(
base: UserProfileContext,
incoming: Record<string, unknown> | null | undefined,
userId: string,
): UserProfileContext {
const userProfile: Record<string, unknown> = { ...(base.userProfile ?? {}) };
if (incoming) {
for (const [key, value] of Object.entries(incoming)) {
if (value !== null && value !== undefined) userProfile[key] = value;
}
}
userProfile.clerk_id = String(userProfile.clerk_id ?? userId);
const preferences = isRecord(incoming?.preferences) ? incoming.preferences : base.preferences ?? {};
return { userProfile, preferences };
}
async function backendMirrorProfile(userId: string): Promise<UserProfileContext> {
const row = await db.query.users.findFirst({ where: eq(users.id, userId) });
const displayName = row?.displayName ?? userId;
const { firstName, lastName } = splitDisplayName(displayName);
return {
userProfile: {
clerk_id: row?.id ?? userId,
email: row?.email ?? `${userId}@service.local`,
display_name: displayName,
first_name: firstName,
last_name: lastName,
preferences: {},
metadata: { source: "backend_user_mirror" },
},
preferences: {},
};
}
async function fetchUserServiceJson(path: string, headers: Headers): Promise<Record<string, unknown> | null> {
const target = new URL(path, config.userServiceUrl.replace(/\/$/, ""));
const res = await fetch(target, { method: "GET", headers });
if (!res.ok) return null;
const json = await res.json().catch(() => null);
return isRecord(json) ? json : null;
}
async function a2aUserState(userId: string): Promise<Record<string, unknown> | null> {
const headers = new Headers();
headers.set("authorization", `Bearer ${config.a2aAllowedKey}`);
return fetchUserServiceJson(`/api/state/${encodeURIComponent(userId)}`, headers);
}
async function clerkUserProfile(req: Request): Promise<Record<string, unknown> | null> {
const headers = new Headers(req.headers);
headers.delete("host");
headers.delete("cookie");
return fetchUserServiceJson("/api/v1/users/me", headers);
}
export async function getRequestUserProfile(req: Request, userId: string): Promise<UserProfileContext> {
const base = await backendMirrorProfile(userId);
const token = bearerToken(req);
if (token && !isTrustedServiceToken(token)) {
const profile = await clerkUserProfile(req);
if (profile) return mergeProfile(base, profile, userId);
}
const state = await a2aUserState(userId);
return mergeProfile(base, state, userId);
}
export async function getRequestUserPreferences(req: Request, userId: string): Promise<Record<string, unknown> | undefined> {
return (await getRequestUserProfile(req, userId)).preferences;
}

View File

@@ -7,7 +7,7 @@ import type { Registry } from "../../actors/registry.js";
import { getConversationModel } from "../../actors/conversation/agent.js";
import { db } from "../../db/client.js";
import { growConversationMessages, growEvents } from "../../db/schema.js";
import { curatorActor } from "../curator/curator-actor.js";
import { curatorService } from "../curator/curator-actor.js";
import { curatorImprovementSignalSchema } from "../curator/curator-types.js";
let _client: Client<Registry> | null = null;
@@ -57,7 +57,7 @@ export const analyticsTools = {
apply_improvement_to_curator: tool({
description: "Apply generated improvement signals to the curator.",
inputSchema: z.object({ userId: z.string(), date: z.string(), signals: z.array(curatorImprovementSignalSchema) }),
execute: async ({ userId, date, signals }) => curatorActor.applyImprovementSignals({ userId, date, signals }),
execute: async ({ userId, date, signals }) => curatorService.applyImprovementSignals({ userId, date, signals }),
}),
};
@@ -103,7 +103,7 @@ export const v1AnalyticsActor = {
},
async applyImprovementSignals(input: { userId: string; date: string; signals: z.infer<typeof curatorImprovementSignalSchema>[] }) {
return curatorActor.applyImprovementSignals(input);
return curatorService.applyImprovementSignals(input);
},
async runNightly(input: { date: string; userId?: string }) {

View File

@@ -1,8 +1,27 @@
import { Hono } from "hono";
import { z } from "zod";
import { and, desc, eq, gte, sql } from "drizzle-orm";
import { requireUser, type AuthContext } from "../../auth/clerk.js";
import { db } from "../../db/client.js";
import { growEvents, growQscoreLatest, growQscoreProjectionState } from "../../db/schema.js";
import { recordGrowEvent } from "../../events/record-grow-event.js";
import { routeGrowEventToUserActor } from "../../events/route-to-user-actor.js";
import { v1AnalyticsActor } from "./analytics-actor.js";
function daysAgo(days: number) {
return new Date(Date.now() - days * 86400000);
}
function sourceBucket(source: string) {
if (source.includes("interview")) return "interview";
if (source.includes("roleplay")) return "roleplay";
if (source.includes("resume")) return "resume";
if (source.includes("qscore")) return "qscore";
if (source.includes("curator")) return "curator";
if (source.includes("match")) return "opportunities";
return source || "unknown";
}
export function v1AnalyticsRoutes() {
const app = new Hono<AuthContext>();
app.use("*", requireUser);
@@ -19,6 +38,120 @@ export function v1AnalyticsRoutes() {
return c.json(await v1AnalyticsActor.getUserActivity({ userId }));
});
app.get("/insight-snapshot", async (c) => {
const userId = c.get("userId");
const [projection] = await db
.select()
.from(growQscoreProjectionState)
.where(eq(growQscoreProjectionState.userId, userId))
.limit(1);
const latestSignals = await db
.select()
.from(growQscoreLatest)
.where(eq(growQscoreLatest.userId, userId))
.orderBy(desc(growQscoreLatest.updatedAt))
.limit(20);
const recentEvents = await db
.select()
.from(growEvents)
.where(and(eq(growEvents.userId, userId), gte(growEvents.occurredAt, daysAgo(14))))
.orderBy(desc(growEvents.occurredAt))
.limit(100);
const [counts] = await db
.select({
total: sql<number>`count(*)::int`,
completed: sql<number>`count(*) filter (where ${growEvents.type} ilike '%completed%' or ${growEvents.type} ilike '%review_completed%')::int`,
opened: sql<number>`count(*) filter (where ${growEvents.type} = 'task.opened' or ${growEvents.type} ilike '%started%')::int`,
})
.from(growEvents)
.where(and(eq(growEvents.userId, userId), gte(growEvents.occurredAt, daysAgo(14))));
const serviceCounts = new Map<string, number>();
for (const event of recentEvents) {
const bucket = sourceBucket(event.source);
serviceCounts.set(bucket, (serviceCounts.get(bucket) ?? 0) + 1);
}
const score = projection?.score ?? null;
const strongestSignal = [...latestSignals].sort((a, b) => b.score - a.score)[0];
const weakestSignal = [...latestSignals].sort((a, b) => a.score - b.score)[0];
const response = {
roleFit: {
score,
label: score === null ? "baseline_needed" : score >= 75 ? "strong" : score >= 55 ? "building" : "needs_focus",
strongestSignal: strongestSignal?.signalId ?? null,
weakestSignal: weakestSignal?.signalId ?? null,
},
readinessTrend: {
signalCount: projection?.signalCount ?? latestSignals.length,
lastUpdatedAt: projection?.updatedAt?.toISOString() ?? latestSignals[0]?.updatedAt?.toISOString() ?? null,
summary: projection?.summary ?? "No projected readiness summary is available yet.",
},
activity: {
totalEvents14d: counts?.total ?? 0,
completedEvents14d: counts?.completed ?? 0,
openedEvents14d: counts?.opened ?? 0,
services: Array.from(serviceCounts.entries()).map(([service, count]) => ({ service, count })),
},
opportunities: {
events14d: recentEvents.filter((event) => sourceBucket(event.source) === "opportunities").length,
latestEventAt: recentEvents.find((event) => sourceBucket(event.source) === "opportunities")?.occurredAt.toISOString() ?? null,
},
source: "grow_events",
};
const event = await recordGrowEvent({
source: "growqr-backend:analytics",
type: "analytics.insight_snapshot.opened",
category: "usage",
userId,
occurredAt: new Date().toISOString(),
dedupeKey: `analytics:insight-snapshot:${userId}:${new Date().toISOString().slice(0, 10)}`,
subject: {
serviceId: "analytics",
externalId: "insight-snapshot",
},
payload: {
score,
signalCount: projection?.signalCount ?? latestSignals.length,
totalEvents14d: counts?.total ?? 0,
source: "v1.analytics.insight-snapshot",
},
});
await routeGrowEventToUserActor(event).catch(() => undefined);
return c.json(response);
});
app.get("/activity-history", async (c) => {
const userId = c.get("userId");
const limit = Math.min(200, Math.max(1, Number(c.req.query("limit") ?? 80)));
const since = c.req.query("since");
const sinceDate = since ? new Date(since) : daysAgo(30);
const rows = await db
.select()
.from(growEvents)
.where(and(eq(growEvents.userId, userId), gte(growEvents.occurredAt, Number.isNaN(sinceDate.getTime()) ? daysAgo(30) : sinceDate)))
.orderBy(desc(growEvents.occurredAt))
.limit(limit);
return c.json({
events: rows.map((event) => ({
id: event.id,
source: event.source,
type: event.type,
category: event.category,
occurredAt: event.occurredAt.toISOString(),
processingStatus: event.processingStatus,
mission: event.mission,
subject: event.subject,
correlation: event.correlation,
payload: event.payload,
})),
count: rows.length,
source: "grow_events",
});
});
app.post("/nightly/run", async (c) => {
const userId = c.get("userId");
const body = z.object({

View File

@@ -8,3 +8,25 @@ V1 replaces the old Daily Mission path with a single Curator layer.
- Services still own their workflows. Curator tools prepare handoffs and routes.
Completion is event gated. A checkbox or chat message cannot complete a task unless a matching service or platform event exists.
## Service Curation Layer
- `icp-registry.ts` defines ICP playbooks and maps each persona goal to registry-backed service actions.
- `curator-user-context.ts` assembles deterministic user context from Grow events and QScore projection state.
- `curator-prompt-builder.ts` builds the LLM-ready curation prompt and stable prompt hash.
- `curator-store.ts` keeps generation idempotent by storing sprint starts in `grow_events` with the plan version, ICP, user context, prompt hash, playbook, plan hash, and 30-day plan days.
- `curator-service-links.ts` is the link builder over the Service Registry. Generated tasks use it to produce actionable frontend deep links.
- `POST /v1/curator/curation/preview` accepts optional `icpId`, `goals`, and `userContext` overrides and returns the assembled prompt, ICP playbook, idempotency hashes, Sunday-start `calendarWeeks`, `days` (all 30 days), `closeoutDays` (day 29-30), and deep-linked tasks.
## Curator Onboarding Loop
- `curator-onboarding-loop.ts` runs once after onboarding completion and creates the user's persisted 30-day streak plan through the curation layer.
- Trigger paths:
- Grow event ingestion: `onboarding.completed`, `user.onboarding.completed`, `profile.onboarding.completed`, or payloads/preferences with `onboarding.completed_at`.
- User profile updates: `PATCH /api/users/me` runs the loop when user-service returns onboarding preferences with `completed_at`.
- QA retry: `POST /v1/curator/onboarding/run` accepts optional `completedAt` and returns `ready` or `already_ready`.
- Before generation, the loop snapshots onboarding context into `grow_events` so curation sees the user-service profile/preferences. Event-only triggers also attempt an internal user-service fetch via the service-token path.
- Idempotency is based on the one-time `curator.onboarding_plan.ready` event. Retries do not duplicate the plan-ready analytics event or in-app notification.
- The loop stores the sprint as `curator.sprint.started`, emits `curator.onboarding_plan.ready` with weekly themes and Day 1 task links, and creates a persistent home notification pointing users to their active plan.
- `task-registry.ts` defines the static day-wise task registry and maps those tasks to service handoffs.

View File

@@ -1,15 +1,69 @@
import { buildCuratorPlan, buildCuratorSprint, buildCuratorStreak, buildCuratorTasks, todayIsoDate } from "./curator-store.js";
import { actor } from "rivetkit";
import { buildCuratorPlan, buildCuratorSprint, buildCuratorStreak, buildCuratorTasks, buildServiceCurationPreview, todayIsoDate } from "./curator-store.js";
import { curatorPlanSchema, curatorSprintResponseSchema, type CuratorImprovementSignal } from "./curator-types.js";
import { emitCuratorEvent } from "./curator-events.js";
import { runCuratorChat } from "./curator-agent.js";
import { prepareHandoffForTask } from "./curator-tools.js";
import type { CuratorIcpId } from "./icp-registry.js";
import { runCuratorOnboardingLoop } from "./curator-onboarding-loop.js";
export const curatorActor = {
type CuratorActorState = {
userId: string;
planGenerations: number;
sprintReads: number;
taskCompletions: number;
lastActionAt?: string;
lastEventId?: string;
};
function addDaysIsoLocal(startDate: string, days: number) {
const date = new Date(`${startDate}T00:00:00.000Z`);
if (Number.isNaN(date.getTime())) return undefined;
date.setUTCDate(date.getUTCDate() + days);
return date.toISOString().slice(0, 10);
}
function dateFromTaskId(taskId: string) {
const match = /:icp-v[^:]+:(\d{4}-\d{2}-\d{2}):day-(\d+):/.exec(taskId);
if (!match) return undefined;
const startDate = match[1];
const daySegment = match[2];
if (!startDate || !daySegment) return undefined;
const dayIndex = Number(daySegment);
if (!Number.isFinite(dayIndex) || dayIndex < 1) return undefined;
return addDaysIsoLocal(startDate, dayIndex - 1);
}
function taskActionDate(input: { taskId: string; date?: string }) {
return input.date ?? dateFromTaskId(input.taskId) ?? todayIsoDate();
}
function touch(c: { state: CuratorActorState }, input: { userId: string }) {
if (c.state.userId && c.state.userId !== input.userId) throw new Error("curatorActor initialized for a different user");
c.state.userId = input.userId;
c.state.lastActionAt = new Date().toISOString();
}
export const curatorService = {
async generatePlanRange(input: { userId: string; startDate?: string; endDate?: string; goals?: string[]; forceRegenerate?: boolean }) {
const startDate = input.startDate ?? todayIsoDate();
const endDate = input.endDate ?? startDate;
const plan = curatorPlanSchema.parse(await buildCuratorPlan(input.userId, { startDate, endDate, goals: input.goals }));
await emitCuratorEvent({ userId: input.userId, type: "curator.plan.generated", payload: { startDate, endDate, goals: input.goals ?? [] } });
await emitCuratorEvent({
userId: input.userId,
type: "curator.plan.generated",
payload: {
startDate,
endDate,
planId: plan.id,
durationDays: plan.durationDays,
goals: input.goals ?? plan.goals,
weekCount: plan.weeks.length,
dayCount: plan.days.length,
plan,
},
});
return { plan };
},
@@ -17,6 +71,18 @@ export const curatorActor = {
return this.generatePlanRange(input);
},
async previewCuration(input: { userId: string; startDate?: string; icpId?: CuratorIcpId; goals?: string[]; userContext?: Record<string, unknown> }) {
return buildServiceCurationPreview(input);
},
async runOnboardingLoop(input: { userId: string; completedAt?: string }) {
return runCuratorOnboardingLoop({
userId: input.userId,
completedAt: input.completedAt,
source: "curator-api",
});
},
async getToday(input: { userId: string; date?: string }) {
const date = input.date ?? todayIsoDate();
const sprint = curatorSprintResponseSchema.parse(await buildCuratorSprint(input.userId, date));
@@ -45,9 +111,16 @@ export const curatorActor = {
},
async startTask(input: { userId: string; taskId: string; date?: string }) {
const date = input.date ?? todayIsoDate();
const date = taskActionDate(input);
const task = (await buildCuratorTasks(input.userId, date)).find((item) => item.id === input.taskId);
if (!task) throw new Error("curator_task_not_found");
if (!task) {
const event = await emitCuratorEvent({
userId: input.userId,
type: "curator.task.started",
payload: { taskId: input.taskId, date, recoveredDeepLink: true },
});
return { task: { id: input.taskId, date, status: "started" as const }, eventId: event.id };
}
const event = await emitCuratorEvent({
userId: input.userId,
type: "curator.task.started",
@@ -58,7 +131,7 @@ export const curatorActor = {
},
async prepareTaskHandoff(input: { userId: string; taskId: string; date?: string }) {
const date = input.date ?? todayIsoDate();
const date = taskActionDate(input);
const task = (await buildCuratorTasks(input.userId, date)).find((item) => item.id === input.taskId);
if (!task) throw new Error("curator_task_not_found");
if (task.serviceId) return prepareHandoffForTask(input.userId, task, task.serviceId);
@@ -66,11 +139,21 @@ export const curatorActor = {
},
async completeTask(input: { userId: string; taskId: string; date?: string; reason?: string }) {
const date = input.date ?? todayIsoDate();
const date = taskActionDate(input);
const task = (await buildCuratorTasks(input.userId, date)).find((item) => item.id === input.taskId);
if (!task) throw new Error("curator_task_not_found");
const reason = input.reason ?? "subtasks_completed";
const allowDirectServiceCompletion = task.serviceId === "qscore-service" && reason === "qscore_review_opened";
if (!task && reason === "matchmaking_matches_reviewed") {
const event = await emitCuratorEvent({
userId: input.userId,
type: "curator.task.completed",
payload: { taskId: input.taskId, date, reason, recoveredDeepLink: true },
});
return { task: { id: input.taskId, date, status: "completed" as const }, eventId: event.id };
}
if (!task) throw new Error("curator_task_not_found");
const allowDirectServiceCompletion =
(task.serviceId === "qscore-service" && reason === "qscore_review_opened") ||
(task.serviceId === "matchmaking-service" && reason === "matchmaking_matches_reviewed");
if (task.serviceId && !allowDirectServiceCompletion) {
throw new Error("curator_service_task_requires_service_event");
}
@@ -102,3 +185,77 @@ export const curatorActor = {
return { tasks: sprint.todayTasks, streak: sprint.streak, sprint };
},
};
export const curatorActor = actor({
options: { name: "Curator Actor", icon: "sparkles", noSleep: true, actionTimeout: 300_000 },
state: {
userId: "",
planGenerations: 0,
sprintReads: 0,
taskCompletions: 0,
} as CuratorActorState,
actions: {
generatePlanRange: async (c, input: Parameters<typeof curatorService.generatePlanRange>[0]) => {
touch(c, input);
c.state.planGenerations += 1;
return curatorService.generatePlanRange(input);
},
getPlan: async (c, input: Parameters<typeof curatorService.getPlan>[0]) => {
touch(c, input);
return curatorService.getPlan(input);
},
previewCuration: async (c, input: Parameters<typeof curatorService.previewCuration>[0]) => {
touch(c, input);
return curatorService.previewCuration(input);
},
runOnboardingLoop: async (c, input: Parameters<typeof curatorService.runOnboardingLoop>[0]) => {
touch(c, input);
return curatorService.runOnboardingLoop(input);
},
getToday: async (c, input: Parameters<typeof curatorService.getToday>[0]) => {
touch(c, input);
c.state.sprintReads += 1;
return curatorService.getToday(input);
},
getSprint: async (c, input: Parameters<typeof curatorService.getSprint>[0]) => {
touch(c, input);
c.state.sprintReads += 1;
return curatorService.getSprint(input);
},
chat: async (c, input: Parameters<typeof curatorService.chat>[0]) => {
touch(c, input);
return curatorService.chat(input);
},
startTask: async (c, input: Parameters<typeof curatorService.startTask>[0]) => {
touch(c, input);
const result = await curatorService.startTask(input);
c.state.lastEventId = result.eventId;
return result;
},
prepareTaskHandoff: async (c, input: Parameters<typeof curatorService.prepareTaskHandoff>[0]) => {
touch(c, input);
return curatorService.prepareTaskHandoff(input);
},
completeTask: async (c, input: Parameters<typeof curatorService.completeTask>[0]) => {
touch(c, input);
const result = await curatorService.completeTask(input);
c.state.taskCompletions += 1;
c.state.lastEventId = result.eventId;
return result;
},
recordServiceImpact: async (c, input: Parameters<typeof curatorService.recordServiceImpact>[0]) => {
touch(c, input);
c.state.lastEventId = input.eventId;
return curatorService.recordServiceImpact(input);
},
applyImprovementSignals: async (c, input: Parameters<typeof curatorService.applyImprovementSignals>[0]) => {
touch(c, input);
return curatorService.applyImprovementSignals(input);
},
getState: async (c, input: Parameters<typeof curatorService.getState>[0]) => {
touch(c, input);
const state = await curatorService.getState(input);
return { ...state, actorState: c.state };
},
},
});

View File

@@ -0,0 +1,352 @@
import { and, desc, eq, inArray } from "drizzle-orm";
import { db } from "../../db/client.js";
import { growEvents, growHomeNotifications, type GrowEventRow } from "../../db/schema.js";
import { asRecord, getString } from "../../events/envelope.js";
import { recordGrowEvent } from "../../events/record-grow-event.js";
import { log } from "../../log.js";
import { config } from "../../config.js";
import { buildCuratorSprint, todayIsoDate } from "./curator-store.js";
import { emitCuratorEvent } from "./curator-events.js";
import type { CuratorSprintResponse } from "./curator-types.js";
const CURATOR_SOURCE = "curator-v1";
const ONBOARDING_READY_EVENT = "curator.onboarding_plan.ready";
const ONBOARDING_SKIPPED_EVENT = "curator.onboarding_plan.skipped";
const ONBOARDING_CONTEXT_EVENT_TYPES = [
"onboarding.snapshot.saved",
"onboarding.completed",
"user.onboarding.completed",
"profile.onboarding.completed",
"onboarding_snapshot_saved",
"onboarding_completed",
"user_onboarding_completed",
"profile_onboarding_completed",
] as const;
type OnboardingLoopInput = {
userId: string;
completedAt?: string | Date | null;
sourceEventId?: string;
source?: string;
context?: Record<string, unknown>;
};
type OnboardingLoopResult =
| { status: "ready"; sprint: CuratorSprintResponse; eventId: string }
| { status: "already_ready"; readyEventId: string; sprint?: CuratorSprintResponse }
| { status: "skipped"; reason: string };
function isoDateFrom(value: string | Date | null | undefined) {
if (value instanceof Date) {
return Number.isNaN(value.getTime()) ? todayIsoDate() : value.toISOString().slice(0, 10);
}
if (typeof value === "string" && value.trim()) {
const parsed = new Date(value);
return Number.isNaN(parsed.getTime()) ? todayIsoDate() : parsed.toISOString().slice(0, 10);
}
return todayIsoDate();
}
function parseCompletedAt(value: unknown): string | undefined {
const raw = getString(value);
if (!raw) return undefined;
const parsed = new Date(raw);
return Number.isNaN(parsed.getTime()) ? new Date().toISOString() : parsed.toISOString();
}
function onboardingContextFromPayload(payload: Record<string, unknown>) {
const preferences = asRecord(payload.preferences);
const onboarding = asRecord(payload.onboarding ?? preferences.onboarding);
if (!Object.keys(onboarding).length && !Object.keys(preferences).length) return undefined;
return {
onboarding,
preferences: Object.keys(preferences).length ? preferences : { onboarding },
source: "grow_events",
};
}
export function onboardingCompletedAtFromPreferences(preferences: Record<string, unknown> | undefined) {
const onboarding = asRecord(preferences?.onboarding);
return parseCompletedAt(onboarding.completed_at ?? onboarding.completedAt);
}
export function onboardingCompletedAtFromEvent(event: Pick<GrowEventRow, "type" | "payload" | "occurredAt">) {
const payload = asRecord(event.payload);
const preferences = asRecord(payload.preferences);
const onboarding = asRecord(payload.onboarding);
return (
parseCompletedAt(payload.completedAt ?? payload.completed_at) ??
onboardingCompletedAtFromPreferences(preferences) ??
parseCompletedAt(onboarding.completed_at ?? onboarding.completedAt) ??
(isOnboardingCompletionEvent(event) ? event.occurredAt.toISOString() : undefined)
);
}
export function isOnboardingCompletionEvent(event: Pick<GrowEventRow, "type" | "payload" | "occurredAt">) {
const normalizedType = event.type.toLowerCase().replaceAll("_", ".");
if (
normalizedType === "onboarding.completed" ||
normalizedType === "user.onboarding.completed" ||
normalizedType === "profile.onboarding.completed"
) {
return true;
}
const payload = asRecord(event.payload);
const preferences = asRecord(payload.preferences);
const onboarding = asRecord(payload.onboarding);
return Boolean(
onboardingCompletedAtFromPreferences(preferences) ??
parseCompletedAt(onboarding.completed_at ?? onboarding.completedAt) ??
parseCompletedAt(payload.onboarding_completed_at ?? payload.onboardingCompletedAt),
);
}
async function findExistingReadyEvent(userId: string) {
const [existing] = await db
.select({ id: growEvents.id, payload: growEvents.payload })
.from(growEvents)
.where(and(
eq(growEvents.userId, userId),
eq(growEvents.source, CURATOR_SOURCE),
eq(growEvents.type, ONBOARDING_READY_EVENT),
))
.orderBy(desc(growEvents.occurredAt))
.limit(1);
return existing;
}
async function recordOnboardingContextSnapshot(input: {
userId: string;
startDate: string;
completedAt?: string | Date | null;
source?: string;
sourceEventId?: string;
context?: Record<string, unknown>;
}) {
if (!input.context || !Object.keys(input.context).length) return;
await recordGrowEvent({
source: input.source ?? "onboarding",
type: "onboarding.completed",
category: "usage",
userId: input.userId,
occurredAt: input.completedAt instanceof Date
? input.completedAt.toISOString()
: typeof input.completedAt === "string" && input.completedAt.trim()
? input.completedAt
: new Date().toISOString(),
correlation: { sourceEventId: input.sourceEventId },
payload: {
completedAt: input.completedAt instanceof Date ? input.completedAt.toISOString() : input.completedAt,
...input.context,
},
dedupeKey: `curator:onboarding-context:${input.userId}:${input.startDate}`,
}, { userId: input.userId, source: input.source ?? "onboarding" });
}
async function findLatestOnboardingContext(userId: string) {
const [event] = await db
.select({ id: growEvents.id, payload: growEvents.payload, occurredAt: growEvents.occurredAt, source: growEvents.source, type: growEvents.type })
.from(growEvents)
.where(and(
eq(growEvents.userId, userId),
inArray(growEvents.type, [...ONBOARDING_CONTEXT_EVENT_TYPES]),
))
.orderBy(desc(growEvents.occurredAt))
.limit(1);
if (!event?.payload) return undefined;
const context = onboardingContextFromPayload(event.payload);
return context ? { ...context, sourceEventId: event.id, sourceEventType: event.type, sourceEventSource: event.source } : undefined;
}
async function fetchUserServiceContext(userId: string): Promise<Record<string, unknown> | undefined> {
const token = config.serviceToken || (config.nodeEnv !== "production" ? config.a2aAllowedKey : "");
if (!token) return undefined;
const target = new URL("/api/v1/users/me", config.userServiceUrl.replace(/\/$/, ""));
const res = await fetch(target, {
method: "GET",
headers: {
authorization: `Bearer ${token}`,
"x-growqr-user": userId,
},
}).catch((err) => {
log.warn({ err, userId }, "curator onboarding could not fetch user-service profile");
return null;
});
if (!res?.ok) return undefined;
const profile = await res.json().catch(() => null) as Record<string, unknown> | null;
if (!profile) return undefined;
const preferences = asRecord(profile.preferences);
return { profile, preferences };
}
function dayOneSubtitle(sprint: CuratorSprintResponse) {
const task = sprint.plan.days[0]?.tasks[0] ?? sprint.todayTasks[0];
if (!task) return "Your personalized Day 1 tasks are ready on the home dashboard.";
return `Day 1 starts with ${task.title.toLowerCase()}.`;
}
async function upsertPlanReadyNotification(userId: string, sprint: CuratorSprintResponse) {
const notificationId = `curator:onboarding-plan-ready:${userId}`;
const expiresAt = new Date(Date.now() + 1000 * 60 * 60 * 24 * 14);
await db
.insert(growHomeNotifications)
.values({
id: notificationId,
userId,
moduleId: "missions",
title: "Your 30-day streak plan is ready",
subtitle: dayOneSubtitle(sprint),
tag: "Day 1 ready",
urgency: "today",
href: "/missions/active",
source: "system",
sourceRef: {
sprintId: sprint.sprintId,
planId: sprint.plan.id,
activeDayIndex: sprint.activeDayIndex,
source: CURATOR_SOURCE,
},
priority: 95,
generatedBy: "manual",
reason: "Created by the curator onboarding loop after onboarding completion.",
status: "active",
expiresAt,
updatedAt: new Date(),
})
.onConflictDoUpdate({
target: growHomeNotifications.id,
set: {
subtitle: dayOneSubtitle(sprint),
sourceRef: {
sprintId: sprint.sprintId,
planId: sprint.plan.id,
activeDayIndex: sprint.activeDayIndex,
source: CURATOR_SOURCE,
},
status: "active",
expiresAt,
updatedAt: new Date(),
},
});
}
function weeklyThemes(sprint: CuratorSprintResponse) {
return sprint.plan.weeks.map((week) => ({
weekIndex: week.weekIndex,
theme: week.theme,
summary: week.summary,
startDayIndex: week.startDayIndex,
endDayIndex: week.endDayIndex,
}));
}
function dayOneTasks(sprint: CuratorSprintResponse) {
return (sprint.plan.days[0]?.tasks ?? sprint.todayTasks).map((task) => ({
id: task.id,
title: task.title,
serviceId: task.serviceId,
route: task.route,
cta: task.cta,
rewardCoins: task.rewardCoins,
}));
}
export async function runCuratorOnboardingLoop(input: OnboardingLoopInput): Promise<OnboardingLoopResult> {
const userId = input.userId.trim();
if (!userId) return { status: "skipped", reason: "missing_user_id" };
const existing = await findExistingReadyEvent(userId);
if (existing) {
return { status: "already_ready", readyEventId: existing.id };
}
const startDate = isoDateFrom(input.completedAt);
const context = input.context ?? await findLatestOnboardingContext(userId) ?? await fetchUserServiceContext(userId);
await recordOnboardingContextSnapshot({
userId,
startDate,
completedAt: input.completedAt,
source: input.source,
sourceEventId: input.sourceEventId,
context,
});
const sprint = await buildCuratorSprint(userId, startDate);
await upsertPlanReadyNotification(userId, sprint);
const event = await emitCuratorEvent({
userId,
type: ONBOARDING_READY_EVENT,
payload: {
source: input.source ?? "onboarding",
sourceEventId: input.sourceEventId,
completedAt: input.completedAt instanceof Date ? input.completedAt.toISOString() : input.completedAt,
startDate: sprint.plan.startDate,
endDate: sprint.plan.endDate,
sprintId: sprint.sprintId,
planId: sprint.plan.id,
durationDays: sprint.plan.durationDays,
weekCount: sprint.plan.weeks.length,
dayCount: sprint.plan.days.length,
activeDayIndex: sprint.activeDayIndex,
weeklyThemes: weeklyThemes(sprint),
dayOneTasks: dayOneTasks(sprint),
notificationId: `curator:onboarding-plan-ready:${userId}`,
},
});
return { status: "ready", sprint, eventId: event.id };
}
export async function runCuratorOnboardingLoopForEvent(event: GrowEventRow): Promise<OnboardingLoopResult> {
if (!event.userId) return { status: "skipped", reason: "missing_user_id" };
if (!isOnboardingCompletionEvent(event)) return { status: "skipped", reason: "not_onboarding_completion" };
return runCuratorOnboardingLoop({
userId: event.userId,
completedAt: onboardingCompletedAtFromEvent(event),
sourceEventId: event.id,
source: event.source,
context: onboardingContextFromPayload(event.payload ?? {}),
});
}
export async function runCuratorOnboardingLoopSafely(input: OnboardingLoopInput): Promise<OnboardingLoopResult> {
try {
return await runCuratorOnboardingLoop(input);
} catch (err) {
log.error({ err, userId: input.userId }, "curator onboarding loop failed");
await emitCuratorEvent({
userId: input.userId,
type: ONBOARDING_SKIPPED_EVENT,
payload: {
reason: "loop_failed",
message: err instanceof Error ? err.message : String(err),
sourceEventId: input.sourceEventId,
},
}).catch((emitErr) => log.warn({ emitErr, userId: input.userId }, "failed to emit curator onboarding failure event"));
return { status: "skipped", reason: "loop_failed" };
}
}
export async function runCuratorOnboardingLoopForEventSafely(event: GrowEventRow): Promise<OnboardingLoopResult> {
try {
return await runCuratorOnboardingLoopForEvent(event);
} catch (err) {
log.error({ err, eventId: event.id, userId: event.userId }, "curator onboarding event loop failed");
if (event.userId) {
await emitCuratorEvent({
userId: event.userId,
type: ONBOARDING_SKIPPED_EVENT,
payload: {
reason: "event_loop_failed",
message: err instanceof Error ? err.message : String(err),
sourceEventId: event.id,
},
}).catch((emitErr) => log.warn({ emitErr, userId: event.userId }, "failed to emit curator onboarding event failure"));
}
return { status: "skipped", reason: "loop_failed" };
}
}

View File

@@ -0,0 +1,101 @@
import { createHash } from "node:crypto";
import type { CuratorIcpPlaybook } from "./icp-registry.js";
import type { CuratorUserContext } from "./curator-user-context.js";
export const CURATOR_PROMPT_VERSION = "service-curation-v1";
export type CuratorPromptAssembly = {
version: typeof CURATOR_PROMPT_VERSION;
hash: string;
prompt: string;
inputs: {
startDate: string;
durationDays: number;
userContext: CuratorUserContext;
playbook: CuratorIcpPlaybook;
goals: string[];
};
};
export function buildCuratorPlanPrompt(input: {
startDate: string;
durationDays: number;
userContext: CuratorUserContext;
playbook: CuratorIcpPlaybook;
goals?: string[];
}): CuratorPromptAssembly {
const goals = input.goals?.filter(Boolean) ?? [input.playbook.sprintTheme, input.playbook.goal];
const inputs = {
startDate: input.startDate,
durationDays: input.durationDays,
userContext: input.userContext,
playbook: input.playbook,
goals,
};
const prompt = [
"# GrowQR Service Curation Layer",
"",
"You generate deterministic 30-day streak plans from user context and an ICP playbook.",
"Do not invent services. Use only service ids present in the playbook and Service Registry.",
"Do not handcraft frontend URLs. Emit linkBuilder inputs; the backend Service Registry builds final deep links.",
"No randomness, no vague tasks, no duplicate same-day service tasks.",
"",
"## Output Contract",
"Return structured JSON only with:",
"- durationDays: 30",
"- calendarWeeks: Sunday-start calendar weeks covering all 30 days",
"- days: exactly 30 days, where Day 1 is the subscription/start date",
"- closeoutDays: day 29 and day 30",
"- each day has exactly 3 tasks: measurement, proof, practice",
"- every task includes taskType, serviceId, title, subtitle, qxImpact, effort, cta, expectedSignals, and linkBuilder input",
"- weekly themes must follow the ICP stage labels",
"",
"## Staging Rules",
"Start weekly grouping on Sunday. If the user subscribes on Monday, Day 1 is Monday inside a Sunday-start Week 1.",
"The sprint is always exactly 30 days. Do not extend or shorten it to fit a calendar week.",
"Use the first calendar week for Baseline + First Proof, then progress through the ICP stage labels.",
"Use Day 29 and Day 30 for next-sprint planning and strongest-proof packaging.",
"",
"## Personalization Rules",
"- Use targetRole for interview and roleplay links.",
"- Use resume/profile context when available; if missing, day 1 proof should collect it.",
"- Use QScore to prioritize measurement tasks.",
"- Use past activity to avoid repeating completed or recently-used actions.",
"- Map every goal to one of the ICP playbook service actions.",
"",
`Start date: ${input.startDate}`,
`Duration days: ${input.durationDays}`,
`Goals: ${goals.join(" | ")}`,
"",
"User context:",
stableStringify(input.userContext),
"",
"ICP playbook:",
stableStringify(input.playbook),
].join("\n");
return {
version: CURATOR_PROMPT_VERSION,
hash: stableHash({ version: CURATOR_PROMPT_VERSION, inputs }),
prompt,
inputs,
};
}
export function stableHash(value: unknown) {
return createHash("sha256").update(stableStringify(value)).digest("hex");
}
function stableStringify(value: unknown): string {
return JSON.stringify(sortKeys(value), null, 2);
}
function sortKeys(value: unknown): unknown {
if (Array.isArray(value)) return value.map(sortKeys);
if (!value || typeof value !== "object") return value;
return Object.fromEntries(
Object.entries(value as Record<string, unknown>)
.sort(([a], [b]) => a.localeCompare(b))
.map(([key, item]) => [key, sortKeys(item)]),
);
}

View File

@@ -1,7 +1,20 @@
import { Hono } from "hono";
import { z } from "zod";
import { createClient, type Client } from "rivetkit/client";
import { requireUser, type AuthContext } from "../../auth/clerk.js";
import { curatorActor } from "./curator-actor.js";
import { config } from "../../config.js";
import type { Registry } from "../../actors/registry.js";
import { curatorService } from "./curator-actor.js";
import { buildCuratorSprint, buildCuratorSprintReport, buildCuratorTasks } from "./curator-store.js";
let _client: Client<Registry> | null = null;
function getClient(): Client<Registry> {
return (_client ??= createClient<Registry>(config.rivetClientEndpoint));
}
function getCuratorActor(userId: string) {
return getClient().curatorActor.getOrCreate(["user", userId]);
}
const chatSchema = z.object({
conversationId: z.string().optional(),
@@ -12,6 +25,13 @@ const chatSchema = z.object({
messages: z.array(z.object({ role: z.enum(["user", "assistant"]), content: z.string() })).min(1).max(50),
});
const curationPreviewSchema = z.object({
startDate: z.string().optional(),
icpId: z.enum(["student_recent_grad", "intern", "fresher_early_professional", "experienced_professional", "freelancer", "founder", "enterprise"]).optional(),
goals: z.array(z.string()).optional(),
userContext: z.record(z.unknown()).optional(),
});
export function v1CuratorRoutes() {
const app = new Hono<AuthContext>();
app.use("*", requireUser);
@@ -24,12 +44,12 @@ export function v1CuratorRoutes() {
goals: z.array(z.string()).optional(),
forceRegenerate: z.boolean().optional(),
}).parse(await c.req.json().catch(() => ({})));
return c.json(await curatorActor.generatePlanRange({ userId, ...body }));
return c.json(await getCuratorActor(userId).generatePlanRange({ userId, ...body }));
});
app.get("/plan", async (c) => {
const userId = c.get("userId");
return c.json(await curatorActor.getPlan({
return c.json(await getCuratorActor(userId).getPlan({
userId,
startDate: c.req.query("startDate"),
endDate: c.req.query("endDate"),
@@ -38,45 +58,92 @@ export function v1CuratorRoutes() {
app.get("/today", async (c) => {
const userId = c.get("userId");
return c.json(await curatorActor.getToday({ userId, date: c.req.query("date") }));
return c.json({ tasks: await buildCuratorTasks(userId, c.req.query("date")) });
});
app.get("/sprint", async (c) => {
const userId = c.get("userId");
return c.json(await curatorActor.getSprint({ userId, date: c.req.query("date") }));
const icpQuery = c.req.query("icpId");
const dayQuery = c.req.query("dayIndex");
const icpId = curationPreviewSchema.shape.icpId.safeParse(icpQuery).success ? icpQuery as NonNullable<z.infer<typeof curationPreviewSchema>["icpId"]> : undefined;
const dayIndex = dayQuery ? Number.parseInt(dayQuery, 10) : undefined;
return c.json(await buildCuratorSprint(userId, c.req.query("date"), {
icpId,
dayIndex: Number.isFinite(dayIndex) ? dayIndex : undefined,
}));
});
app.get("/sprint/report", async (c) => {
const userId = c.get("userId");
const icpQuery = c.req.query("icpId");
const dayQuery = c.req.query("dayIndex");
const icpId = curationPreviewSchema.shape.icpId.safeParse(icpQuery).success ? icpQuery as NonNullable<z.infer<typeof curationPreviewSchema>["icpId"]> : undefined;
const dayIndex = dayQuery ? Number.parseInt(dayQuery, 10) : undefined;
return c.json(await buildCuratorSprintReport(userId, c.req.query("date"), {
icpId,
dayIndex: Number.isFinite(dayIndex) ? dayIndex : undefined,
}));
});
app.post("/sprint/report/generate", async (c) => {
const userId = c.get("userId");
const body = z.object({
date: z.string().optional(),
icpId: curationPreviewSchema.shape.icpId.optional(),
dayIndex: z.number().int().min(1).max(30).optional(),
}).parse(await c.req.json().catch(() => ({})));
return c.json(await buildCuratorSprintReport(userId, body.date, {
icpId: body.icpId,
dayIndex: body.dayIndex,
save: true,
}));
});
app.post("/curation/preview", async (c) => {
const userId = c.get("userId");
const body = curationPreviewSchema.parse(await c.req.json().catch(() => ({})));
return c.json(await curatorService.previewCuration({ userId, ...body }));
});
app.post("/onboarding/run", async (c) => {
const userId = c.get("userId");
const body = z.object({
completedAt: z.string().optional(),
}).parse(await c.req.json().catch(() => ({})));
return c.json(await getCuratorActor(userId).runOnboardingLoop({ userId, ...body }));
});
app.post("/chat", async (c) => {
const userId = c.get("userId");
const body = chatSchema.parse(await c.req.json());
return c.json(await curatorActor.chat({ userId, ...body }));
return c.json(await getCuratorActor(userId).chat({ userId, ...body }));
});
app.post("/tasks/:taskId/start", async (c) => {
const userId = c.get("userId");
return c.json(await curatorActor.startTask({ userId, taskId: c.req.param("taskId"), date: c.req.query("date") }));
return c.json(await getCuratorActor(userId).startTask({ userId, taskId: c.req.param("taskId"), date: c.req.query("date") }));
});
app.post("/tasks/:taskId/handoff", async (c) => {
const userId = c.get("userId");
return c.json(await curatorActor.prepareTaskHandoff({ userId, taskId: c.req.param("taskId"), date: c.req.query("date") }));
return c.json(await getCuratorActor(userId).prepareTaskHandoff({ userId, taskId: c.req.param("taskId"), date: c.req.query("date") }));
});
app.post("/tasks/:taskId/complete", async (c) => {
const userId = c.get("userId");
const body = z.object({ reason: z.string().optional() }).parse(await c.req.json().catch(() => ({})));
return c.json(await curatorActor.completeTask({ userId, taskId: c.req.param("taskId"), date: c.req.query("date"), reason: body.reason }));
return c.json(await getCuratorActor(userId).completeTask({ userId, taskId: c.req.param("taskId"), date: c.req.query("date"), reason: body.reason }));
});
app.post("/events/service-impact", async (c) => {
const userId = c.get("userId");
const body = z.object({ eventId: z.string() }).parse(await c.req.json());
return c.json(await curatorActor.recordServiceImpact({ userId, eventId: body.eventId }));
return c.json(await getCuratorActor(userId).recordServiceImpact({ userId, eventId: body.eventId }));
});
app.get("/state", async (c) => {
const userId = c.get("userId");
return c.json(await curatorActor.getState({ userId }));
return c.json(await getCuratorActor(userId).getState({ userId }));
});
return app;

View File

@@ -25,6 +25,17 @@ export function serviceRoute(input: ServiceRouteInput) {
return buildCuratorServiceRoute(input);
}
export function buildCuratorTaskDeepLink(task: Pick<CuratorTask, "serviceId" | "missionId" | "missionInstanceId" | "stageId" | "id">, targetRole?: string) {
return buildCuratorServiceRoute({
serviceId: task.serviceId,
missionId: task.missionId,
missionInstanceId: task.missionInstanceId,
stageId: task.stageId,
taskId: task.id,
targetRole,
});
}
export function serviceName(serviceId?: CuratorServiceId, fallback = "Mission planner") {
return getServiceDisplayName(serviceId, fallback);
}

File diff suppressed because it is too large Load Diff

View File

@@ -4,6 +4,7 @@ import { eq, desc, and, inArray } from "drizzle-orm";
import { db } from "../../db/client.js";
import { growEvents, growQscoreLatest, growQscoreProjectionState } from "../../db/schema.js";
import { interviewService, resumeService, roleplayService } from "../../services/product-service-clients.js";
import { listServices } from "../../services/service-registry.js";
import { createMissionAction, listMissionActions } from "../../missions/actions.js";
import { listActiveMissionsPg, listMessagesPg } from "../../grow/persistence.js";
import { buildCuratorStreak, buildCuratorTasks, listCuratorRegistryCapabilities } from "./curator-store.js";
@@ -71,9 +72,9 @@ async function latestInterviewResumeEvidence(userId: string) {
.where(and(
eq(growEvents.userId, userId),
inArray(growEvents.type as any, [
"interview.review_completed",
"interview.feedback.generated",
"interview.completed",
"roleplay.review_completed",
"roleplay.feedback.generated",
"roleplay.completed",
]),
))
@@ -176,9 +177,16 @@ export async function prepareHandoffForTask(
await emitCuratorEvent({
userId,
type: "curator.service_handoff.opened",
type: "task.opened",
mission: { missionId: task.missionId, missionInstanceId: task.missionInstanceId, stageId: task.stageId },
payload: { taskId: task.id, serviceId, route, actionId },
payload: {
taskId: task.id,
curatorTaskId: task.id,
serviceId,
route,
actionId,
expectedCompletionEvents: task.completionEvents,
},
});
return {
@@ -269,9 +277,9 @@ export function buildCuratorTools(ctx: { userId: string; date: string; conversat
execute: async ({ taskId }) => {
const task = taskId ? await findTask(ctx.userId, taskId, ctx.date) : null;
return {
routes: ["interview-service", "resume-service", "roleplay-service", "qscore-service"].map((serviceId) => ({
serviceId,
route: serviceRoute({ serviceId: serviceId as any, missionId: task?.missionId, missionInstanceId: task?.missionInstanceId, stageId: task?.stageId, taskId: task?.id }),
routes: listServices().map((service) => ({
serviceId: service.id,
route: serviceRoute({ serviceId: service.id, missionId: task?.missionId, missionInstanceId: task?.missionInstanceId, stageId: task?.stageId, taskId: task?.id }),
})),
};
},
@@ -489,13 +497,9 @@ export function buildCuratorTools(ctx: { userId: string; date: string; conversat
}),
prepare_qscore_review: tool({
description: "Prepare a Q-score review handoff.",
description: "Disabled for curator task handoffs; QScore is read-only for dashboard scoring and should not be assigned as a curator task.",
inputSchema: z.object({ taskId: z.string().optional() }),
execute: async ({ taskId }) => {
const task = await findTask(ctx.userId, taskId ?? ctx.taskId ?? "", ctx.date);
if (!task) return { error: "task_not_found" };
return prepareHandoffForTask(ctx.userId, task, "qscore-service");
},
execute: async () => ({ error: "qscore_curator_handoff_disabled", replacementServices: ["interview-service", "roleplay-service", "resume-service", "matchmaking-service", "courses-service"] }),
}),
emit_curator_event: tool({

View File

@@ -3,15 +3,20 @@ import { z } from "zod";
export const curatorServiceIdSchema = z.enum([
"interview-service",
"resume-service",
"cover-letter-service",
"roleplay-service",
"courses-service",
"assessment-service",
"qscore-service",
"social-branding-service",
"matchmaking-service",
"expert-service",
"events-service",
]);
export type CuratorServiceId = z.infer<typeof curatorServiceIdSchema>;
export const curatorTaskTypeSchema = z.enum(["measurement", "proof", "practice"]);
export const curatorTaskTypeSchema = z.enum(["measurement", "proof", "practice", "recovery"]);
export type CuratorTaskType = z.infer<typeof curatorTaskTypeSchema>;
export const curatorTaskStatusSchema = z.enum([
@@ -20,6 +25,9 @@ export const curatorTaskStatusSchema = z.enum([
"handoff_prepared",
"completed",
"blocked",
"partial",
"skipped",
"abandoned",
]);
export const curatorWeekLifecycleSchema = z.enum(["done", "active", "upcoming"]);
@@ -30,7 +38,7 @@ export const curatorTaskSchema = z.object({
date: z.string(),
dayIndex: z.number().int().min(1).max(30),
dayIndexInWeek: z.number().int().min(1).max(7),
weekIndex: z.number().int().min(1).max(5),
weekIndex: z.number().int().min(1).max(6),
taskType: curatorTaskTypeSchema,
title: z.string(),
subtitle: z.string(),
@@ -65,11 +73,12 @@ export const curatorPlanDaySchema = z.object({
date: z.string(),
dayIndex: z.number().int().min(1).max(30),
dayIndexInWeek: z.number().int().min(1).max(7),
weekIndex: z.number().int().min(1).max(5),
weekIndex: z.number().int().min(1).max(6),
weekTheme: z.string(),
weekSummary: z.string(),
focus: z.string().optional(),
plannedServices: z.array(curatorServiceIdSchema).max(3).default([]),
outcome: z.string().optional(),
plannedServices: z.array(curatorServiceIdSchema).max(4).default([]),
generationStatus: z.enum(["seeded", "generated", "adapted"]).default("seeded"),
adaptationReason: z.string().optional(),
completedCount: z.number().int().min(0),
@@ -79,7 +88,7 @@ export const curatorPlanDaySchema = z.object({
});
export const curatorWeekSchema = z.object({
weekIndex: z.number().int().min(1).max(5),
weekIndex: z.number().int().min(1).max(6),
title: z.string(),
theme: z.string(),
summary: z.string(),
@@ -90,7 +99,7 @@ export const curatorWeekSchema = z.object({
completedTaskCount: z.number().int().min(0),
totalTaskCount: z.number().int().min(0),
completionPercent: z.number().min(0).max(100),
days: z.array(curatorPlanDaySchema).min(2).max(7),
days: z.array(curatorPlanDaySchema).min(1).max(7),
});
export const curatorPlanSchema = z.object({
@@ -101,7 +110,7 @@ export const curatorPlanSchema = z.object({
goals: z.array(z.string()),
generatedAt: z.string(),
durationDays: z.literal(30),
weeks: z.array(curatorWeekSchema).length(5),
weeks: z.array(curatorWeekSchema).min(5).max(6),
days: z.array(curatorPlanDaySchema).length(30),
streak: curatorStreakSchema,
source: z.literal("curator-v1"),
@@ -125,10 +134,10 @@ export const curatorSprintResponseSchema = z.object({
sprintId: z.string(),
plan: curatorPlanSchema,
activeWeek: curatorWeekSchema,
activeWeekIndex: z.number().int().min(1).max(5),
activeWeekIndex: z.number().int().min(1).max(6),
activeDay: curatorPlanDaySchema,
activeDayIndex: z.number().int().min(1).max(30),
todayTasks: z.array(curatorTaskSchema).length(3),
todayTasks: z.array(curatorTaskSchema).min(3).max(4),
streak: curatorStreakSchema,
completedCount: z.number().int().min(0),
totalCount: z.number().int().min(0),
@@ -136,6 +145,48 @@ export const curatorSprintResponseSchema = z.object({
source: z.literal("curator-v1"),
});
export const curatorKpiEvidenceSchema = z.object({
serviceId: curatorServiceIdSchema.optional(),
taskId: z.string().optional(),
eventId: z.string().optional(),
status: curatorTaskStatusSchema.optional(),
});
export const curatorKpiReportItemSchema = z.object({
id: z.string(),
category: z.string(),
label: z.string(),
met: z.boolean(),
evidence: z.array(curatorKpiEvidenceSchema),
});
export const curatorOutcomeReportItemSchema = z.object({
id: z.string(),
text: z.string(),
met: z.boolean(),
requiredKpiIds: z.array(z.string()),
});
export const curatorSprintReportSchema = z.object({
userId: z.string(),
icpId: z.string(),
icpLabel: z.string(),
sprintId: z.string(),
generatedAt: z.string(),
saved: z.boolean().default(false),
eligible: z.boolean(),
eligibilityReason: z.string(),
days: z.array(z.object({
dayIndex: z.number().int().min(1),
title: z.string(),
outcome: z.string(),
completedCount: z.number().int().min(0),
totalCount: z.number().int().min(0),
})),
kpis: z.array(curatorKpiReportItemSchema),
outcomes: z.array(curatorOutcomeReportItemSchema),
});
export type CuratorTask = z.infer<typeof curatorTaskSchema>;
export type CuratorPlanDay = z.infer<typeof curatorPlanDaySchema>;
export type CuratorWeek = z.infer<typeof curatorWeekSchema>;
@@ -143,6 +194,10 @@ export type CuratorPlan = z.infer<typeof curatorPlanSchema>;
export type CuratorStreak = z.infer<typeof curatorStreakSchema>;
export type CuratorImprovementSignal = z.infer<typeof curatorImprovementSignalSchema>;
export type CuratorSprintResponse = z.infer<typeof curatorSprintResponseSchema>;
export type CuratorKpiEvidence = z.infer<typeof curatorKpiEvidenceSchema>;
export type CuratorKpiReportItem = z.infer<typeof curatorKpiReportItemSchema>;
export type CuratorOutcomeReportItem = z.infer<typeof curatorOutcomeReportItemSchema>;
export type CuratorSprintReport = z.infer<typeof curatorSprintReportSchema>;
export type CuratorTodayResponse = {
date: string;

View File

@@ -1,6 +1,6 @@
import { and, desc, eq } from "drizzle-orm";
import { db } from "../../db/client.js";
import { growEvents } from "../../db/schema.js";
import { growEvents, growQscoreProjectionState } from "../../db/schema.js";
import { asRecord, getString } from "../../events/envelope.js";
import type { CuratorTask } from "./curator-types.js";
@@ -12,6 +12,29 @@ function stringArray(value: unknown): string[] {
: [];
}
export type CuratorUserContext = {
userId: string;
targetRole: string;
experienceLevel: "student" | "intern" | "early" | "experienced" | "unknown";
resume: {
available: boolean;
latestSummary?: string;
skills: string[];
};
goals: string[];
pastActivity: {
recentEventCount: number;
serviceSources: string[];
latestEvents: Array<{ type: string; source: string; occurredAt: string; summary?: string }>;
};
qscore: {
score: number | null;
signalCount: number;
summary: string | null;
dimensions: Record<string, unknown> | null;
};
};
function firstRoleFromValue(value: unknown): string | undefined {
const direct = getString(value);
if (direct) return direct;
@@ -130,3 +153,103 @@ export async function resolveCuratorTargetRole(input: {
export function fallbackCuratorRole(role?: string) {
return role?.trim() || "Product Manager";
}
export async function buildCuratorUserContext(userId: string): Promise<CuratorUserContext> {
const rows = await db
.select({ type: growEvents.type, source: growEvents.source, payload: growEvents.payload, occurredAt: growEvents.occurredAt })
.from(growEvents)
.where(eq(growEvents.userId, userId))
.orderBy(desc(growEvents.occurredAt))
.limit(80);
const [qscore] = await db
.select({
score: growQscoreProjectionState.score,
signalCount: growQscoreProjectionState.signalCount,
summary: growQscoreProjectionState.summary,
dimensions: growQscoreProjectionState.dimensions,
})
.from(growQscoreProjectionState)
.where(eq(growQscoreProjectionState.userId, userId))
.limit(1);
const targetRole = fallbackCuratorRole(await resolveCuratorTargetRole({ userId }));
const corpus = rows.map((row) => `${row.type} ${row.source} ${payloadText(row.payload)}`).join(" ").toLowerCase();
const goals = uniqueStrings(rows.flatMap((row) => goalsFromPayload(row.payload)));
const skills = uniqueStrings(rows.flatMap((row) => stringArray(asRecord(row.payload).skills))).slice(0, 12);
const latestResume = rows
.map((row) => resumeSummaryFromPayload(row.payload))
.find(Boolean);
return {
userId,
targetRole,
experienceLevel: inferExperienceLevel(corpus),
resume: {
available: Boolean(latestResume || /\bresume|cv|linkedin\b/i.test(corpus)),
latestSummary: latestResume,
skills,
},
goals: goals.length ? goals.slice(0, 8) : [targetRole],
pastActivity: {
recentEventCount: rows.length,
serviceSources: uniqueStrings(rows.map((row) => row.source)).slice(0, 12),
latestEvents: rows.slice(0, 12).map((row) => ({
type: row.type,
source: row.source,
occurredAt: row.occurredAt.toISOString(),
summary: eventSummary(row.payload),
})),
},
qscore: {
score: typeof qscore?.score === "number" ? qscore.score : null,
signalCount: typeof qscore?.signalCount === "number" ? qscore.signalCount : 0,
summary: qscore?.summary ?? null,
dimensions: qscore?.dimensions ?? null,
},
};
}
function goalsFromPayload(payload: Record<string, unknown>) {
const preferences = asRecord(payload.preferences);
return [
...stringArray(preferences.target_roles ?? preferences.targetRoles),
...stringArray(payload.goals),
getString(payload.goal),
getString(payload.userGoal),
getString(payload.target_role ?? payload.targetRole),
].filter((item): item is string => Boolean(item));
}
function resumeSummaryFromPayload(payload: Record<string, unknown>) {
return getString(
payload.resumeSummary ??
payload.summary ??
payload.resume_text ??
payload.resumeText ??
asRecord(payload.resume).summary,
)?.slice(0, 900);
}
function eventSummary(payload: Record<string, unknown>) {
return getString(payload.summary ?? payload.title ?? payload.goal ?? payload.serviceIntent)?.slice(0, 220);
}
function payloadText(value: unknown): string {
if (typeof value === "string") return value;
if (Array.isArray(value)) return value.map(payloadText).join(" ");
if (value && typeof value === "object") return Object.values(value as Record<string, unknown>).map(payloadText).join(" ");
return "";
}
function inferExperienceLevel(corpus: string): CuratorUserContext["experienceLevel"] {
if (/\b(intern|internship|return offer|return-offer)\b/.test(corpus)) return "intern";
if (/\b(student|recent grad|recent graduate|campus|college|university)\b/.test(corpus)) return "student";
if (/\b(staff|principal|director|vp|head of|leadership|executive|10\+ years|5\+ years)\b/.test(corpus)) return "experienced";
if (/\b(fresher|junior|entry level|entry-level|early career|0-2 years|1 year|2 years)\b/.test(corpus)) return "early";
return "unknown";
}
function uniqueStrings(values: Array<string | undefined>) {
return [...new Set(values.map((value) => value?.trim()).filter((value): value is string => Boolean(value)))];
}

View File

@@ -0,0 +1,145 @@
import type { CuratorServiceId, CuratorTaskType } from "./curator-types.js";
export type CuratorIcpId =
| "student_recent_grad"
| "intern"
| "fresher_early_professional"
| "experienced_professional"
| "freelancer"
| "founder"
| "enterprise";
export type CuratorPlaybookAction = {
taskType: CuratorTaskType;
serviceId: CuratorServiceId;
goal: string;
action: string;
deepLinkIntent: string;
expectedSignals: string[];
};
export type CuratorIcpPlaybook = {
id: CuratorIcpId;
label: string;
sprintTheme: string;
goal: string;
stageLabels: [string, string, string, string, string];
serviceActions: CuratorPlaybookAction[];
};
export const CURATOR_ICP_PLAYBOOKS: Record<CuratorIcpId, CuratorIcpPlaybook> = {
student_recent_grad: {
id: "student_recent_grad",
label: "Student / Recent Grad",
sprintTheme: "First Role Readiness Sprint",
goal: "Have a credible resume, practiced interviews, visible proof, and a clear target role by the end of the sprint.",
stageLabels: ["Baseline + First Proof", "Fix Obvious Gaps", "Build Proof Momentum", "Market-Ready Practice", "Closeout + Next Sprint"],
serviceActions: [
play("measurement", "qscore-service", "baseline", "Establish readiness baseline and weakest drivers.", "analytics", ["qscore baseline", "weakest driver"]),
play("proof", "resume-service", "first proof", "Import resume and convert projects into proof bullets.", "resume workspace", ["resume import", "project proof"]),
play("proof", "social-branding-service", "visible credibility", "Turn proof into public-safe profile and post artifacts.", "social profile flow", ["headline", "public proof"]),
play("practice", "interview-service", "interview confidence", "Run behavioral and project interview reps.", "interview preview", ["mock interview", "feedback"]),
play("practice", "matchmaking-service", "role direction", "Shortlist realistic first-role opportunities.", "pathways", ["target roles", "opportunity shortlist"]),
],
},
intern: {
id: "intern",
label: "Intern",
sprintTheme: "Intern-to-Offer Sprint",
goal: "Convert internship work into stronger impact proof, return-offer readiness, and external backup options.",
stageLabels: ["Baseline + First Proof", "Fix Obvious Gaps", "Build Proof Momentum", "Market-Ready Practice", "Closeout + Next Sprint"],
serviceActions: [
play("measurement", "qscore-service", "return-offer baseline", "Measure return-offer proof gaps and readiness.", "analytics", ["return offer", "readiness"]),
play("proof", "resume-service", "internship proof", "Document project decisions, metrics, and impact bullets.", "resume workspace", ["internship proof", "impact log"]),
play("proof", "social-branding-service", "manager visibility", "Prepare manager updates, feedback asks, and visibility notes.", "social profile flow", ["manager update", "feedback ask"]),
play("practice", "roleplay-service", "conversion conversations", "Practice mentor, manager, and return-offer asks.", "roleplay builder", ["conversion ask", "stakeholder conversation"]),
play("practice", "matchmaking-service", "backup options", "Maintain credible external backup opportunities.", "pathways", ["backup roles", "pipeline"]),
],
},
fresher_early_professional: {
id: "fresher_early_professional",
label: "Fresher / Early Professional",
sprintTheme: "Callback-to-Offer Sprint",
goal: "Improve callback conversion, sharpen proof, and build stronger interview confidence across the sprint.",
stageLabels: ["Baseline + First Proof", "Fix Obvious Gaps", "Build Proof Momentum", "Market-Ready Practice", "Closeout + Next Sprint"],
serviceActions: [
play("measurement", "qscore-service", "readiness baseline", "Anchor the sprint in current QScore and missing signals.", "analytics", ["qscore", "readiness"]),
play("proof", "resume-service", "role-fit proof", "Tailor resume proof to target roles and outcomes.", "resume workspace", ["resume proof", "role fit"]),
play("proof", "social-branding-service", "credibility signal", "Create visible credibility updates from real work.", "social profile flow", ["credibility", "visibility"]),
play("practice", "interview-service", "callback conversion", "Run focused interview reps for weak question types.", "interview preview", ["interview practice", "callback"]),
play("practice", "roleplay-service", "confidence conversations", "Practice recruiter intros, objections, and pitch clarity.", "roleplay builder", ["recruiter intro", "confidence"]),
],
},
experienced_professional: {
id: "experienced_professional",
label: "Experienced Professional",
sprintTheme: "Leadership Readiness Sprint",
goal: "Strengthen leadership proof, senior interview readiness, and authority positioning for the next move.",
stageLabels: ["Leadership Baseline + Strategic Proof", "Strategic Positioning + Authority", "Negotiation + Market Action", "Conversion + Closeout", "Momentum + Carry Forward"],
serviceActions: [
play("measurement", "qscore-service", "senior readiness baseline", "Identify leadership readiness and authority gaps.", "analytics", ["leadership baseline", "authority"]),
play("proof", "resume-service", "leadership proof", "Translate execution into scope, team, and business impact.", "resume workspace", ["leadership proof", "business impact"]),
play("proof", "social-branding-service", "authority positioning", "Turn strategic lessons into public-safe authority signals.", "social profile flow", ["authority post", "positioning"]),
play("practice", "interview-service", "senior interviews", "Practice stakeholder, strategy, and leadership interview reps.", "interview preview", ["senior interview", "strategy"]),
play("practice", "roleplay-service", "negotiation and pushback", "Practice compensation, scope, promotion, and objection conversations.", "roleplay builder", ["negotiation", "pushback"]),
],
},
freelancer: {
id: "freelancer",
label: "Freelancer / Gig User",
sprintTheme: "Freelancer Business Sprint",
goal: "Build a stronger freelancer profile, portfolio proof, client conversation readiness, and business learning momentum.",
stageLabels: ["Brand + Portfolio", "Package Value", "Trust + Cases", "Client Pipeline", "Scale Forward"],
serviceActions: [
play("proof", "resume-service", "portfolio proof", "Package portfolio, services, case studies, and proposal assets.", "resume workspace", ["portfolio", "proposal"]),
play("practice", "roleplay-service", "client conversations", "Practice discovery, pricing, objections, and negotiation.", "roleplay builder", ["client discovery", "negotiation"]),
play("practice", "interview-service", "client pitch", "Practice concise pitch and client-facing presentation.", "interview preview", ["client pitch", "presentation"]),
play("measurement", "courses-service", "business learning", "Complete practical learning for sales, pricing, and marketing.", "courses", ["business sprint", "learning"]),
],
},
founder: {
id: "founder",
label: "Founder / Solopreneur",
sprintTheme: "Founder Acceleration Sprint",
goal: "Build founder proof, pitch readiness, customer conversation practice, and focused learning momentum.",
stageLabels: ["Vision + Proof", "Validate Idea", "Credibility", "Find Market", "Launch Forward"],
serviceActions: [
play("proof", "resume-service", "founder proof", "Create founder story, venture overview, pitch assets, and product proof.", "resume workspace", ["founder story", "pitch assets"]),
play("practice", "interview-service", "founder pitch", "Practice investor, customer, and media-style pitch delivery.", "interview preview", ["founder pitch", "investor"]),
play("practice", "roleplay-service", "customer discovery", "Simulate customer, sales, partnership, and negotiation conversations.", "roleplay builder", ["customer discovery", "sales"]),
play("measurement", "courses-service", "founder learning", "Complete focused founder learning for validation, growth, or fundraising.", "courses", ["founder learning", "validation"]),
],
},
enterprise: {
id: "enterprise",
label: "Enterprise",
sprintTheme: "Enterprise Capability Sprint",
goal: "Activate workforce capability proof, leadership practice, hiring calibration, and learning pathways.",
stageLabels: ["Workforce Baseline", "Talent Measurement", "Capability Building", "Hiring Excellence", "Future Ready"],
serviceActions: [
play("proof", "resume-service", "capability documentation", "Generate competency frameworks, hiring profiles, and workforce documentation.", "resume workspace", ["competency", "hiring profiles"]),
play("practice", "interview-service", "interview calibration", "Standardize interview questions, rubrics, and hiring simulations.", "interview preview", ["interview calibration", "rubrics"]),
play("practice", "roleplay-service", "leadership simulation", "Practice leadership, coaching, conflict, and stakeholder conversations.", "roleplay builder", ["leadership", "coaching"]),
play("measurement", "courses-service", "learning pathways", "Assign or complete focused capability learning pathways.", "courses", ["learning pathways", "capability"]),
],
},
};
export function isCuratorIcpId(value: string): value is CuratorIcpId {
return value in CURATOR_ICP_PLAYBOOKS;
}
export function curatorPlaybookFor(id: CuratorIcpId) {
return CURATOR_ICP_PLAYBOOKS[id] ?? CURATOR_ICP_PLAYBOOKS.fresher_early_professional;
}
function play(
taskType: CuratorTaskType,
serviceId: CuratorServiceId,
goal: string,
action: string,
deepLinkIntent: string,
expectedSignals: string[],
): CuratorPlaybookAction {
return { taskType, serviceId, goal, action, deepLinkIntent, expectedSignals };
}

View File

@@ -0,0 +1,150 @@
import type { CuratorIcpId } from "./icp-registry.js";
import type { CuratorServiceId, CuratorTaskType } from "./curator-types.js";
export type CuratorKpiDefinition = {
id: string;
icpId: CuratorIcpId;
category: string;
label: string;
requiredServiceIds: CuratorServiceId[];
requiredTaskTypes?: CuratorTaskType[];
acceptableCompletionEvents: string[];
outcomeIds: string[];
};
type KpiSeed = {
key: string;
category: string;
label: string;
requiredServiceIds: CuratorServiceId[];
requiredTaskTypes?: CuratorTaskType[];
acceptableCompletionEvents?: string[];
};
const DEFAULT_COMPLETION_EVENTS = [
"curator.task.completed",
"service.completed",
"qscore.updated",
"qscore.signal.projected",
"resume.analysis.completed",
"resume.parsed",
"resume.updated",
"resume.exported",
"interview.session.configured",
"interview.session.completed",
"interview.feedback.generated",
"roleplay.scenario.configured",
"roleplay.scenario.completed",
"roleplay.feedback.generated",
"assessment.completed",
"course.completed",
"course.lesson.completed",
"matchmaking.matches.generated",
"matchmaking.feed.viewed",
"matchmaking.match.saved",
"matchmaking.application.completed",
"brand.post.exported",
"brand.profile.updated",
];
const COMMON_KPI_KEYS = {
branding: ["social-branding-service"] as CuratorServiceId[],
intelligence: ["qscore-service", "assessment-service"] as CuratorServiceId[],
assets: ["resume-service", "cover-letter-service"] as CuratorServiceId[],
interview: ["interview-service", "roleplay-service"] as CuratorServiceId[],
learning: ["courses-service"] as CuratorServiceId[],
networking: ["expert-service", "events-service"] as CuratorServiceId[],
opportunity: ["matchmaking-service"] as CuratorServiceId[],
strategy: ["resume-service"] as CuratorServiceId[],
};
function kpis(icpId: CuratorIcpId, seeds: KpiSeed[]): CuratorKpiDefinition[] {
return seeds.map((seed) => ({
id: `${icpId}:${seed.key}`,
icpId,
category: seed.category,
label: seed.label,
requiredServiceIds: seed.requiredServiceIds,
requiredTaskTypes: seed.requiredTaskTypes,
acceptableCompletionEvents: seed.acceptableCompletionEvents ?? DEFAULT_COMPLETION_EVENTS,
outcomeIds: [`${icpId}:${seed.key}`],
}));
}
export const CURATOR_KPI_REGISTRY: Record<CuratorIcpId, CuratorKpiDefinition[]> = {
student_recent_grad: kpis("student_recent_grad", [
{ key: "branding", category: "Professional Branding", label: "GrowQR profile and LinkedIn profile completed", requiredServiceIds: COMMON_KPI_KEYS.branding },
{ key: "intelligence", category: "Career Intelligence", label: "QX Score baseline established and Dashboard activated", requiredServiceIds: COMMON_KPI_KEYS.intelligence },
{ key: "assets", category: "Career Assets", label: "Resume, cover letter and academic portfolio completed", requiredServiceIds: COMMON_KPI_KEYS.assets },
{ key: "interview", category: "Interview Readiness", label: "Mock interview and communication practice completed", requiredServiceIds: COMMON_KPI_KEYS.interview },
{ key: "learning", category: "Learning", label: "One AI-recommended course completed", requiredServiceIds: COMMON_KPI_KEYS.learning },
{ key: "networking", category: "Networking", label: "One mentor session and one GrowQR event attended", requiredServiceIds: COMMON_KPI_KEYS.networking },
{ key: "opportunity", category: "Opportunity Pipeline", label: "AI-recommended internships or graduate roles reviewed and at least three opportunities saved or applied for", requiredServiceIds: COMMON_KPI_KEYS.opportunity },
{ key: "strategy", category: "Career Strategy", label: "AI-generated Career Growth Blueprint completed", requiredServiceIds: COMMON_KPI_KEYS.strategy, requiredTaskTypes: ["proof"] },
]),
intern: kpis("intern", [
{ key: "branding", category: "Professional Branding", label: "GrowQR profile and LinkedIn profile completed", requiredServiceIds: COMMON_KPI_KEYS.branding },
{ key: "intelligence", category: "Career Intelligence", label: "QX Score baseline established and Dashboard activated", requiredServiceIds: COMMON_KPI_KEYS.intelligence },
{ key: "assets", category: "Career Assets", label: "Resume, cover letter and project portfolio completed", requiredServiceIds: COMMON_KPI_KEYS.assets },
{ key: "interview", category: "Interview Readiness", label: "HR interview and workplace simulation completed", requiredServiceIds: COMMON_KPI_KEYS.interview },
{ key: "learning", category: "Learning", label: "One personalized learning pathway completed", requiredServiceIds: COMMON_KPI_KEYS.learning },
{ key: "networking", category: "Networking", label: "One mentor session and one GrowQR event attended", requiredServiceIds: COMMON_KPI_KEYS.networking },
{ key: "opportunity", category: "Opportunity Pipeline", label: "AI-matched roles reviewed and at least three applications submitted", requiredServiceIds: COMMON_KPI_KEYS.opportunity },
{ key: "strategy", category: "Career Strategy", label: "AI-generated Career Advancement Blueprint completed", requiredServiceIds: COMMON_KPI_KEYS.strategy, requiredTaskTypes: ["proof"] },
]),
fresher_early_professional: kpis("fresher_early_professional", [
{ key: "branding", category: "Professional Branding", label: "GrowQR profile and LinkedIn profile completed", requiredServiceIds: COMMON_KPI_KEYS.branding },
{ key: "intelligence", category: "Career Intelligence", label: "QX Score baseline established and Dashboard activated", requiredServiceIds: COMMON_KPI_KEYS.intelligence },
{ key: "assets", category: "Career Assets", label: "Resume, cover letter and project portfolio completed", requiredServiceIds: COMMON_KPI_KEYS.assets },
{ key: "interview", category: "Interview Readiness", label: "Mock interview and workplace simulation completed", requiredServiceIds: COMMON_KPI_KEYS.interview },
{ key: "learning", category: "Learning", label: "One recommended course completed", requiredServiceIds: COMMON_KPI_KEYS.learning },
{ key: "networking", category: "Networking", label: "One mentor session and one career event attended", requiredServiceIds: COMMON_KPI_KEYS.networking },
{ key: "opportunity", category: "Opportunity Pipeline", label: "AI-recommended jobs reviewed and at least three applications submitted", requiredServiceIds: COMMON_KPI_KEYS.opportunity },
{ key: "strategy", category: "Career Strategy", label: "AI-generated Career Growth Blueprint completed", requiredServiceIds: COMMON_KPI_KEYS.strategy, requiredTaskTypes: ["proof"] },
]),
experienced_professional: kpis("experienced_professional", [
{ key: "branding", category: "Executive Branding", label: "Executive profile, leadership bio and thought leadership completed", requiredServiceIds: COMMON_KPI_KEYS.branding },
{ key: "intelligence", category: "Leadership Intelligence", label: "Executive QX Score baseline and Dashboard activated", requiredServiceIds: COMMON_KPI_KEYS.intelligence },
{ key: "assets", category: "Career Assets", label: "Executive resume, leadership portfolio and achievement documentation completed", requiredServiceIds: COMMON_KPI_KEYS.assets },
{ key: "interview", category: "Leadership Readiness", label: "Executive interview and boardroom simulations completed", requiredServiceIds: COMMON_KPI_KEYS.interview },
{ key: "learning", category: "Learning", label: "Executive leadership course completed", requiredServiceIds: COMMON_KPI_KEYS.learning },
{ key: "networking", category: "Networking", label: "One executive mentor session and one leadership event attended", requiredServiceIds: COMMON_KPI_KEYS.networking },
{ key: "opportunity", category: "Opportunity Pipeline", label: "Senior leadership and advisory opportunities reviewed", requiredServiceIds: COMMON_KPI_KEYS.opportunity },
{ key: "strategy", category: "Growth Strategy", label: "AI-generated Executive Leadership Blueprint completed", requiredServiceIds: COMMON_KPI_KEYS.strategy, requiredTaskTypes: ["proof"] },
]),
freelancer: kpis("freelancer", [
{ key: "branding", category: "Personal Branding", label: "Freelancer profile and service page completed", requiredServiceIds: COMMON_KPI_KEYS.branding },
{ key: "intelligence", category: "Business Intelligence", label: "Freelancer QX Score baseline and Dashboard activated", requiredServiceIds: COMMON_KPI_KEYS.intelligence },
{ key: "assets", category: "Portfolio & Proof", label: "Portfolio, case study and proposal templates created", requiredServiceIds: COMMON_KPI_KEYS.assets },
{ key: "interview", category: "Client Readiness", label: "Client pitch and negotiation simulations completed", requiredServiceIds: COMMON_KPI_KEYS.interview },
{ key: "learning", category: "Learning", label: "One freelancer business course completed", requiredServiceIds: COMMON_KPI_KEYS.learning },
{ key: "networking", category: "Networking", label: "One mentor consultation and one networking event attended", requiredServiceIds: COMMON_KPI_KEYS.networking },
{ key: "opportunity", category: "Opportunity Pipeline", label: "AI-recommended client opportunities reviewed and shortlisted", requiredServiceIds: COMMON_KPI_KEYS.opportunity },
{ key: "strategy", category: "Growth Strategy", label: "AI-generated Client Growth Blueprint completed", requiredServiceIds: COMMON_KPI_KEYS.strategy, requiredTaskTypes: ["proof"] },
]),
founder: kpis("founder", [
{ key: "branding", category: "Founder Brand", label: "Founder profile and startup page completed", requiredServiceIds: COMMON_KPI_KEYS.branding },
{ key: "intelligence", category: "Venture Intelligence", label: "Founder QX Score baseline and Dashboard activated", requiredServiceIds: COMMON_KPI_KEYS.intelligence },
{ key: "assets", category: "Business Proof", label: "Founder story, pitch deck and MVP portfolio completed", requiredServiceIds: COMMON_KPI_KEYS.assets },
{ key: "validation", category: "Customer Validation", label: "Customer discovery conversations completed", requiredServiceIds: ["roleplay-service"] },
{ key: "interview", category: "Investor Readiness", label: "Investor pitch practiced and refined", requiredServiceIds: ["interview-service", "roleplay-service"] },
{ key: "learning", category: "Learning", label: "Founder capability course completed", requiredServiceIds: COMMON_KPI_KEYS.learning },
{ key: "networking", category: "Networking", label: "One mentor session and one founder event attended", requiredServiceIds: COMMON_KPI_KEYS.networking },
{ key: "opportunity", category: "Opportunity Pipeline", label: "Customer, investor and partnership opportunities identified", requiredServiceIds: COMMON_KPI_KEYS.opportunity },
{ key: "strategy", category: "Growth Strategy", label: "AI-generated Startup Growth Blueprint completed", requiredServiceIds: COMMON_KPI_KEYS.strategy, requiredTaskTypes: ["proof"] },
]),
enterprise: kpis("enterprise", [
{ key: "branding", category: "Employer Branding", label: "Employer profile updated and employer branding campaign launched", requiredServiceIds: COMMON_KPI_KEYS.branding },
{ key: "intelligence", category: "Workforce Intelligence", label: "Enterprise QX Score baseline and capability dashboard activated", requiredServiceIds: COMMON_KPI_KEYS.intelligence },
{ key: "capability", category: "Capability Development", label: "Competency framework, learning paths and succession plans created", requiredServiceIds: ["resume-service", "courses-service"] },
{ key: "hiring", category: "Hiring Excellence", label: "Standardized interview process and AI-assisted hiring toolkit deployed", requiredServiceIds: ["resume-service", "roleplay-service"] },
{ key: "leadership", category: "Leadership Development", label: "Leadership simulations and coaching sessions completed", requiredServiceIds: ["roleplay-service"] },
{ key: "learning", category: "Learning", label: "Enterprise learning journeys assigned to teams", requiredServiceIds: COMMON_KPI_KEYS.learning },
{ key: "expert", category: "Expert Engagement", label: "At least one expert consultation and one enterprise event attended", requiredServiceIds: COMMON_KPI_KEYS.networking },
{ key: "strategy", category: "Strategic Output", label: "Workforce Transformation Blueprint generated", requiredServiceIds: COMMON_KPI_KEYS.strategy, requiredTaskTypes: ["proof"] },
]),
};
export function kpiDefinitionsForIcp(icpId: CuratorIcpId) {
return CURATOR_KPI_REGISTRY[icpId] ?? CURATOR_KPI_REGISTRY.fresher_early_professional;
}

View File

@@ -0,0 +1,101 @@
import type { CuratorIcpId } from "./icp-registry.js";
export type CuratorOutcomeDefinition = {
id: string;
icpId: CuratorIcpId;
text: string;
requiredKpiIds: string[];
};
type OutcomeSeed = {
key: string;
text: string;
requiredKpiKeys?: string[];
};
function outcomes(icpId: CuratorIcpId, seeds: OutcomeSeed[]): CuratorOutcomeDefinition[] {
return seeds.map((seed) => ({
id: `${icpId}:${seed.key}`,
icpId,
text: seed.text,
requiredKpiIds: (seed.requiredKpiKeys ?? [seed.key]).map((key) => `${icpId}:${key}`),
}));
}
export const CURATOR_OUTCOME_REPORT_REGISTRY: Record<CuratorIcpId, CuratorOutcomeDefinition[]> = {
student_recent_grad: outcomes("student_recent_grad", [
{ key: "branding", text: "Professional GrowQR profile showcasing academic achievements and career aspirations." },
{ key: "intelligence", text: "Measurable QX Score with clear insights into career readiness." },
{ key: "assets", text: "ATS-ready resume, personalized cover letter and academic project portfolio." },
{ key: "interview", text: "Increased confidence through AI-powered interview practice and workplace simulations." },
{ key: "learning", text: "Personalized learning recommendations based on identified skill gaps." },
{ key: "networking", text: "Guidance from mentors and access to professional networking opportunities." },
{ key: "opportunity", text: "Curated list of internships, graduate programs and entry-level roles aligned with career goals." },
{ key: "strategy", text: "AI-generated Career Growth Blueprint outlining the next 30-90 days of development and application strategy." },
]),
intern: outcomes("intern", [
{ key: "branding", text: "Polished professional profile highlighting internship and early work experience." },
{ key: "intelligence", text: "Measurable QX Score with actionable career intelligence." },
{ key: "assets", text: "ATS-optimized resume, tailored cover letter and project portfolio." },
{ key: "interview", text: "Improved confidence through AI-powered interview coaching and workplace roleplays." },
{ key: "learning", text: "Personalized learning recommendations to close career-critical skill gaps." },
{ key: "networking", text: "Connections with mentors, recruiters and industry professionals." },
{ key: "opportunity", text: "Curated pipeline of relevant early-career opportunities." },
{ key: "strategy", text: "AI-generated Career Advancement Blueprint with clear next steps for securing interviews and accelerating professional growth." },
]),
fresher_early_professional: outcomes("fresher_early_professional", [
{ key: "branding", text: "Complete professional profile and stronger personal brand." },
{ key: "intelligence", text: "Measurable QX Score with clear employability insights." },
{ key: "assets", text: "ATS-ready resume, tailored cover letter and project portfolio." },
{ key: "interview", text: "Improved interview confidence through AI coaching and roleplay." },
{ key: "learning", text: "Personalized learning recommendations based on skill gaps." },
{ key: "networking", text: "Guidance from industry experts and professional mentors." },
{ key: "opportunity", text: "Active pipeline of job opportunities and recruiter connections." },
{ key: "strategy", text: "AI-generated Career Growth Blueprint with practical next steps for securing interviews and job offers." },
]),
experienced_professional: outcomes("experienced_professional", [
{ key: "branding", text: "Compelling executive profile and leadership brand." },
{ key: "intelligence", text: "Measurable Executive QX Score with leadership intelligence insights." },
{ key: "assets", text: "Polished executive resume, leadership portfolio and business impact documentation." },
{ key: "interview", text: "Increased confidence in executive interviews, negotiations and boardroom communication." },
{ key: "learning", text: "Personalized leadership learning recommendations." },
{ key: "networking", text: "Strategic relationships with mentors, executive communities and industry experts." },
{ key: "opportunity", text: "Curated pipeline of senior leadership, consulting and board opportunities." },
{ key: "strategy", text: "AI-generated Executive Leadership Growth Blueprint outlining the next phase of career advancement." },
]),
freelancer: outcomes("freelancer", [
{ key: "branding", text: "Compelling freelancer profile and professional brand." },
{ key: "intelligence", text: "Measurable Freelancer QX Score with business readiness insights." },
{ key: "assets", text: "Polished portfolio, proposal templates and client case studies." },
{ key: "interview", text: "Improved confidence in client discovery, pitching and negotiations." },
{ key: "learning", text: "Personalized learning recommendations for growing a freelance business." },
{ key: "networking", text: "Stronger networking through mentors and GrowQR community events." },
{ key: "opportunity", text: "Active pipeline of freelance projects and collaboration opportunities." },
{ key: "strategy", text: "AI-generated Business Growth Blueprint with actionable recommendations for attracting better clients and increasing recurring revenue." },
]),
founder: outcomes("founder", [
{ key: "branding", text: "Compelling founder profile and personal brand." },
{ key: "intelligence", text: "Measurable Founder QX Score and startup readiness dashboard." },
{ key: "assets", text: "Polished pitch deck, founder story and business portfolio." },
{ key: "validation", text: "Customer discovery insights and validated value proposition." },
{ key: "interview", text: "AI-coached investor and customer pitch confidence." },
{ key: "learning", text: "Personalized learning recommendations for business growth." },
{ key: "networking", text: "Connections with mentors, founders and startup communities." },
{ key: "opportunity", text: "Structured opportunity pipeline for customers, investors and partners." },
{ key: "strategy", text: "AI-generated Startup Growth Blueprint outlining the next 90 days of execution." },
]),
enterprise: outcomes("enterprise", [
{ key: "intelligence", text: "Measurable Enterprise QX Capability Score." },
{ key: "intelligence-dashboard", text: "Live Workforce Intelligence Dashboard.", requiredKpiKeys: ["intelligence"] },
{ key: "capability", text: "AI-generated competency frameworks and hiring playbooks.", requiredKpiKeys: ["capability", "hiring"] },
{ key: "leadership", text: "Structured leadership and interview standards.", requiredKpiKeys: ["leadership", "hiring"] },
{ key: "learning", text: "Personalized learning pathways for employees." },
{ key: "branding", text: "Enhanced employer branding and visibility." },
{ key: "expert", text: "Access to expert consultations and enterprise events." },
{ key: "strategy", text: "Complete AI-powered Workforce Transformation Blueprint for future-ready talent development." },
]),
};
export function outcomeDefinitionsForIcp(icpId: CuratorIcpId) {
return CURATOR_OUTCOME_REPORT_REGISTRY[icpId] ?? CURATOR_OUTCOME_REPORT_REGISTRY.fresher_early_professional;
}

View File

@@ -0,0 +1,257 @@
import type { CuratorIcpId } from "./icp-registry.js";
import type { CuratorServiceId, CuratorTaskType } from "./curator-types.js";
export type TaskSeed = {
taskType: CuratorTaskType;
serviceId: CuratorServiceId;
title: string;
subtitle: string;
effort: string;
qxImpact: string;
cta: string;
signals: string[];
};
export type WeekTemplate = {
theme: string;
summary: string;
days: Array<{
focus?: string;
outcome?: string;
measurement: TaskSeed;
proof: TaskSeed;
practice: TaskSeed;
roleplay?: TaskSeed;
}>;
};
export type CuratorIcpTemplateSet = {
id: CuratorIcpId;
label: string;
sprintTheme: string;
goal: string;
weeks: WeekTemplate[];
};
function unavailableCta(serviceId: CuratorServiceId, fallback: string) {
if (
serviceId === "social-branding-service" ||
serviceId === "qscore-service" ||
serviceId === "assessment-service" ||
serviceId === "expert-service" ||
serviceId === "events-service"
) {
return "View service status";
}
return fallback;
}
export const CURATOR_TASK_REGISTRY: Record<CuratorIcpId, CuratorIcpTemplateSet> = {
student_recent_grad: staticIcpTemplate("student_recent_grad", "Student / Recent Grad", "7-Day Student CareerSprint", "Build a professional identity, career assets, interview confidence, and learning momentum.", [
day("Start Your Story", "Career profile established with a measurable readiness baseline.", "Claim Your Corner", "Complete your GrowQR profile with education, interests, career goals and profile photo.", "social-branding-service", "Know Your Number", "Review your QX Score, Career Dashboard and complete your Career Readiness Assessment.", "qscore-service", "Resume Rescue", "Create your first AI-powered resume using your academic achievements.", "resume-service", "Pitch Perfect", "Record a 60-second self-introduction for recruiters and mentors.", "interview-service"),
day("Discover Your Strengths", "Stronger professional identity and clearer career direction.", "Headline Hero", "Write a compelling professional headline and About section.", "social-branding-service", "Skill Gap Radar", "Review assessment insights and identify your top three improvement areas.", "assessment-service", "Project Spotlight", "Add one academic project, internship or extracurricular achievement.", "resume-service", "Manager Mode", "Practice introducing yourself in a professional workplace conversation.", "roleplay-service"),
day("Build Credibility", "Career proof strengthened with interview feedback received.", "Proof Beats Promises", "Upload certificates, awards or extracurricular achievements.", "social-branding-service", "Career Snapshot", "Review ATS readiness, profile completion and Dashboard insights.", "qscore-service", "Portfolio Power", "Create a simple academic portfolio or project showcase.", "resume-service", "Interview Replay", "Complete a mock interview and review AI-generated feedback.", "interview-service"),
day("Explore Opportunities", "Opportunity pipeline activated and application assets prepared.", "Go Visible", "Update your LinkedIn and GrowQR professional profile.", "social-branding-service", "Opportunity Radar", "Review AI-matched internships, campus placements and research opportunities.", "matchmaking-service", "Cover Letter Boost", "Generate a personalized cover letter for a selected opportunity.", "resume-service", "Group Discussion Lab", "Practice communication through an AI group discussion simulation.", "roleplay-service"),
day("Learn To Grow", "Skills improved with measurable learning progress.", "Show Your Progress", "Publish your latest certification or learning achievement.", "social-branding-service", "Growth Snapshot", "Review Dashboard, updated QX Score and weekly readiness improvements.", "qscore-service", "Learning Sprint", "Complete one AI-recommended course to close a skill gap.", "courses-service", "Communication Lab", "Practice workplace communication and presentation skills.", "roleplay-service"),
day("Grow Your Network", "Professional network expanded with expert guidance.", "Network Smart", "Connect with alumni, mentors, faculty members and professionals.", "social-branding-service", "Career Intelligence", "Review networking suggestions and recruiter recommendations.", "matchmaking-service", "Expert Connect", "Book a mentor session for resume review or career guidance.", "expert-service", "Career Connect Live", "Attend a GrowQR workshop, employer webinar or networking event.", "events-service"),
day("Ready For Tomorrow", "Career-ready profile completed with a personalized action plan for internships and first-job opportunities.", "Ready To Shine", "Share your weekly achievements and future career aspirations.", "social-branding-service", "Career Scorecard", "Review Dashboard, updated QX Score and weekly progress report.", "qscore-service", "Career Blueprint", "Generate your AI-powered Career Growth Blueprint and apply for your best-matched opportunity.", "resume-service", "Mock Finale", "Complete a full AI interview simulation for your dream internship or first job.", "interview-service"),
]),
intern: staticIcpTemplate("intern", "Intern / Early Career", "7-Day Early CareerSprint", "Convert internship work into career proof, stronger interviews, workplace confidence, and learning momentum.", [
day("Own Your Journey", "Professional profile established with career readiness baseline.", "Claim Your Corner", "Complete your GrowQR profile with internship experience, skills and career goals.", "social-branding-service", "Know Your Number", "Review your QX Score, Career Dashboard and complete your Career Readiness Assessment.", "qscore-service", "Resume Reloaded", "Build an AI-optimized resume tailored to your target role.", "resume-service", "Pitch Perfect", "Record a 60-second professional introduction highlighting your internship experience.", "interview-service"),
day("Show Your Growth", "Internship experience converted into impactful career proof.", "Headline Hero", "Write a compelling professional headline and update your About section.", "social-branding-service", "Career Snapshot", "Review recruiter visibility, ATS readiness and internship-to-job readiness.", "qscore-service", "Achievement Spotlight", "Transform internship tasks into measurable achievements.", "resume-service", "Recruiter Connect", "Practice an HR screening interview.", "roleplay-service"),
day("Build Credibility", "Professional credibility enhanced through measurable work examples.", "Proof Beats Promises", "Publish one internship project, certification or professional achievement.", "social-branding-service", "Skill Gap Radar", "Review competency assessment and identify improvement areas.", "assessment-service", "Project Showcase", "Document a project demonstrating business impact.", "resume-service", "Manager Mode", "Practice workplace communication with a reporting manager.", "roleplay-service"),
day("Find Better Opportunities", "Career documents optimized and opportunity pipeline activated.", "Go Visible", "Strengthen your LinkedIn and GrowQR professional profile.", "social-branding-service", "Opportunity Radar", "Review AI-recommended early-career roles and opportunities.", "matchmaking-service", "Application Pack", "Generate a customized resume and cover letter for a selected role.", "resume-service", "Workplace Conversations", "Practice collaboration and workplace scenarios.", "roleplay-service"),
day("Strengthen Your Skills", "Skill gaps addressed and interview confidence improved.", "Build Authority", "Share a certification, learning milestone or project update.", "social-branding-service", "Growth Snapshot", "Review Dashboard insights, QX Score movement and interview readiness.", "qscore-service", "Learning Sprint", "Complete an AI-recommended course aligned with your career goals.", "courses-service", "Interview Replay", "Complete a mock interview and implement AI feedback.", "interview-service"),
day("Expand Your Network", "Professional network expanded with expert guidance and recruiter exposure.", "Network Smart", "Connect with recruiters, mentors, managers and industry professionals.", "social-branding-service", "Career Intelligence", "Review networking opportunities and recruiter engagement.", "matchmaking-service", "Expert Connect", "Book an expert session for career guidance or resume review.", "expert-service", "Career Connect Live", "Attend a GrowQR networking session, employer webinar or career event.", "events-service"),
day("Step Into Opportunity", "Career-ready profile completed with active applications and a personalized growth roadmap.", "Ready To Rise", "Publish your weekly achievements and career aspirations.", "social-branding-service", "Career Scorecard", "Review Dashboard, updated QX Score and weekly employability report.", "qscore-service", "Career Blueprint", "Generate your AI-powered Career Advancement Blueprint and submit applications to your top-matched roles.", "resume-service", "Mock Finale", "Complete a full interview simulation for your preferred job role.", "interview-service"),
]),
fresher_early_professional: staticIcpTemplate("fresher_early_professional", "Fresher / Active Job Seeker", "7-Day Job Seeker CareerSprint", "Improve employability, recruiter visibility, interview readiness, and application quality.", [
day("Start Strong", "Professional profile completed with a measurable employability baseline.", "Claim Your Corner", "Complete your GrowQR profile with education, skills, career goals and professional photo.", "social-branding-service", "Know Your Number", "Review your QX Score, Career Dashboard and complete your Career Readiness Assessment.", "qscore-service", "Resume Rescue", "Build your first AI-optimized resume for your preferred job role.", "resume-service", "Pitch Perfect", "Record a 60-second self-introduction for recruiters.", "interview-service"),
day("Build Visibility", "Resume optimized for recruiter searches and stronger first impressions.", "Headline Hero", "Write an attention-grabbing professional headline and About section.", "social-branding-service", "Career Snapshot", "Review profile completion, ATS readiness and recruiter visibility.", "qscore-service", "Keyword Lift", "Optimize your resume with AI-recommended keywords.", "resume-service", "Recruiter Connect", "Practice a recruiter screening conversation.", "roleplay-service"),
day("Show Your Potential", "Stronger proof of capability and improved interview awareness.", "Proof Beats Promises", "Publish an academic project, internship or achievement.", "social-branding-service", "Skill Gap Radar", "Review your capability assessment and identify priority skills.", "assessment-service", "Project Spotlight", "Add one detailed project with measurable outcomes.", "resume-service", "Interview Replay", "Complete a behavioural mock interview and review AI feedback.", "interview-service"),
day("Find Better Opportunities", "Career documents prepared and opportunity pipeline activated.", "Go Visible", "Update your LinkedIn and GrowQR professional presence.", "social-branding-service", "Opportunity Radar", "Review AI-matched jobs, internships and career opportunities.", "matchmaking-service", "Cover Letter Boost", "Generate a personalized cover letter for a target role.", "resume-service", "Manager Mode", "Practice workplace communication with an AI manager simulation.", "roleplay-service"),
day("Grow Your Skills", "Skill gaps reduced and communication confidence improved.", "Build Authority", "Share one learning milestone or certification.", "social-branding-service", "Growth Snapshot", "Review Dashboard insights, QX Score movement and profile strength.", "qscore-service", "Learning Sprint", "Complete one AI-recommended skill course.", "courses-service", "Communication Lab", "Practice workplace communication and confidence exercises.", "roleplay-service"),
day("Expand Your Network", "Professional network expanded with expert guidance received.", "Network Smart", "Connect with recruiters, alumni and professionals in your field.", "social-branding-service", "Career Intelligence", "Review networking opportunities and recruiter recommendations.", "matchmaking-service", "Expert Connect", "Book a mentor session for resume or career guidance.", "expert-service", "Career Connect Live", "Attend a GrowQR hiring webinar or networking event.", "events-service"),
day("Launch Your Career", "Job-ready profile completed with applications submitted and clear next steps.", "Ready To Shine", "Publish your career goals and professional achievements.", "social-branding-service", "Career Scorecard", "Review Dashboard, updated QX Score and weekly progress report.", "qscore-service", "Application Ready", "Generate your AI-powered Career Action Blueprint and apply to your best-matched role.", "resume-service", "Mock Finale", "Complete a full AI interview simulation for your target role.", "interview-service"),
]),
experienced_professional: staticIcpTemplate("experienced_professional", "Experienced Professional", "7-Day Leadership CareerSprint", "Strengthen leadership proof, senior interview readiness, conversation confidence, and learning momentum.", [
day("Lead With Purpose", "Executive profile established with leadership readiness baseline.", "Own Your Executive Brand", "Complete your GrowQR executive profile, leadership bio and career vision.", "social-branding-service", "Know Your Number", "Review Executive QX Score, Dashboard and Leadership Assessment.", "qscore-service", "Executive Resume", "Create a leadership-focused resume highlighting measurable business impact.", "resume-service", "Executive Introduction", "Record a 90-second executive leadership introduction.", "interview-service"),
day("Build Executive Presence", "Executive positioning strengthened with measurable business impact.", "Thought Leader Mode", "Publish your leadership philosophy or industry insight.", "social-branding-service", "Leadership Pulse", "Review leadership strengths, competency gaps and opportunity insights.", "matchmaking-service", "Impact Portfolio", "Document major achievements, transformation projects and business outcomes.", "resume-service", "Boardroom Briefing", "Practice executive stakeholder communication.", "roleplay-service"),
day("Show Strategic Value", "Leadership capability validated with executive proof assets.", "Influence In Action", "Share a case study, article or strategic business insight.", "social-branding-service", "Capability Scan", "Review leadership capability assessment and organizational fit.", "assessment-service", "Promotion Portfolio", "Prepare evidence for promotions, executive interviews or board appointments.", "resume-service", "Executive Interview", "Complete a senior leadership mock interview.", "interview-service"),
day("Expand Your Influence", "Executive opportunities identified and leadership brand elevated.", "Network With Purpose", "Strengthen your executive network and update your professional presence.", "social-branding-service", "Opportunity Scan", "Review senior leadership roles, advisory positions and board opportunities.", "matchmaking-service", "Executive Brand Kit", "Optimize executive profile, LinkedIn and professional portfolio.", "resume-service", "Negotiation Room", "Practice salary, board and leadership negotiations.", "roleplay-service"),
day("Future-Proof Leadership", "Leadership capabilities strengthened through targeted executive learning.", "Executive Visibility", "Publish a leadership perspective or innovation story.", "social-branding-service", "Growth Snapshot", "Review Dashboard, QX trends and executive readiness analytics.", "qscore-service", "Leadership Learning", "Complete an executive leadership or AI strategy course.", "courses-service", "Crisis Leadership", "Practice managing high-pressure executive scenarios.", "roleplay-service"),
day("Build Strategic Relationships", "Expanded strategic network and leadership guidance established.", "Executive Connector", "Engage with senior professionals, mentors and industry communities.", "social-branding-service", "Market Intelligence", "Review strategic collaborations, board roles and executive opportunities.", "matchmaking-service", "Expert Connect", "Book a mentoring session with an industry leader or executive coach.", "expert-service", "Leadership Forum", "Attend an executive webinar or GrowQR leadership event.", "events-service"),
day("Lead The Future", "Executive CareerSprint completed with a measurable leadership roadmap and next-level career strategy.", "Executive Spotlight", "Publish your leadership roadmap and professional milestones.", "social-branding-service", "Leadership Scorecard", "Review Dashboard, Executive QX Score and weekly progress analytics.", "qscore-service", "Career Blueprint", "Generate an AI-powered Executive Growth & Leadership Blueprint.", "resume-service", "Boardroom Simulation", "Deliver a complete executive presentation and strategic decision simulation.", "roleplay-service"),
]),
freelancer: staticIcpTemplate("freelancer", "Freelancer / Gig User", "7-Day Freelancer CareerSprint", "Build trust, package freelance proof, practice client conversations, and improve business skills.", [
day("Build Your Brand", "Professional freelance identity established with measurable business baseline.", "Claim Your Corner", "Complete your freelancer profile, niche and service offerings.", "social-branding-service", "Know Your Number", "Review Freelancer QX Score, Dashboard and Business Readiness Assessment.", "qscore-service", "Portfolio Rescue", "Upload your best work, portfolio and service profile.", "resume-service", "Client Pitch", "Record a 60-second introduction for prospective clients.", "interview-service"),
day("Package Your Value", "Clear service positioning and improved client readiness.", "Show Your Expertise", "Publish your services and professional strengths.", "social-branding-service", "Opportunity Radar", "Review recommended freelance projects and client opportunities.", "matchmaking-service", "Service Showcase", "Create attractive service packages and pricing overview.", "resume-service", "Discovery Call", "Practice your first client consultation.", "roleplay-service"),
day("Earn Trust", "Stronger credibility through real business proof.", "Proof Beats Promises", "Publish testimonials or client success stories.", "social-branding-service", "Trust Meter", "Track profile strength and credibility indicators.", "qscore-service", "Case Study Builder", "Document one successful project with measurable outcomes.", "resume-service", "Client Objection Lab", "Practice handling pricing and client objections.", "roleplay-service"),
day("Find More Clients", "Qualified client pipeline established.", "Go Visible", "Share a professional insight or portfolio update.", "social-branding-service", "Lead Finder", "Review AI-matched projects and client opportunities.", "matchmaking-service", "Proposal Builder", "Generate a professional proposal template.", "resume-service", "Sales Conversation", "Simulate a client proposal presentation.", "roleplay-service"),
day("Grow Your Business", "Business skills strengthened and pricing confidence improved.", "Build Authority", "Publish expertise through LinkedIn or GrowQR community.", "social-branding-service", "Business Snapshot", "Review Dashboard, earnings potential and pipeline analytics.", "qscore-service", "Learning Sprint", "Complete a course on sales, marketing or pricing.", "courses-service", "Negotiation Lab", "Practice pricing and contract negotiation.", "roleplay-service"),
day("Expand Your Network", "Expanded professional network and collaboration opportunities.", "Community Builder", "Connect with clients, creators and professionals.", "social-branding-service", "Opportunity Pulse", "Review collaborations, referrals and strategic matches.", "matchmaking-service", "Expert Connect", "Book a mentor or business expert consultation.", "expert-service", "Freelancer Meetup", "Attend a GrowQR networking event or webinar.", "events-service"),
day("Scale Forward", "Business growth roadmap completed with stronger client acquisition strategy.", "Trusted Professional", "Publish your weekly growth achievements and client success.", "social-branding-service", "Growth Scorecard", "Review Dashboard, Freelancer QX Score and weekly business insights.", "qscore-service", "Growth Blueprint", "Generate your AI-powered Client Growth & Business Roadmap.", "resume-service", "Premium Client Meeting", "Deliver a complete client pitch simulation.", "interview-service"),
]),
founder: staticIcpTemplate("founder", "Founder / Solopreneur", "7-Day Founder Acceleration Sprint", "Validate venture proof, strengthen founder credibility, and practice customer or investor conversations.", [
day("Own Your Vision", "Founder identity established with a measurable business baseline.", "Build In Public", "Complete your Founder Profile, business description and company branding.", "social-branding-service", "Know Your Number", "Review Founder QX Score, Dashboard and Startup Readiness Assessment.", "qscore-service", "Founder Story", "Create your founder profile, mission and venture overview.", "resume-service", "Founder Pitch", "Record a 90-second startup introduction.", "interview-service"),
day("Validate Your Idea", "Product positioning clarified through customer validation.", "Mission Matters", "Publish your vision and value proposition.", "social-branding-service", "Market Radar", "Review customer opportunities and industry trends.", "matchmaking-service", "ProblemSolution Canvas", "Build your customer problem statement and solution.", "resume-service", "Customer Discovery", "Practice customer discovery conversations.", "roleplay-service"),
day("Build Credibility", "Investor-ready business story developed.", "Show Your Progress", "Share an update about your product or startup.", "social-branding-service", "Traction Pulse", "Track business readiness and founder score.", "qscore-service", "Pitch Deck Builder", "Create an investor-ready pitch deck.", "resume-service", "Investor Warm-Up", "Practice investor introduction and elevator pitch.", "interview-service"),
day("Find Your Market", "Business proof strengthened with qualified opportunities identified.", "Become Discoverable", "Optimize founder profile and startup page.", "social-branding-service", "Opportunity Scan", "Explore customers, investors, grants and partnerships.", "matchmaking-service", "Business Portfolio", "Upload MVP screenshots, website or product demo.", "resume-service", "Sales Conversation", "Roleplay first customer sales meeting.", "roleplay-service"),
day("Grow Smarter", "Founder capability expanded through learning and negotiation practice.", "Lead With Value", "Publish an industry insight or founder journey.", "social-branding-service", "Growth Snapshot", "Review Dashboard, customer pipeline and QX progress.", "qscore-service", "Learning Sprint", "Complete one founder-focused course.", "courses-service", "Negotiation Lab", "Practice pricing, partnership or investment negotiation.", "roleplay-service"),
day("Build Your Network", "Strategic relationships established with founders, mentors and investors.", "Community Builder", "Engage with founders and startup communities.", "social-branding-service", "Network Intelligence", "Review strategic matches and collaboration opportunities.", "matchmaking-service", "Expert Connect", "Book a mentor session and refine your strategy.", "expert-service", "Founder Roundtable", "Attend a GrowQR startup event or networking session.", "events-service"),
day("Launch Forward", "Founder completes a 7-day launch roadmap with measurable business readiness and a clear action plan.", "Founder Spotlight", "Publish your startup milestone and growth roadmap.", "social-branding-service", "Growth Scorecard", "Review Dashboard, Founder QX Score and capability improvements.", "qscore-service", "Startup Blueprint", "Generate your AI-powered Growth & Funding Blueprint.", "resume-service", "Vision Presentation", "Deliver your final investor/customer presentation.", "interview-service"),
]),
enterprise: staticIcpTemplate("enterprise", "Enterprise", "7-Day Enterprise Capability Sprint", "Activate workforce capability proof, structured hiring practice, leadership simulations, and learning pathways.", [
day("Know Your Workforce", "Enterprise capability baseline established with organizational priorities identified.", "Shape Your Story", "Publish or refresh your Employer Value Proposition and company profile.", "social-branding-service", "Know Your Workforce", "Review Enterprise QX Score, Dashboard and workforce capability baseline.", "qscore-service", "Capability Blueprint", "Upload competency framework, hiring roles and workforce structure.", "resume-service", "Leadership Kickoff", "Complete an executive leadership simulation.", "roleplay-service"),
day("Measure Your Talent", "Skill gaps identified and hiring standards aligned.", "Culture Showcase", "Highlight employee culture, achievements and workplace initiatives with LinkedIn Post.", "social-branding-service", "Skill Gap Radar", "Run organization-wide capability assessment.", "assessment-service", "Talent Profiles", "Create AI-ready hiring profiles and role descriptions.", "resume-service", "Interview Calibration", "Standardize interview questions with AI Interview Coach.", "roleplay-service"),
day("Strengthen Capability", "Learning pathways activated and leadership coaching initiated.", "Lead With Purpose", "Publish a leadership or employer branding update.", "social-branding-service", "Capability Heatmap", "Review department-wise readiness dashboard.", "qscore-service", "Learning Pathways", "Assign personalized learning journeys to employees.", "courses-service", "Manager Coaching", "Practice performance review conversations.", "roleplay-service"),
day("Build Better Hiring", "Hiring quality improved with standardized recruitment practices.", "Employer Spotlight", "Showcase hiring philosophy and employee success stories.", "social-branding-service", "Talent Match Scan", "Review AI talent recommendations and internal mobility insights.", "matchmaking-service", "Hiring Playbook", "Generate structured hiring toolkit and competency matrix.", "resume-service", "Hiring Lab", "Conduct structured interview simulations.", "roleplay-service"),
day("Grow Leaders", "Leadership pipeline strengthened with succession planning.", "People First", "Share a postemployee development initiatives.", "social-branding-service", "Leadership Pulse", "Review leadership readiness dashboard and QX trends.", "qscore-service", "Leadership Portfolio", "Document succession plans and leadership competencies.", "resume-service", "Executive Roleplay", "Simulate stakeholder, boardroom and conflict scenarios.", "roleplay-service"),
day("Activate Growth", "Organizational capability accelerated through expert intervention.", "Innovation Culture", "Promote innovation, awards and organizational achievements on social media.", "social-branding-service", "Growth Snapshot", "Track workforce learning completion and hiring analytics.", "qscore-service", "Expert Connect", "Book an expert consultation or capability workshop.", "expert-service", "Capability Workshop", "Attend GrowQR expert-led enterprise learning session.", "events-service"),
day("Future Ready Enterprise", "Enterprise receives a complete workforce capability roadmap with measurable actions for hiring, learning, leadership and transformation.", "Future Forward", "Launch employer branding campaign across digital channels.", "social-branding-service", "Capability Scorecard", "Review Enterprise Dashboard, QX Score improvements and analytics.", "qscore-service", "Transformation Blueprint", "Generate AI-powered workforce transformation report.", "resume-service", "Leadership Summit", "Complete executive simulation and strategic planning workshop.", "events-service"),
]),
};
export function seed(
taskType: CuratorTaskType,
serviceId: CuratorServiceId,
title: string,
subtitle: string,
effort: string,
qxImpact: string,
cta: string,
signals: string[],
): TaskSeed {
return { taskType, serviceId, title, subtitle, effort, qxImpact, cta: unavailableCta(serviceId, cta), signals };
}
type StaticSprintDay = {
focus: string;
outcome: string;
socialTitle: string;
socialTask: string;
socialServiceId: CuratorServiceId;
measurementTitle: string;
measurementTask: string;
measurementServiceId: CuratorServiceId;
proofTitle: string;
proofTask: string;
proofServiceId: CuratorServiceId;
practiceTitle: string;
practiceTask: string;
practiceServiceId: CuratorServiceId;
};
function day(
focus: string,
outcome: string,
socialTitle: string,
socialTask: string,
socialServiceId: CuratorServiceId,
measurementTitle: string,
measurementTask: string,
measurementServiceId: CuratorServiceId,
proofTitle: string,
proofTask: string,
proofServiceId: CuratorServiceId,
practiceTitle: string,
practiceTask: string,
practiceServiceId: CuratorServiceId,
): StaticSprintDay {
return {
focus,
outcome,
socialTitle,
socialTask,
socialServiceId,
measurementTitle,
measurementTask,
measurementServiceId,
proofTitle,
proofTask,
proofServiceId,
practiceTitle,
practiceTask,
practiceServiceId,
};
}
function staticWeekTemplate(days: StaticSprintDay[]): WeekTemplate {
return {
theme: "Static 7-Day CareerSprint",
summary: "Static day-wise sprint tasks sourced from the GrowQR task library and 7-day sprint document.",
days: days.map((item) => {
return {
focus: item.focus,
outcome: item.outcome,
measurement: seed(
"proof",
item.socialServiceId,
item.socialTitle,
item.socialTask,
"10 min",
"+6 projected",
"Open social proof",
["static social proof", item.socialTitle.toLowerCase()],
),
proof: seed(
"measurement",
item.measurementServiceId,
item.measurementTitle,
item.measurementTask,
"10 min",
"+6 projected",
"Open measurement",
["static measurement", item.measurementTitle.toLowerCase()],
),
practice: seed(
"proof",
item.proofServiceId,
item.proofTitle,
item.proofTask,
"10 min",
"+7 projected",
"Open proof task",
["static proof", item.proofTitle.toLowerCase()],
),
roleplay: seed(
"practice",
item.practiceServiceId,
item.practiceTitle,
item.practiceTask,
"10 min",
"+10 projected",
"Open practice task",
["static practice", item.practiceTitle.toLowerCase()],
),
};
}),
};
}
function staticIcpTemplate(
id: CuratorIcpId,
label: string,
sprintTheme: string,
goal: string,
days: StaticSprintDay[],
): CuratorIcpTemplateSet {
return {
id,
label,
sprintTheme,
goal,
weeks: [staticWeekTemplate(days)],
};
}
export function normalizeActiveCuratorTaskSeed(task: TaskSeed): TaskSeed {
return task;
}
export function templateSetFor(variantId: CuratorIcpId): CuratorIcpTemplateSet {
return CURATOR_TASK_REGISTRY[variantId] ?? CURATOR_TASK_REGISTRY.fresher_early_professional;
}

View File

@@ -0,0 +1,119 @@
import { Hono } from "hono";
import { z } from "zod";
import { requireUser, type AuthContext } from "../../auth/clerk.js";
import { applyQscoreProjection } from "../../events/projectors/qscore-projector.js";
import { applyServiceSessionProjection } from "../../events/projectors/service-session-projector.js";
import { markGrowEventFailed, markGrowEventProcessed, markGrowEventProcessing, recordGrowEvent } from "../../events/record-grow-event.js";
import { ensureOnboardingSideEffectsForEvent } from "../../events/onboarding-ledger.js";
const eventTrackSchema = z.object({
id: z.string().optional(),
source: z.string().min(1),
type: z.string().min(1).optional(),
action: z.string().min(1).optional(),
category: z.enum(["mission", "service", "artifact", "usage", "qscore", "entitlement", "system"]).default("service"),
userId: z.string().optional(),
user_id: z.string().optional(),
orgId: z.string().optional(),
org_id: z.string().optional(),
timestamp: z.string().optional(),
occurredAt: z.string().optional(),
occurred_at: z.string().optional(),
mission: z.record(z.unknown()).optional(),
subject: z.record(z.unknown()).optional(),
correlation: z.record(z.unknown()).optional(),
metadata: z.record(z.unknown()).optional(),
payload: z.record(z.unknown()).optional(),
dedupeKey: z.string().optional(),
dedupe_key: z.string().optional(),
taskId: z.string().optional(),
curatorTaskId: z.string().optional(),
curator_task_id: z.string().optional(),
serviceId: z.string().optional(),
service_id: z.string().optional(),
});
function compactRecord(value: Record<string, unknown>) {
return Object.fromEntries(Object.entries(value).filter(([, item]) => item !== undefined));
}
export function v1EventRoutes() {
const app = new Hono<AuthContext>();
app.use("*", requireUser);
app.post("/track", async (c) => {
const authUserId = c.get("userId");
const body = eventTrackSchema.parse(await c.req.json());
const type = body.type ?? body.action;
if (!type) return c.json({ error: "event_type_required" }, 400);
const payload = {
...(body.payload ?? {}),
...(body.metadata ? { metadata: body.metadata } : {}),
taskId: body.taskId,
curatorTaskId: body.curatorTaskId ?? body.curator_task_id ?? body.taskId,
serviceId: body.serviceId ?? body.service_id,
status: (body.payload?.status ?? body.metadata?.status) as unknown,
};
const correlation = compactRecord({
...(body.correlation ?? {}),
taskId: body.taskId,
curatorTaskId: body.curatorTaskId ?? body.curator_task_id ?? body.taskId,
serviceId: body.serviceId ?? body.service_id,
});
const event = await recordGrowEvent({
id: body.id,
source: body.source,
type,
category: body.category,
userId: authUserId,
orgId: body.orgId ?? body.org_id,
occurredAt: body.occurredAt ?? body.occurred_at ?? body.timestamp ?? new Date().toISOString(),
mission: body.mission,
subject: body.subject,
correlation,
payload: compactRecord(payload),
raw: body,
dedupeKey: body.dedupeKey ?? body.dedupe_key ?? body.id,
}, { userId: authUserId, source: body.source });
if (event.processingStatus === "processed") {
return c.json({
eventId: event.id,
processingStatus: "processed",
idempotent: true,
}, 202);
}
await markGrowEventProcessing(event.id);
try {
const serviceSession = await applyServiceSessionProjection(event);
const qscore = await applyQscoreProjection(event);
const onboarding = await ensureOnboardingSideEffectsForEvent(event);
if (
onboarding.curatorOnboarding.status === "skipped" &&
onboarding.curatorOnboarding.reason === "loop_failed"
) {
throw new Error("curator_onboarding_loop_failed");
}
await markGrowEventProcessed(event.id);
return c.json({
eventId: event.id,
processingStatus: "processed",
serviceSession,
qscore,
onboarding,
}, 202);
} catch (err) {
await markGrowEventFailed(event.id, err);
return c.json({
eventId: event.id,
processingStatus: "failed",
error: err instanceof Error ? err.message : String(err),
}, 500);
}
});
return app;
}

View File

@@ -1,10 +1,14 @@
import { Hono } from "hono";
import { v1CuratorRoutes } from "./curator/curator-routes.js";
import { v1AnalyticsRoutes } from "./analytics/analytics-routes.js";
import { v1EventRoutes } from "./events/events-routes.js";
import { v1QscoreRoutes } from "./qscore/qscore-routes.js";
export function v1Routes() {
const app = new Hono();
app.route("/curator", v1CuratorRoutes());
app.route("/analytics", v1AnalyticsRoutes());
app.route("/events", v1EventRoutes());
app.route("/qscore", v1QscoreRoutes());
return app;
}

View File

@@ -0,0 +1,84 @@
import { Hono } from "hono";
import { desc, eq } from "drizzle-orm";
import { requireUser, type AuthContext } from "../../auth/clerk.js";
import { db } from "../../db/client.js";
import { growQscoreLatest, growQscoreProjectionState } from "../../db/schema.js";
function groupDimensions(signals: Array<typeof growQscoreLatest.$inferSelect>) {
const grouped = new Map<string, { score: number; count: number; sources: Set<string> }>();
for (const signal of signals) {
const id = signal.signalId.split(".")[0] || "readiness";
const current = grouped.get(id) ?? { score: 0, count: 0, sources: new Set<string>() };
current.score += signal.score;
current.count += 1;
if (signal.source) current.sources.add(signal.source);
grouped.set(id, current);
}
return Array.from(grouped.entries()).map(([id, group]) => ({
id,
label: id.replace(/-/g, " ").replace(/^./, (char) => char.toUpperCase()),
score: Math.round(group.score / Math.max(group.count, 1)),
signalCount: group.count,
sources: Array.from(group.sources),
}));
}
export function v1QscoreRoutes() {
const app = new Hono<AuthContext>();
app.use("*", requireUser);
app.get("/latest", async (c) => {
const userId = c.get("userId");
const [projection] = await db
.select()
.from(growQscoreProjectionState)
.where(eq(growQscoreProjectionState.userId, userId))
.limit(1);
const signals = await db
.select()
.from(growQscoreLatest)
.where(eq(growQscoreLatest.userId, userId))
.orderBy(desc(growQscoreLatest.updatedAt));
if (!projection && signals.length === 0) {
return c.json({
status: "baseline_needed",
score: null,
dimensions: [],
trendLabel: "No QScore signals yet",
lastUpdatedAt: null,
explanation: "No onboarding, service completion, or readiness signals have been projected for this user.",
signalCount: 0,
signals: [],
source: "grow_qscore_projection_state",
});
}
const score = projection?.score && projection.score > 0
? projection.score
: Math.round(signals.reduce((sum, signal) => sum + signal.score, 0) / Math.max(signals.length, 1));
const lastUpdatedAt = projection?.updatedAt ?? signals[0]?.updatedAt ?? null;
return c.json({
status: "ready",
score,
dimensions: groupDimensions(signals),
trendLabel: signals.length > 1 ? "Updated from recent activity" : "Baseline established",
lastUpdatedAt: lastUpdatedAt?.toISOString() ?? null,
explanation: projection?.summary ?? `Readiness score computed from ${signals.length} current signal${signals.length === 1 ? "" : "s"}.`,
signalCount: projection?.signalCount ?? signals.length,
signals: signals.map((signal) => ({
signalId: signal.signalId,
score: Math.round(signal.score),
present: signal.present,
source: signal.source,
sourceEventId: signal.sourceEventId,
occurredAt: signal.occurredAt.toISOString(),
updatedAt: signal.updatedAt.toISOString(),
})),
source: "grow_qscore_projection_state",
});
});
return app;
}

View File

@@ -81,7 +81,7 @@ export async function updateRunProgress(runId: string) {
}
function extractQScore(output: Record<string, unknown>): number | undefined {
const direct = output.q_score ?? output.estimated_q_score;
const direct = output.q_score;
if (typeof direct === "number") return Math.round(direct);
const compute = output.compute as Record<string, unknown> | undefined;
if (typeof compute?.q_score === "number") return Math.round(compute.q_score);

View File

@@ -1,28 +1,57 @@
import { listFeatureDefinitions, internalWorkflowModules } from "../features/registry.js";
import { internalWorkflowModules } from "../features/registry.js";
import { listServices } from "../services/service-registry.js";
export type ServiceCapability = {
id: string;
name: string;
label?: string;
description?: string;
category?: string;
enabled: boolean;
internalUrl?: string;
publicUrl?: string;
operations: string[];
featureId?: string;
promptModulePath?: string;
healthPath?: string;
backend?: unknown;
frontend?: unknown;
curator?: unknown;
eventContract?: unknown;
usageDocs?: string[];
};
export function listServiceCapabilities(): ServiceCapability[] {
export function listServiceCapabilities(opts: { public?: boolean } = {}): ServiceCapability[] {
const services = listServices().map((service) => ({
id: service.id,
name: service.label,
label: service.label,
description: service.description,
category: service.category,
enabled: service.enabled,
...(opts.public ? {} : { internalUrl: service.backend.baseUrl }),
publicUrl: service.backend.publicUrl,
operations: Object.keys(service.backend.endpoints),
featureId: service.featureId,
promptModulePath: service.promptModulePath,
healthPath: service.backend.healthPath,
backend: {
...(opts.public ? {} : { baseUrl: service.backend.baseUrl }),
publicUrl: service.backend.publicUrl,
healthPath: service.backend.healthPath,
endpoints: service.backend.endpoints,
usage: service.backend.usage,
},
frontend: service.frontend,
curator: service.curator,
eventContract: service.eventContract,
usageDocs: service.usageDocs,
}));
if (opts.public) return services;
return [
...listFeatureDefinitions().map((feature) => ({
id: feature.serviceId,
name: feature.title,
enabled: feature.enabled,
internalUrl: feature.internalUrl,
publicUrl: feature.publicUrl,
operations: feature.operations,
featureId: feature.id,
promptModulePath: feature.promptModulePath,
})),
...services,
...internalWorkflowModules.map((module) => ({
id: module.id,
name: module.title,