Compare commits

..

62 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
76 changed files with 6584 additions and 1382 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,9 +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 { runCuratorOnboardingLoopForEventSafely } from "../v1/curator/curator-onboarding-loop.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.
@@ -101,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("_", ".")}`;
@@ -151,7 +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");
});
await runCuratorOnboardingLoopForEventSafely(event);
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,9 +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 { runCuratorOnboardingLoopForEventSafely } from "../v1/curator/curator-onboarding-loop.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();
@@ -21,8 +27,41 @@ async function ingest(body: unknown, userId?: string, source?: string) {
routed: false as const,
reason: err instanceof Error ? err.message : String(err),
}));
const curatorOnboarding = await runCuratorOnboardingLoopForEventSafely(event);
return { event, route, curatorOnboarding };
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() {
@@ -32,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, curatorOnboarding } = await ingest(body, userId);
return c.json({ eventId: event.id, processingStatus: event.processingStatus, route, curatorOnboarding }, 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.
@@ -43,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, curatorOnboarding } = await ingest(body, undefined, source);
return c.json({ eventId: event.id, processingStatus: event.processingStatus, route, curatorOnboarding }, 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,14 +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,
runCuratorOnboardingLoopSafely,
} 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;
@@ -83,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,
@@ -97,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");
@@ -109,13 +123,9 @@ export function userRoutes() {
const normalizedPreferences = preferences && typeof preferences === "object" && !Array.isArray(preferences)
? (preferences as Record<string, unknown>)
: undefined;
await ensureOnboardingBaselineQscore(
c.get("userId"),
normalizedPreferences,
);
const completedAt = onboardingCompletedAtFromPreferences(normalizedPreferences);
if (completedAt) {
await runCuratorOnboardingLoopSafely({
await recordAndProcessOnboardingCompletion({
userId: c.get("userId"),
completedAt,
source: "user-service-profile",

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

@@ -11,7 +11,7 @@ Completion is event gated. A checkbox or chat message cannot complete a task unl
## Service Curation Layer
- `curator-icp-playbooks.ts` defines ICP playbooks and maps each persona goal to registry-backed service actions.
- `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.
@@ -28,3 +28,5 @@ Completion is event gated. A checkbox or chat message cannot complete a task unl
- 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,12 +1,51 @@
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 "./curator-icp-playbooks.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;
@@ -72,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",
@@ -85,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);
@@ -93,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");
}
@@ -129,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

@@ -1,4 +1,4 @@
import { and, desc, eq } from "drizzle-orm";
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";
@@ -12,6 +12,16 @@ 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;
@@ -44,6 +54,17 @@ function parseCompletedAt(value: unknown): string | undefined {
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);
@@ -123,6 +144,22 @@ async function recordOnboardingContextSnapshot(input: {
}, { 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;
@@ -228,7 +265,7 @@ export async function runCuratorOnboardingLoop(input: OnboardingLoopInput): Prom
}
const startDate = isoDateFrom(input.completedAt);
const context = input.context ?? await fetchUserServiceContext(userId);
const context = input.context ?? await findLatestOnboardingContext(userId) ?? await fetchUserServiceContext(userId);
await recordOnboardingContextSnapshot({
userId,
startDate,
@@ -272,6 +309,7 @@ export async function runCuratorOnboardingLoopForEvent(event: GrowEventRow): Pro
completedAt: onboardingCompletedAtFromEvent(event),
sourceEventId: event.id,
source: event.source,
context: onboardingContextFromPayload(event.payload ?? {}),
});
}

View File

@@ -1,5 +1,5 @@
import { createHash } from "node:crypto";
import type { CuratorIcpPlaybook } from "./curator-icp-playbooks.js";
import type { CuratorIcpPlaybook } from "./icp-registry.js";
import type { CuratorUserContext } from "./curator-user-context.js";
export const CURATOR_PROMPT_VERSION = "service-curation-v1";

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(),
@@ -14,7 +27,7 @@ const chatSchema = z.object({
const curationPreviewSchema = z.object({
startDate: z.string().optional(),
icpId: z.enum(["student_recent_grad", "intern", "fresher_early_professional", "experienced_professional"]).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(),
});
@@ -31,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"),
@@ -45,18 +58,51 @@ 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 curatorActor.previewCuration({ userId, ...body }));
return c.json(await curatorService.previewCuration({ userId, ...body }));
});
app.post("/onboarding/run", async (c) => {
@@ -64,40 +110,40 @@ export function v1CuratorRoutes() {
const body = z.object({
completedAt: z.string().optional(),
}).parse(await c.req.json().catch(() => ({})));
return c.json(await curatorActor.runOnboardingLoop({ userId, ...body }));
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;

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"]);
@@ -69,7 +77,8 @@ export const curatorPlanDaySchema = z.object({
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),
@@ -128,7 +137,7 @@ export const curatorSprintResponseSchema = z.object({
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

@@ -4,7 +4,10 @@ export type CuratorIcpId =
| "student_recent_grad"
| "intern"
| "fresher_early_professional"
| "experienced_professional";
| "experienced_professional"
| "freelancer"
| "founder"
| "enterprise";
export type CuratorPlaybookAction = {
taskType: CuratorTaskType;
@@ -81,6 +84,45 @@ export const CURATOR_ICP_PLAYBOOKS: Record<CuratorIcpId, CuratorIcpPlaybook> = {
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 {

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,