Compare commits

..

155 Commits

Author SHA1 Message Date
Sai-karthik
e444d1be15 Fix compressed resume proxy responses 2026-07-13 06:52:59 +00:00
Sai-karthik
fdf4f6468d Cover task-aware curator handoffs 2026-07-13 06:10:15 +00:00
Sai-karthik
8617be7e7c Preserve task details on unavailable handoffs 2026-07-13 01:07:43 +00:00
Sai-karthik
8cb5413766 Keep presentation handoffs task-focused 2026-07-13 00:21:57 +00:00
Sai-karthik
2410a4800b Expose profile asset state in Day 1 tasks 2026-07-13 00:10:38 +00:00
Sai-karthik
f848f45429 Remove invented interview role fallback 2026-07-12 23:51:44 +00:00
Sai-karthik
5f5622b3a0 Fix curator task handoffs and profile state 2026-07-12 23:21:52 +00:00
-Puter
c7c6f1b8cc fix curator sprint ICP reconciliation 2026-07-13 03:31:02 +05:30
-Puter
e7546142b6 fix curator sprint access choice reconciliation 2026-07-13 02:39:29 +05:30
-Puter
78860ecf5e fix(staging): point Social service URL to AWS Social via gqr-staging tunnel (18015)
Live root cause: dashboard gateway reached old developer FastAPI at
social.sai-onchain.me and 404'd on the LinkedIn onboarding route.
The gqr-staging reverse SSH tunnel now exposes AWS Social on host
port 18015 (verified: healthz=200, POST route 422).

- SOCIAL_BRANDING_SERVICE_URL: http://host.docker.internal:18015 (internal tunnel)
- SOCIAL_BRANDING_PUBLIC_URL: https://social-staging.gqr.puter.wtf (public staging)
2026-07-12 21:59:31 +05:30
-Puter
e50be827ca fix(scripts): repair onboarding-bulk-reset with A2A user-service endpoint
Rewire Phase 1 to call the user-service POST /api/v1/users/onboarding-reset A2A
endpoint (targeting by clerk_id) instead of the per-user authenticated DELETE
route, which could not reach all users. A 404 from user-service means the user
has no profile (backend-only) — a clean skip that does not block Phase 2.

Phase 2 (backend ledger reset via resetOnboardingLedger) now runs ONLY after
Phase 1 succeeds or is skipped; a non-404 pref error blocks the ledger for that
user to avoid inconsistent state. Triple guard (NODE_ENV=staging,
ONBOARDING_BULK_RESET_ALLOWED=true, --confirm) and aggregate-only output are
preserved. Add source-structure assertions for the CLI contract.
2026-07-12 20:50:17 +05:30
-Puter
c54e12972c fix(onboarding): synthetic PATCH /me stale content-length socket close
patchPreferencesRequest forwarded inbound headers (including
content-length from the browser PATCH body) but set a DIFFERENT body
({ preferences }). The Request constructor does NOT recompute
content-length when an explicit body is provided alongside forwarded
headers, so user-service waited for bytes that never arrived and closed
the socket (~2.8s 'other side closed') → uncaught → 500.

Delete content-length and transfer-encoding before constructing the
synthetic request so undici recomputes them from the actual body. Apply
the same defensive deletes in fetchUserProfile.

Extract patchPreferencesRequest into the DB-free
src/services/user-profile.ts module (alongside fetchUserProfile) so the
regression test imports the REAL function and asserts content-length /
transfer-encoding / host / cookie are stripped and the body is the
synthetic { preferences } blob. tsc clean; existing onboarding tests
pass.
2026-07-12 20:29:40 +05:30
-Puter
ad1318424b fix(onboarding): PATCH /onboarding 500 — fetchUserProfile must GET /me
PATCH /onboarding read its JSON body via c.req.json() (consuming
c.req.raw), then passed c.req.raw to fetchUserProfile → fetchUserService,
which called req.arrayBuffer() on the already-consumed body:
TypeError: Body is unusable. The global onError caught it as a 500
{"error":"internal"}, so every access-choice save failed. GET
/onboarding worked only because it never reads a body.

fetchUserProfile is a read; it must never replay the inbound method or
body. Extract it (+ userServiceTarget) into a DB-free module that issues
an explicit GET /me with forwarded headers, sidestepping body reuse
entirely and fixing a latent wrong-method bug (PATCH /onboarding would
have issued a PATCH /me write for a read).

Verified against staging logs (TypeError stack at fetchUserService →
fetchUserProfile → PATCH handler). Focused regression test consumes the
body first and asserts GET + no body re-read + profile parse; tsc clean.
2026-07-12 20:14:22 +05:30
-Puter
62208d385f feat(onboarding): persist resettable user state 2026-07-12 19:23:29 +05:30
Sai-karthik
a1de355ea0 fix: propagate curator context to service routes 2026-07-11 23:47:00 +00:00
Sai-karthik
411ad591bf fix: route curator tasks to unified service pages 2026-07-11 23:02:29 +00:00
Sai-karthik
63619f5047 Implement canonical onboarding and static curator sprint 2026-07-11 21:27:21 +00:00
-Puter
b2283476d1 fix(staging): route social through edge tunnel 2026-07-11 16:49:59 +05:30
-Puter
09d3da0d7a fix(staging): route social service to dedicated host 2026-07-11 16:46:14 +05:30
-Puter
c715171aa2 fix(staging): use canonical matchmaking network 2026-07-11 14:59:00 +05:30
-Puter
f4964e5030 fix(events): keep actor state inside workflow steps 2026-07-11 14:57:10 +05:30
-Puter
5f5b08bfda fix(events): prevent userEventActor queue saturation from duplicate routing
Root cause: recordGrowEvent returned the existing row on dedupe hits, and
routeGrowEventToUserActor was called unconditionally — so every duplicate
ingest re-enqueued the same event to the actor queue, saturating it.

Fix at the persistence boundary:
- recordGrowEventWithResult returns {event, inserted} using atomic
  onConflictDoNothing().returning() (row present = fresh insert, no row =
  dedupe hit). No clock heuristics, no xmax probe.
- Conflict lookup tries dedupeKey first, then id PK fallback, covering both
  constraint types.
- shouldRouteGrowEvent(event, inserted) gates routing to only newly-inserted,
  user-resolved, pending events. Dedupe hits never re-enqueue.
- recordGrowEvent kept as thin .event wrapper — zero changes to ~10 callers.

Fix the actor run loop (mirrors workflow-run-actor.ts):
- tryStep with catch:['timeout','exhausted'] replaces step+rethrow. Per-event
  failure is marked terminal in a separate record-failure step, loop advances.
- isProcessableStatus guard allows pending/processing/processed — only skips
  failed/unresolved. This ensures mission reducers (actor-only) still run on
  events that got synchronous projections.
- Status guard + claim moved inside tryStep's run for atomic retry unit.
- onError handler added to workflow().

Ingress callers migrated (3 paths):
- redis-consumer recordAndRoute: shouldRouteGrowEvent gate + always mark
  processed after projections.
- routes/events.ts ingest: shouldRouteGrowEvent gate + always mark processed.
- v1/events/events-routes.ts /track: inserted flag for dedupe skip.

Tests: 3 new (event-dedupe-routing, user-event-actor-advance,
event-ingress-route-integration with injectable enqueue proving 5x duplicate
ingest enqueues exactly once). All existing touched-scope tests green.

Co-authored-by: independent review (2 rounds)
2026-07-11 06:19:38 +05:30
-Puter
3f2dabd23d fix(events): bound actor routing latency 2026-07-10 20:26:38 +05:30
-Puter
ac7bdca912 feat(backend): REST-authoritative qscore pipeline with Redis opt-in
- Gate both Redis ingestion paths (canonical + legacy) behind separate
  default-off flags (GROW_EVENTS_REDIS_ENABLED, LEGACY_SERVICE_REDIS_ENABLED).
  The redis module is never imported when both flags are off.
- Sever legacy URL cascade: interview/roleplay/resume/courses Redis URLs
  resolve ONLY from their own explicit env vars, never from REDIS_URL or
  GROW_EVENTS_REDIS_URL.
- Export resolveRedisConfig() pure resolver for testability.
- Fix onboarding signal ID from 'onboarding.completed_baseline' to
  'onboarding.completed' (registry-valid in all v2 formula JSONs).
- Ensure interview/roleplay completed events emit at least a single
  completion signal when session_count/scenario_count is absent (default 1).
- Extract computeStreakFromDays() into streak-utils.ts (pure, no DB dep)
  with defensive dedup so duplicate days never inflate streaks.
- Add focused tests: redis-gating, signal-registry membership, count
  fallback, streak policy (current/longest/duplicate/gap/recovery/7-day),
  service-ingest projector behavior.
- typecheck: 80 pre-existing errors unchanged, 0 new.
2026-07-10 20:12:13 +05:30
-Puter
d3a4e63e4a refactor: backend becomes pure signal feeder to qscore_service
- applyQscoreProjection forwards extracted signals to qscore_service via HTTP
- applyCuratorTaskDeltaProjection removed (no local additive scoring)
- forwardSignalsToQscoreService helper added to service-agents.ts
- New qscore-proxy.ts: getQscoreFromService proxies all reader sites
- 7 reader endpoints proxy to qscore_service (services, qscore-routes, analytics, home-feed, curator context, curator-store, curator-tools)
- Onboarding baseline forwards to qscore_service
- All existing response shapes preserved for dashboard compatibility
- tsc --noEmit passes clean
2026-07-10 00:28:19 +05:30
-Puter
722d5825bf fix(staging): use zen balance endpoint for minimax 2026-07-09 22:14:20 +05:30
-Puter
389a9fb014 fix(staging): attach backend to service networks 2026-07-09 22:12:41 +05:30
-Puter
689bfb6c52 fix(staging): bind backend compose ports locally 2026-07-09 22:09:59 +05:30
-Puter
c3e7b00250 fix(staging): pin backend runtime overrides 2026-07-09 21:58:30 +05:30
-Puter
7bcd664afb fix(events): keep mission metadata on service sessions 2026-07-09 19:07:30 +05:30
-Puter
64c245535f feat(events): project course and matchmaking activity 2026-07-09 18:55:00 +05:30
-Puter
237c65f328 fix(social): remove incorrect /api/v1/ prefix from social proxy
The social-branding service routes are at root level (/accounts/connect,
/profiles/fetch-by-url, etc.) — NOT under /api/v1/. The proxy was
prepending /api/v1/ to every forwarded path, causing 404 Not Found on
all social POST endpoints including LinkedIn account connect.
2026-07-09 14:52:40 +05:30
-Puter
ee964a5d13 feat(social): emit GrowQR events from social-branding proxy routes
The social-branding proxy was a raw passthrough that never called
recordGatewayEvent — so no grow_events rows were created for any social
action (account connect, profile polish, post publish, etc.), breaking
the curator/streak/QScore loop.

Follow the proxyResumeRequest pattern exactly:
- GET/HEAD pass through directly (no event)
- POST/PUT/PATCH/DELETE buffer response, record gateway event, return
- Add socialEventTypeForRest() mapping 10+ social routes to event types
- Add 'social' to canonicalSubjectServiceId mapping
- Event payload is strictly {path, method, status, action} — no raw
  cookies, tokens, passwords, or LinkedIn session data
- Extract account_id from path/response for correlation.externalId
2026-07-09 14:30:04 +05:30
Sai-karthik
f61742b38f Add two-day trial sprint recovery flags 2026-07-09 04:50:29 +00:00
Sai-karthik
da065d1077 Include curator task metadata in service handoffs 2026-07-08 09:04:54 +00:00
Sai-karthik
38c147b331 Point backend social branding URL to live service 2026-07-08 07:53:18 +00:00
Sai-karthik
8314ffbe75 Configure social branding service URL 2026-07-08 07:49:30 +00:00
Sai-karthik
ee304b0016 Enable social branding curator handoffs 2026-07-08 07:41:57 +00:00
Sai-karthik
0c9ddd3b25 Route social branding curator handoffs to Nova 2026-07-08 07:38:59 +00:00
Sai-karthik
098056458e Project streak task completions into QX score 2026-07-07 20:45:30 +00:00
Sai-karthik
6eeaf50423 Fix QX fallback dimension initialization 2026-07-07 20:12:10 +00:00
Sai-karthik
4b0bf4379f Fix QX task impact registry initialization 2026-07-07 20:10:16 +00:00
Sai-karthik
6d996882bf Apply deterministic QX values to streak tasks 2026-07-07 20:06:48 +00:00
Sai-karthik
82859d8d08 Wire CareerSprint context into talk conversations 2026-07-07 09:37:59 +00:00
Sai-karthik
7b4f6f7d94 Merge pull request #16 system notifications backend 2026-07-06 12:31:03 +00:00
790db95bda feat: back system notification bell with database 2026-07-06 01:46:14 +05:30
Sai-karthik
7142c8f7e8 PRM-88 use full service registry for KPI signals 2026-07-05 19:12:25 +00:00
Sai-karthik
9062ce9f22 PRM-88 add CareerSprint report KPI backend 2026-07-05 11:43:08 +00:00
Sai-karthik
d252077135 Expose static curator day outcomes 2026-07-04 12:22:29 +00:00
Sai-karthik
5606f987c5 Serve static curator reads without actor startup 2026-07-02 20:19:57 +00:00
Sai-karthik
4353e8fe33 Align curator sprint with static task docs 2026-07-02 19:54:50 +00:00
Sai-karthik
f826b7b9a3 Remove legacy curator ICP shim 2026-07-02 18:03:14 +00:00
Sai-karthik
6432734f74 Split curator ICP and task registries 2026-07-02 17:59:07 +00:00
Sai-karthik
0394dfbca0 Verify static curator handoff registry 2026-07-02 13:18:49 +00:00
Sai-karthik
837a36276a Make curator missions static by ICP sprint 2026-07-02 12:57:38 +00:00
Sai-karthik
a43b7dd9dd Align recovery curator CTA with roleplay 2026-07-01 13:16:36 +00:00
Sai-karthik
4ec3f57210 Clean resume proof curator copy 2026-07-01 12:37:02 +00:00
Sai-karthik
9df22e01e7 Restrict curator streak tasks to active services 2026-07-01 12:27:31 +00:00
Sai-karthik
35052a0ced Merge remote-tracking branch 'origin/fix/curator-streak-agent-mapping' into staging 2026-07-01 11:16:09 +00:00
e1bb3b2b11 fix: map curator tasks to service agents 2026-07-01 16:32:06 +05:30
Sai-karthik
ac66abec28 Merge PR #13: passive cross-service actions 2026-07-01 10:29:09 +00:00
Sai-karthik
7d74f81913 Recover deep-linked curator starts 2026-06-30 15:46:15 +00:00
Sai-karthik
3bba99e874 Recover completed deep-linked curator tasks 2026-06-30 13:31:30 +00:00
Sai-karthik
697040e9d3 Complete deep-linked matchmaking tasks 2026-06-30 13:23:44 +00:00
Sai-karthik
39ba59ab25 Cap home mission cards 2026-06-29 18:44:28 +00:00
Sai-karthik
10478fb035 Fix staging home mission service routing 2026-06-29 18:38:03 +00:00
a6b0cf3a00 feat: add passive cross-service actions 2026-06-29 23:18:45 +05:30
Sai-karthik
dfc45fbea2 Merge PR #13: passive mission lifecycle 2026-06-29 17:19:43 +00:00
210b577462 feat: add passive mission lifecycle 2026-06-29 22:15:59 +05:30
Sai-karthik
60332956a0 Merge PRM-80 backend canonical service event contract 2026-06-29 11:37:22 +00:00
Sai-karthik
1b90e5db39 fix: inline service registry event contracts 2026-06-28 08:27:04 +00:00
Sai-karthik
52589fc76d Merge remote-tracking branch 'origin/pr/13' into karthiksaiketha/prm-80-backend-canonical-service-event-contract-for-streaks-qscore 2026-06-28 08:16:30 +00:00
685f2dcd24 feat: implement onboarding ledger event handling and related tests 2026-06-28 04:03:39 +05:30
Sai-karthik
3329eeb2fd Merge PR #12: PRM-80 canonical service events 2026-06-25 12:55:07 +00:00
Sai-karthik
760103f838 fix: harden PRM-80 audit requirements 2026-06-25 12:54:54 +00:00
Sai-karthik
592bbf0f57 docs: add PRM-80 final PR audit 2026-06-25 12:24:50 +00:00
-Puter
57b31d58cc Harden conversation bootstrap on staging 2026-06-25 17:49:39 +05:30
Sai-karthik
e13dfe7d46 fix: keep qscore out of curator tasks 2026-06-25 12:19:39 +00:00
Sai-karthik
b895d6be79 fix: emit canonical service events for PRM-80 2026-06-25 11:35:37 +00:00
-Puter
91600e4e8c fix: make onboarding status ledger-only 2026-06-24 19:41:42 +05:30
-Puter
eaba7f95e3 fix: gate onboarding on ledger snapshot 2026-06-24 19:34:36 +05:30
-Puter
a442f1f53a fix: keep user activity analytics resilient 2026-06-24 19:11:18 +05:30
e88bc02012 Merge PR #11: Register curator actor
Register curatorActor in the Rivet registry and route Curator APIs through the actor handle.
2026-06-24 11:46:00 +00:00
sai karthik
13e82e0a52 Register curator actor 2026-06-24 16:53:27 +05:30
-Puter
750a6ab03b puter fix: service registry and curator onboaridng data fix, home feed route fixes 2026-06-24 15:30:00 +05:30
dv
1ecd964104 Merge pull request 'PRM-71 Backend QA Curator streak loop' (#10) from prm-71-backend-qa-curator-streak-loop into staging
Reviewed-on: #10
2026-06-23 21:26:55 +00:00
sai karthik
97ed70a921 Add PRM-71 backend QA evidence 2026-06-24 02:35:47 +05:30
Sai-karthik
0bfc18305b Project scored service completions into QScore 2026-06-23 21:01:10 +00:00
Sai-karthik
a83a27eb50 Recognize explicit abandoned curator service events 2026-06-23 20:45:12 +00:00
Sai-karthik
2de70d3b8c Solve PRM-71 curator backend QA loop 2026-06-23 20:38:33 +00:00
dv
b379d5b9fc Merge pull request '[PRM-63] Backend service registry issue solved' (#9) from prm-63-service-registry into staging
Reviewed-on: #9
2026-06-23 18:49:19 +00:00
dv
71f18fde9d Merge branch 'staging' into prm-63-service-registry 2026-06-23 18:49:11 +00:00
dv
dfdde7fa4d Merge pull request 'Backend: Add Curator 30-Day Streak Curation and Onboarding Loop' (#8) from backend/service-curation-layer into staging
Reviewed-on: #8
2026-06-23 18:48:19 +00:00
Sai-karthik
dbc984ed7f Keep home feed available when agent generation slows 2026-06-23 08:54:37 +00:00
Sai-karthik
4092025693 Backfill short home feed agent responses 2026-06-23 08:41:55 +00:00
Sai-karthik
29ed0a15cd Throttle user stack provisioning retries 2026-06-23 06:23:56 +00:00
Sai-karthik
7bad0a46c2 Repair missing home feed hrefs 2026-06-23 05:37:47 +00:00
Sai-karthik
f888a6fc0d Remove QScore estimate fallback 2026-06-23 05:04:55 +00:00
Sai-karthik
1cbd3e1a84 Repair missing home feed agent tags 2026-06-22 23:41:00 +00:00
Sai-karthik
bff336baa7 Add generated content quality smoke 2026-06-22 23:16:52 +00:00
Sai-karthik
cad24ea089 Keep public service catalog registry-only 2026-06-22 22:48:30 +00:00
Sai-karthik
459832a2a3 Add service registry acceptance probe 2026-06-22 22:35:06 +00:00
Sai-karthik
610975561f Harden home feed agent generation 2026-06-22 22:29:32 +00:00
Sai-karthik
a3a84faae7 Add service gateway write-flow smoke 2026-06-22 22:19:03 +00:00
Sai-karthik
d493ce8f33 Wire gateway user context through user service 2026-06-22 22:09:38 +00:00
Sai-karthik
fe62662cb6 Harden service gateway smoke coverage 2026-06-22 21:46:06 +00:00
Sai-karthik
6a77bb5d2e Enrich service preview gateway payloads 2026-06-22 21:31:58 +00:00
Sai-karthik
c48c28fdb3 Implement canonical service registry 2026-06-22 21:25:38 +00:00
17a888bd67 feat: update curator schemas to support 6-week plans and enhance user context
- Increased weekIndex max from 5 to 6 in curator task, plan day, and week schemas.
- Adjusted days array in curator week schema to allow a minimum of 1 day.
- Modified weeks array in curator plan schema to accept between 5 and 6 weeks.
- Enhanced CuratorUserContext type to include detailed user information and QScore.
- Introduced Curator ICP Playbooks for various user profiles with structured actions.
- Implemented onboarding loop for user onboarding completion and notification.
- Added prompt builder for generating structured 30-day plans based on user context and playbooks.
2026-06-22 22:24:27 +05:30
Sai-karthik
1be3ab1961 Refine curator sprint planning flow 2026-06-22 07:46:50 +00:00
Sai-karthik
bd582fc6c4 Make nightly analytics operational for active curator users 2026-06-20 10:15:31 +00:00
Sai-karthik
2c5cf1bcf8 Allow nightly analytics fanout runs 2026-06-20 10:11:00 +00:00
Sai-karthik
292e375a37 Use nightly analytics signals in curator day generation 2026-06-20 10:08:21 +00:00
Sai-karthik
9a6518a5d8 Add curator resume handoff from interview evidence 2026-06-20 08:50:32 +00:00
Sai-karthik
c66360cb7e Stabilize curator chat fallbacks 2026-06-19 22:44:53 +00:00
Sai-karthik
abeefc221b Propagate curator task ids through service events 2026-06-19 22:22:51 +00:00
Sai-karthik
20c18583db Build adaptive 30-day curator sprint 2026-06-19 21:51:34 +00:00
Sai-karthik
27c9f58b80 Implement ICP-driven curator sprint flow 2026-06-19 15:10:39 +00:00
Sai-karthik
c73b1a1788 Merge staging into staging-rosh preserving curator flow 2026-06-19 09:53:19 +00:00
Sai-karthik
447b5ca726 Close qscore curator tasks from review 2026-06-18 09:50:56 +00:00
Sai-karthik
e8b4634dd1 Prefer one live curator task per mission 2026-06-18 08:34:18 +00:00
Sai-karthik
a41e8be1e1 Surface qscore stages in curator daily tasks 2026-06-18 08:06:27 +00:00
Sai-karthik
38e68d8273 Ensure curator seeds three live daily missions 2026-06-18 05:55:06 +00:00
Sai-karthik
1d887bc153 Tighten curator mission generation 2026-06-17 13:05:54 +00:00
Sai-karthik
c46b9b11f6 feat: finalize curator preview handoff flow 2026-06-17 12:33:19 +00:00
Sai-karthik
fe449fdc50 refactor: replace personified workflow labels 2026-06-17 12:22:48 +00:00
9b6f887c3f Fix curator preview handoffs 2026-06-17 08:09:34 +05:30
Sai-karthik
89e1be4b12 Stabilize curator handoff generation 2026-06-15 12:50:25 +00:00
Sai-karthik
2ccc0ea48d Return structured curator handoffs 2026-06-15 11:08:29 +00:00
Sai-karthik
3fecfdc403 Fix curator prompt leakage 2026-06-15 10:38:33 +00:00
Sai-karthik
37fa8f13f4 Remove curator fallback questions 2026-06-15 09:30:29 +00:00
Sai-karthik
9bb2c0de3f Require service events for curator service tasks 2026-06-15 08:57:29 +00:00
Sai-karthik
368410e9d8 Fix curator subtask chat state 2026-06-15 08:36:21 +00:00
Sai-karthik
4b23dd0905 Make curator chats live generated 2026-06-14 20:10:41 +00:00
Sai-karthik
60b1df6892 Fix curator task chat scoping 2026-06-14 19:33:24 +00:00
Sai-karthik
ed7233d6e2 Route curator chat through conversation actor 2026-06-14 19:00:28 +00:00
Sai-karthik
4a20816ba0 Refine V1 curator task context 2026-06-14 15:59:59 +00:00
Sai-karthik
036aff1d1d Implement V1 curator flow 2026-06-14 15:10:39 +00:00
dv
72b3f03dad Merge pull request 'Canonicalize mission links and preserve mission context in the service gateway' (#5) from prm-47/agent-harness-over-microservice into staging
Reviewed-on: #5
2026-06-14 13:29:25 +00:00
Sai-karthik
41b0c69326 Implement mission chat actors and analytics 2026-06-14 10:06:34 +00:00
92ab414048 feat: enhance mission detail handling and update hrefs across services 2026-06-10 02:49:18 +05:30
-Puter
9fd478c095 fix: keep onboarding qscore baseline at 35 2026-06-06 13:51:22 +05:30
-Puter
f0ef57f054 fix: allow long interview service actions 2026-06-06 12:53:22 +05:30
-Puter
dd48321904 fix: keep home feed responsive 2026-06-06 04:38:47 +05:30
-Puter
bef6d08b6b feat: add mission action queue runtime 2026-06-06 03:25:29 +05:30
-Puter
170d3583c6 fix: enrich roleplay service context 2026-06-06 01:59:00 +05:30
-Puter
aa8f2853b2 fix: enrich interview service context 2026-06-06 01:22:44 +05:30
-Puter
c47e6de526 fix: preserve nested resume service proxy paths 2026-06-06 01:03:05 +05:30
-Puter
5f667038d8 docs: plan robust retry and dlq layer 2026-06-05 22:01:00 +05:30
-Puter
ef5d7bb378 docs: map staging and production backend behavior 2026-06-05 22:01:00 +05:30
-Puter
d4f9b0edcb docs: inventory backend dead code candidates 2026-06-05 22:01:00 +05:30
-Puter
01e9cc92d4 docs: audit backend organization and actor flow 2026-06-05 22:01:00 +05:30
-Puter
213987a9e0 fix: persist onboarding qscore baseline 2026-06-05 19:51:24 +05:30
-Puter
8e4fdc6adf fix: set Rivet runner version in image 2026-06-05 19:08:52 +05:30
-Puter
d10ef2a882 feat: personalize home feed suggestions 2026-06-05 17:30:00 +05:30
132 changed files with 24448 additions and 1098 deletions

View File

@@ -2,17 +2,20 @@ 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
ENV NODE_ENV=production
ENV RIVET_RUNNER_VERSION=$RIVET_RUNNER_VERSION
COPY --from=deps /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
COPY package.json ./

View File

@@ -39,10 +39,10 @@ Use the Q Score service when the user wants to:
Default launch signals to ingest when detailed service results are not yet available:
- `resume.uploaded`
- `resume.ats_compatibility`
- `interview.completed`
- `roleplay.completed`
- `goal.clarity`
- `profile.role_fit`
- `interview.sessions_completed`
- `roleplay.scenarios_completed`
- `onboarding.goals_clarity`
- `onboarding.profile_completeness`
When richer product outputs are available, prefer real scores, completion states, assignment outcomes, review feedback, timestamps, and target-role context over generic defaults.

View File

@@ -0,0 +1,54 @@
# VPS override: make host.docker.internal resolve to the host so the
# backend container can reach product services + spawned per-user
# containers published on host ports (Linux has no built-in mapping).
services:
backend:
extra_hosts:
- "host.docker.internal:host-gateway"
networks:
default:
interview-service:
roleplay-service:
resume-builder-staging:
course-service:
qscore-service-staging:
matchmaking-v2-staging:
user-service-staging:
social-branding-staging:
environment:
DATABASE_URL: postgres://growqr:growqr@growqr-postgres:5432/growqr
SOCIAL_BRANDING_SERVICE_URL: http://social-branding-api-1:8011
SOCIAL_BRANDING_PUBLIC_URL: https://social-staging.gqr.puter.wtf
PRODUCT_SERVICE_TIMEOUT_MS: 10000
PRODUCT_INTERACTIVE_SERVICE_TIMEOUT_MS: 240000
LLM_PROVIDER: opencode
LLM_BASE_URL: https://opencode.ai/zen/v1
LLM_MODEL: minimax-m3
GROW_AGENT_MODEL: minimax-m3
CONVERSATION_ACTOR_MODEL: minimax-m3
networks:
interview-service:
external: true
name: interview-service_default
roleplay-service:
external: true
name: roleplay-service_default
resume-builder-staging:
external: true
name: resume-builder_default
course-service:
external: true
name: courses_service_default
qscore-service-staging:
external: true
name: qscore-service_default
matchmaking-v2-staging:
external: true
name: matchmaking-service_default
user-service-staging:
external: true
name: growqr-app_growqr-net
social-branding-staging:
external: true
name: social-branding_default

View File

@@ -9,7 +9,7 @@ services:
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-growqr}
POSTGRES_DB: ${POSTGRES_DB:-growqr}
ports:
- "5432:5432"
- "127.0.0.1:15445:5432"
volumes:
- postgres-data:/var/lib/postgresql/data
healthcheck:
@@ -33,8 +33,8 @@ services:
GITEA__security__INSTALL_LOCK: "true"
GITEA__service__DISABLE_REGISTRATION: "true"
ports:
- "3001:3000" # HTTP (Gitea listens on 3000 internally)
- "2222:2222" # SSH
- "127.0.0.1:13001:3000" # HTTP (Gitea listens on 3000 internally)
- "127.0.0.1:12222:2222" # SSH
volumes:
- gitea-data:/data
healthcheck:
@@ -50,8 +50,8 @@ services:
image: rivetdev/engine:latest
container_name: growqr-rivet
ports:
- "6420:6420" # API
- "6421:6421" # Guard/edge
- "127.0.0.1:16420:6420" # API
- "127.0.0.1:16421:6421" # Guard/edge
environment:
RIVET__FILE_SYSTEM__PATH: /data
RIVET__AUTH__ADMIN_TOKEN: ${RIVET_ADMIN_TOKEN:-dev-admin-token}
@@ -74,7 +74,7 @@ services:
rivet-engine:
condition: service_started
ports:
- "4000:4000"
- "127.0.0.1:18012:4000"
environment:
PORT: 4000
NODE_ENV: ${NODE_ENV:-production}
@@ -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,11 +118,19 @@ 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}
SOCIAL_BRANDING_SERVICE_URL: ${SOCIAL_BRANDING_SERVICE_URL:-http://host.docker.internal:8011}
SOCIAL_BRANDING_PUBLIC_URL: ${SOCIAL_BRANDING_PUBLIC_URL:-http://host.docker.internal:8011}
PATHWAYS_SERVICE_URL: ${PATHWAYS_SERVICE_URL:-http://host.docker.internal:8009}
# Frontend
FRONTEND_ORIGIN: ${FRONTEND_ORIGIN:-http://localhost:3000}
volumes:
# Docker-out-of-Docker: backend uses host Docker to spawn per-user OpenCode containers.
- /var/run/docker.sock:/var/run/docker.sock
- ./prompts:/app/prompts
# Shared host dir that per-user containers will also bind-mount their
# workspace from (so backend and spawned containers see the same files).
- ./.data/users:/data/users

84
docs/backend-dead-code.md Normal file
View File

@@ -0,0 +1,84 @@
# Backend Dead Code Inventory
PRM-46 inventory pass for `growqr-backend`.
No source code was deleted in this pass. Static search and manual inspection were used. Typecheck was run successfully with `pnpm typecheck`.
## Summary
The codebase is mostly wired, but it contains several compatibility, demo, and partially superseded paths. The main cleanup risk is accidentally removing code still used by the frontend's older workflow screens or by demo environments.
## Candidates
| Priority | Candidate | Recommendation | Evidence |
| --- | --- | --- | --- |
| High | `src/actors/product-service-actors.ts` | Keep for now; consider deleting only after confirming no Rivet clients call these actors. | Actors are registered in `src/actors/registry.ts`, but local code routes service calls through `src/routes/services.ts` and `src/services/product-service-clients.ts` directly. No local `getOrCreate` references for `interviewServiceActor`, `roleplayServiceActor`, or `resumeServiceActor` were found. |
| High | Legacy `/workflows/job-application*` route aliases in `src/routes/workflows.ts` and large portions of `src/actors/user-actor.ts` workflow state | Keep until frontend migration is verified; likely cleanup after DB-backed workflow runs fully replace it. | `job-application` aliases call `userActor`; newer `/workflow-runs` path uses `workflowRuns`, `workflowRunModules`, and `workflowRunActor`/`executeWorkflowModule`. Two workflow systems coexist. |
| High | `src/workflows/module-runner.ts` synchronous execution from routes | Keep, but consolidate behind `workflowRunActor` before cleanup. | Used both by `workflowRunActor` and directly by route handlers. Direct route use undercuts actor durability, but the module runner itself is active. |
| Medium | `src/workflows/smoke-test.ts` | Keep as script if used manually; otherwise convert to documented test or remove. | Only referenced by `package.json` script `workflows:smoke`; not part of app runtime. |
| Medium | `scripts/rivet-actors.ts` | Keep if used by ops; document or remove if not. | Standalone admin script; not imported by source. It relies on `RIVET_ENDPOINT`, `RIVET_NAMESPACE`, and admin token defaults. |
| Medium | Demo home seeder `src/home/seed-demo-home.ts` and `/home/seed-demo` | Keep in staging/demo only; move behind explicit environment gate. | `src/routes/home.ts` exposes a seed endpoint. Schema has `generatedBy: "demo"` for notifications. This is live source behavior rather than isolated fixture code. |
| Medium | Static fallback mission registry vs persisted registry (`src/missions/registry.ts` and `src/missions/postgres-registry.ts`) | Keep both until migration/backfill is confirmed; then decide whether DB registry or static registry is source of truth. | `routes/missions.ts` reads persisted definitions, while actor factory and conversations read static definitions. `postgres-registry` falls back to static definitions. |
| Medium | Duplicate mission actor wrappers (`career-transition-actor.ts`, `salary-negotiation-war-room-actor.ts`, `promotion-readiness-actor.ts`, `personal-brand-opportunity-engine-actor.ts`) | Keep; low-cost wrappers are active. | Thin wrappers are mapped in routes, registry, event actor, and actor registry. |
| Medium | `src/events/projectors/projection-agent.ts` LLM insight path | Keep, but verify product use. | Referenced by `userEventActor` and `reducer-types`, so not dead. It can silently fall back when no LLM API key exists. |
| Medium | Legacy Redis observers in `src/events/redis-consumer.ts` | Keep until services emit canonical Grow Events. | Comments state these observe existing service A2A traffic. They are enabled by `INTERVIEW_REDIS_URL`, `ROLEPLAY_REDIS_URL`, and `RESUME_REDIS_URL`. |
| Medium | `events` audit table in `src/db/schema.ts` | Keep until old frontend timelines and route writes are audited. | Older user/service paths still import/use `events` table, while newer Grow Event tables also exist. |
| Low | `src/workflows/registry.ts` and `src/missions/registry.ts` duplicate product concepts | Keep; consolidate later. | Workflows are commercial product definitions; missions are actor-backed variants. The overlap is intentional but duplicative. |
| Low | `docker/opencode/workspace-template/*/README.md` placeholders | Keep as template docs or remove if generated workspaces no longer need empty folders. | Template-only files are not runtime code, but useful for preserving folder structure. |
| Low | `docs/architecture.html` | Keep unless replaced by Markdown architecture docs. | Existing doc artifact, not source. |
## Unused or Underused Env Vars / Config Values
| Env/config | Recommendation | Evidence |
| --- | --- | --- |
| `config.required` | Keep or remove after scanning call sites; currently exported but not used in local source. | `required` is attached to config, but no local `config.required(` references were found. |
| `clerkPublishableKey` | Keep if clients read backend config elsewhere; otherwise remove from backend config. | Defined in `config.ts` and `.env.example`, but backend auth uses secret key. |
| `opencodeApiKey` | Keep only if future direct OpenCode auth requires it; currently `llmApiKey` consumes `OPENCODE_API_KEY`. | Defined separately in config; most OpenCode runtime calls use per-container password, not this field. |
| `userServiceUrl` | Keep; used by missions profile lookup. | `routes/missions.ts` fetches `/api/v1/users/me`. |
| `legacyServiceTaskObserverGroup` | Keep while legacy Redis observers exist. | Used in `redis-consumer.ts`. |
| `migrationVersion`, `promptVersion`, `opencodeImageVersion` | Keep; active Docker rollout labels. | Used by `docker/manager.ts` and Docker build metadata. |
## Stale or Demo-Oriented Behavior
- Demo generated home notifications and `/home/seed-demo` should move to a staging/demo module or be guarded by `config.environment`.
- `service-agents.ts` includes demo-like defaults, such as `formula_version: "workflow-demo"` and synthetic Q Score fallback summaries.
- `config.ts` defaults many production-sensitive values to local/dev values, including Gitea admin credentials, service token fallback, A2A key, and localhost URLs.
- Docker/OpenCode scripts are active but dev-biased, using image tags like `growqr/opencode:dev`.
## Prompt Workflow Inventory
All prompt workflow files under `prompts/workflows/*` are referenced by `src/workflows/registry.ts` through `promptPath` values:
- `career-transition/orchestrator.md`
- `interview-to-offer/interview-plan.md`
- `salary-negotiation-war-room/orchestrator.md`
- `promotion-readiness/orchestrator.md`
- `personal-brand-opportunity-engine/orchestrator.md`
Additional interview-to-offer prompt files (`resume-analysis.md`, `story-bank.md`, `final-readiness-report.md`) are not referenced by `workflowDefinitions` directly in this pass. Recommendation: keep until OpenCode/agent prompt loading is audited, then either wire them into module definitions or archive them.
## Delete/Keep Decisions Before Cleanup
Do not delete yet:
- `userActor` workflow code
- `product-service-actors`
- static mission/workflow registries
- Redis legacy observers
- demo home seeder
- standalone scripts
Good first cleanup after approval:
1. Move demo seeding to `src/staging` and guard it with a staging/demo environment.
2. Remove or document unused config fields (`config.required`, `clerkPublishableKey`, `opencodeApiKey`) after a second pass across frontend/deployment references.
3. Convert `workflows:smoke` into a real test or delete the script.
4. Consolidate mission actor type mapping into one helper and remove duplicate mapping functions.
## Verification
`pnpm typecheck` passed:
```txt
tsc -p tsconfig.json --noEmit
```

View File

@@ -0,0 +1,179 @@
# Backend Organization Audit
PRM-41 audit pass for `growqr-backend`.
Scope reviewed: `src/routes`, `src/actors`, `src/events`, `src/missions`, `src/workflows`, and `src/services`.
## Executive Summary
The backend currently has three overlapping orchestration layers:
1. HTTP routes that directly perform database writes, service calls, and some synchronous workflow execution.
2. Rivet actors that own durable user, workflow, mission, conversation, memory, and event processing state.
3. Event/projector code that normalizes service events into Grow Events, updates mission state, records service sessions, and projects Q Score signals.
That split is workable for a demo-stage backend, but it blurs ownership. Several routes contain business logic that should live in services or actors, while actors and event consumers need stronger idempotency, retry, and replay boundaries before production traffic.
## High-Level Architecture
```mermaid
flowchart LR
FE[Frontend / service clients] --> Hono[Hono routes]
Hono --> DB[(Postgres / Drizzle)]
Hono --> Rivet[Rivet actors]
Hono --> Svc[Product services]
Hono --> Docker[Docker + Gitea + OpenCode]
Svc --> Redis[Redis streams / pubsub]
Redis --> Consumer[events/redis-consumer]
Consumer --> GrowEvents[(grow_events)]
Consumer --> EventActor[userEventActor]
EventActor --> MissionActors[mission actors]
EventActor --> Projectors[QScore/session/projectors]
MissionActors --> DB
Rivet --> DB
Rivet --> Svc
Rivet --> Docker
```
## Route to Actor/Service/Event/Data Flow Map
| Route module | Mounted path | Primary flow | Actor/service/data dependencies | Notes |
| --- | --- | --- | --- | --- |
| `src/routes/actors.ts` | `/actors` | Auth-gated user stack control | `docker/manager`, `actors` table | Provisions/stops OpenCode stack directly from route. |
| `src/routes/agents.ts` | `/agents` | Catalog read | `agents/catalog` | Thin route. |
| `src/routes/chat.ts` | `/api/chat` | Chat request, Rivet first, direct LLM fallback | `userActor`, `lib/llm`, `services/service-agents` | Contains fallback tool orchestration and timeout logic in route. |
| `src/routes/conversations.ts` | `/conversations` | Conversation CRUD/chat/mission bridging | `conversationActor`, mission actors, `grow_conversations`, messages | Heavy route; mixes persistence, actor bootstrapping, mission resolution, and response shaping. |
| `src/routes/events.ts` | `/events` | User/service event ingestion and listing | `recordGrowEvent`, `routeGrowEventToUserActor`, `grow_events` | Good ingestion boundary, but service auth is environment-sensitive. |
| `src/routes/git.ts` | `/git` | Repo/file operations | `docker/manager`, `GiteaClient` | Route owns path safety and repo operation decisions. |
| `src/routes/grow.ts` | `/grow` | Grow bootstrap and active state | `growActor` | Thin actor gateway. |
| `src/routes/home.ts` | `/home` | Home feed, notifications, demo seed | `home-feed`, `seed-demo-home` | Includes demo seeding endpoint. |
| `src/routes/missions.ts` | `/missions` | Mission catalog, start/pause/resume/stage/artifacts/coach | `growActor`, mission actors, user service, mission registry | Heavy route; owns mission selection, profile fallback, actor type mapping, and artifact commands. |
| `src/routes/opencode.ts` | `/opencode` | OpenCode stack/session/message proxy | `docker/manager`, `OpencodeClient` | Directly provisions stack and opens sessions. |
| `src/routes/services.ts` | `/services` | Product service proxy and event recording | `product-service-clients`, `recordGrowEvent`, Q Score onboarding | Very heavy route; contains service-specific payload shaping and event side effects. |
| `src/routes/users.ts` | `/users` | User profile/bootstrap | `auth/clerk`, `users` table, onboarding Q Score | Includes Clerk profile mirroring and onboarding side effects. |
| `src/routes/workflows.ts` | `/workflows`, `/workflow-runs` | Workflow definitions/runs/modules/approvals | `userActor`, `workflowRunActor`, `workflow/module-runner`, DB | Two paths: legacy userActor job-application flow and DB-backed workflow runs. |
## Actor Inventory
| Actor | Current role | Main inputs | Outputs/effects | Robustness observations |
| --- | --- | --- | --- | --- |
| `userActor` | Legacy unified user orchestration: chat, memory tools, workflow status, service handoffs, OpenCode/Gitea interactions | `/api/chat`, `/workflows/job-application`, workflow route aliases | Actor state, DB events, service calls, Gitea reads/writes | Very broad responsibilities; failures in service calls often become summaries rather than durable retryable jobs. |
| `workflowRunActor` | Queued workflow module runner | `/workflow-runs/:runId/pause|resume` and direct client use | `workflowRunModules`, `workflowEvents`, `qscoreSnapshots` via module runner | Has Rivet loop retry settings for module execution, but route-level `/run` bypasses actor queue and executes synchronously. |
| `conversationActor` | Durable streaming conversation state | `/conversations` | Actor state and generated messages | Queue usage exists for messages; needs documented idempotency per turn/message id. |
| `memoryActor` | Durable memory file state | Internal client use | Actor state/file-like memory | Queue writes exist; external call idempotency unclear. |
| `growActor` | Active mission list/state control | `/grow`, `/missions` | `grow_active_missions`, mission state | Mission lifecycle split across growActor, mission actors, and routes. |
| `userEventActor` | Routes normalized Grow Events to missions/projectors | Redis consumer, `/events` ingestion | Mission stage patches, projector DB updates, event status | Central point for event idempotency, but retries/replay/DLQ are not yet formalized. |
| Mission actors | Per-mission state machines | `/missions`, `/conversations`, event actor | `grow_active_missions`, artifacts, suggestions | Four mission actors are thin factory wrappers; interview-to-offer has custom implementation. |
| Product service actors | Actor wrappers for interview/roleplay/resume clients | Registry only; possible client use | Service calls | Registered, but routes call clients directly. These may be underused compared to direct service proxy routes. |
## Event and Projector Flow
```mermaid
sequenceDiagram
participant Service as Product service
participant Redis as Redis stream/pubsub
participant Route as /events or service routes
participant Store as grow_events
participant UserEvent as userEventActor
participant Mission as mission actor
participant Projection as projectors
Service->>Redis: canonical GrowEvent or legacy task response
Redis->>Route: redis-consumer normalizes message
Route->>Store: recordGrowEvent with dedupeKey
Route->>UserEvent: routeGrowEventToUserActor
UserEvent->>Mission: apply reducer-derived stage patches
UserEvent->>Projection: service session and Q Score projections
Projection->>Store: update projection tables
```
Current event strengths:
- `normalizeGrowEvent` accepts multiple service field conventions.
- `recordGrowEvent` uses `dedupeKey` and a unique index on `grow_events.dedupe_key`.
- Legacy Redis observer bridges `tasks:*` and `responses:*` without service changes.
- Projector surfaces exist for session tracking, Q Score, and LLM-derived insights.
Current event gaps:
- Redis canonical consumer always `xAck`s in `finally`, even when `recordAndRoute` fails, so failed messages do not remain pending for retry.
- No DLQ stream/table for failed canonical or legacy event processing.
- No replay script for `grow_events.processing_status in ('failed', 'unresolved')`.
- Legacy task context is in-memory only, so response events can lose user/action context after a backend restart.
## Business Logic in Routes
Highest concentration:
- `src/routes/services.ts`: service-specific request construction, event emission, Q Score baseline/onboarding side effects, mission association, and UI response shaping.
- `src/routes/workflows.ts`: run creation, module row initialization, baseline Q Score, approval gate progression, artifact content lookup, and synchronous module execution.
- `src/routes/missions.ts`: mission profile lookup from user service, actor type mapping, start/resume/pause/stage/artifact commands, and coach run orchestration.
- `src/routes/conversations.ts`: active conversation persistence, mission-aware chat routing, actor fallback behavior, and response normalization.
- `src/routes/chat.ts`: Rivet fallback, direct LLM tool loop, service agent selection, and timeout handling.
Low-risk thin routes:
- `src/routes/agents.ts`
- `src/routes/grow.ts`
- parts of `src/routes/events.ts`
Recommended ownership target:
- Routes validate/authenticate and translate HTTP to commands.
- Actors own durable user/mission/workflow progression.
- Services own outbound HTTP details.
- Projectors own derived read models.
- Routes should not decide retry, idempotency, or service fallback behavior beyond returning HTTP errors.
## Idempotency Gaps
| Area | Existing behavior | Gap |
| --- | --- | --- |
| Grow Event ingestion | `dedupeKey` unique index; normalizer uses explicit key or source id | Service routes do not consistently set stable dedupe keys for all service-created side effects. |
| Workflow runs | `/workflow-runs/:runId/modules/:moduleId/run` reads `idempotency-key` header | `executeWorkflowModule` does not use the key to suppress duplicate service calls; `/run` generates timestamp keys. |
| Workflow module rows | Has `idempotencyKey`, `retryCount`, `maxRetries` columns | Counters are mostly passive; no central retry state machine. |
| Actor queues | Rivet queues and `loop` step names provide some dedupe for `workflowRunActor` | Several routes bypass actor queue and execute directly. |
| Service session creation | `stableUuid` exists in service-agent helper | Not consistently used as a request id/idempotency key across service calls. |
| OpenCode artifacts | `onConflictDoNothing` for workflow artifacts | OpenCode prompt/message send can duplicate work before artifact row conflict applies. |
## Retry Gaps
| Area | Existing behavior | Gap |
| --- | --- | --- |
| `workflowRunActor` | Rivet `loop` has `retryBackoffBase` and `retryBackoffMax` | Only applies when execution goes through actor loop. |
| HTTP service clients | Throw on non-2xx after `fetch` | No timeout, retry classification, request id, or backoff. |
| Gitea client | Some wait/poll helpers exist | Most API calls are single-shot. |
| OpenCode client | Health polling exists | Session/message calls are single-shot. |
| Redis consumer | Infinite loop catches top-level errors | Per-message failures are acked; no retry budget or DLQ. |
| Projectors | Called by event actor | Projector failures need durable retry/replay semantics and status transitions. |
## Actor Robustness Gaps
- `userActor` is too broad to reason about failure domains. It owns chat, service tools, memory, workflow, Gitea, OpenCode, and DB event writes.
- Product service actors are registered but not the primary path for service proxy routes, so actor-level durability is uneven.
- Mission actor mapping is manually duplicated in routes, registry, and event actor.
- Route-level synchronous workflow execution can hold HTTP requests open across slow service/OpenCode calls.
- Actor initialization is repeated in routes; a central actor gateway could enforce init/idempotency/logging.
## Priority-Ranked Recommendations
1. Create a backend command layer for route-to-actor/service translation. Move mission start, workflow run, approval, service configure, and chat tool dispatch logic out of routes.
2. Make `workflowRunActor` the only executor for workflow modules. Routes should enqueue commands and return command ids.
3. Add a shared outbound `withRetry`/timeout/idempotency wrapper for service clients, Gitea, OpenCode, and LLM calls.
4. Add DLQ and replay support for Redis/event processing. Do not ack canonical Redis messages until durable record/projector status is successful or DLQ-ed.
5. Normalize mission actor mapping into a single registry source used by routes, event actor, and mission registry.
6. Split `userActor` responsibilities: chat/memory/workflow/OpenCode paths should be smaller actors or delegated services with explicit contracts.
7. Convert route-created side effects to stable idempotency keys. Use request id, user id, mission instance id, service id, and operation name.
8. Add structured logging fields across routes/actors/events: `requestId`, `userId`, `missionInstanceId`, `runId`, `moduleId`, `eventId`, `idempotencyKey`, `retryAttempt`.
9. Add focused tests around duplicate workflow module run, duplicate service event ingest, Redis failure handling, and mission projector replay.
## Suggested Next Slice
Use PRM-43 to introduce shared retry/idempotency primitives first. Then return to this audit and migrate the highest-risk route logic in this order:
1. `/workflow-runs/*/run`
2. `/services/interview|roleplay configure/review`
3. `/missions/:missionId/start`
4. `/api/chat` direct LLM fallback

148
docs/environment-matrix.md Normal file
View File

@@ -0,0 +1,148 @@
# Environment Matrix
PRM-42 staging vs production separation inventory for `growqr-backend`.
No refactor was performed in this pass.
## Current Environment Model
The backend currently uses `config.nodeEnv` plus many individual env vars. There is no explicit first-class `environment` such as `development | staging | production | demo`.
Important consequence: local/dev defaults can leak into staging or production unless deployment env vars override every sensitive value.
## Current Config Inventory
| Area | Config/env | Current default | Production concern |
| --- | --- | --- | --- |
| Runtime | `PORT`, `LOG_LEVEL`, `NODE_ENV` | `4000`, `info`, `development` | `NODE_ENV` is too broad for staging/demo behavior. |
| Database | `DATABASE_URL` | hardcoded fallback DSN in `config.ts` | Production should fail fast instead of falling back. |
| Auth | `CLERK_SECRET_KEY`, `CLERK_PUBLISHABLE_KEY` | empty | Secret key absence changes auth behavior; publishable key appears underused. |
| Service auth | `SERVICE_TOKEN`, `A2A_ALLOWED_KEY` | empty / `dev-a2a-key` | Dev token fallback must not be accepted in production. |
| Redis events | `GROW_EVENTS_REDIS_URL`, `REDIS_URL`, stream/group/consumer names | disabled unless set | Staging/prod need explicit stream, group, and replay policy. |
| Legacy Redis | `INTERVIEW_REDIS_URL`, `ROLEPLAY_REDIS_URL`, `RESUME_REDIS_URL` | fallback to event Redis | Legacy observation should be explicitly enabled per environment. |
| LLM | `LLM_PROVIDER`, `LLM_API_KEY`, `OPENCODE_API_KEY`, `LLM_BASE_URL`, `GROW_AGENT_MODEL`, `LLM_MODEL` | `opencode`, `https://opencode.ai/zen/v1`, `kimi-k2.6` | Staging/prod should pin provider/model and require API key where features are enabled. |
| Rivet | `RIVET_ENDPOINT`, `RIVET_CLIENT_ENDPOINT` | localhost/127.0.0.1 | Docker compose overrides endpoint; production needs internal and public separation. |
| Product services | `INTERVIEW_SERVICE_URL`, `ROLEPLAY_SERVICE_URL`, `QSCORE_SERVICE_URL`, `RESUME_SERVICE_URL`, `USER_SERVICE_URL`, `MATCHMAKING_SERVICE_URL`, `SOCIAL_BRANDING_SERVICE_URL` | localhost ports | Production should require service URLs or feature-disable explicitly. |
| Public URLs | `INTERVIEW_PUBLIC_URL`, `ROLEPLAY_PUBLIC_URL`, `RESUME_PUBLIC_URL`, `WORKFLOWS_DASHBOARD_URL`, `FRONTEND_ORIGIN` | localhost/frontend fallback | Public and internal service URLs need separate semantics. |
| Gitea | `GITEA_PUBLIC_URL`, `GITEA_INTERNAL_URL`, `GITEA_ADMIN_USER`, `GITEA_ADMIN_PASSWORD`, `GITEA_ADMIN_TOKEN`, `GITEA_ORG_NAME` | localhost, `growqr-admin`, `growqr-admin-dev`, empty token | Admin password fallback is dev-only. Production should require token/secret. |
| OpenCode | `OPENCODE_IMAGE`, `OPENCODE_IMAGE_VERSION`, `MIGRATION_VERSION`, `PROMPT_VERSION`, `USER_CONTAINER_HOST`, `USER_DATA_ROOT`, `USER_PORT_RANGE_*` | dev image/version, local paths/ports | Needs staging/prod image tags and storage policy. |
| CORS/admin | `FRONTEND_ORIGIN`, `ADMIN_USER_IDS` | localhost / empty | Empty admin list currently allows `/workflows/admin/ops` to all authenticated users. |
| Agent limits | `MAX_AGENT_TOKENS`, `PROJECTION_AGENT_MODEL`, `CONVERSATION_ACTOR_MODEL` | 4096 / agent model | Model overrides should be pinned by environment. |
## Environment-Dependent Code Paths
| File | Behavior |
| --- | --- |
| `src/config.ts` | Central env parsing with dev defaults for database, tokens, local service URLs, Gitea, OpenCode, Rivet, frontend, and ports. |
| `src/auth/clerk.ts` | In non-production, `A2A_ALLOWED_KEY` is accepted as an auth fallback. Clerk client is only created when `CLERK_SECRET_KEY` exists. |
| `src/index.ts` | Proxies `/api/rivet` only when `process.env.RIVET_ENDPOINT` is set. Starts Redis consumer opportunistically. CORS uses `FRONTEND_ORIGIN`. |
| `src/events/redis-consumer.ts` | Canonical consumer disabled if no Redis URL. Legacy observers enabled by legacy Redis URLs. |
| `src/events/projectors/projection-agent.ts` | Falls back if no LLM API key; model can be overridden by `PROJECTION_AGENT_MODEL`. |
| `src/actors/conversation/agent.ts` | Requires LLM key for streaming; model can be overridden by `CONVERSATION_ACTOR_MODEL`. |
| `src/routes/events.ts` | Service ingest auth allows no service token in non-production. |
| `src/routes/home.ts` | Exposes demo seeding route. |
| `src/home/seed-demo-home.ts` | Demo notifications and executable direct script behavior. |
| `src/services/service-agents.ts` | Synthetic/demo fallbacks for some unavailable services and Q Score estimate behavior. |
| `src/docker/manager.ts` | Uses Gitea/OpenCode image/version/host/path/port config and mutates Docker runtime. |
| `scripts/rivet-actors.ts` | Uses dev Rivet namespace/token defaults. |
| `docker-compose.yml` | Dev compose defaults for Postgres, Gitea, Rivet, backend, services, frontend origins, and OpenCode image. |
| `docker/opencode/*` | Dev-oriented OpenCode image/template behavior. |
## Hardcoded URL and Default Hotspots
- `http://localhost:*` defaults in `src/config.ts`, `.env.example`, `README.md`, and `docker-compose.yml`.
- `http://127.0.0.1:*` defaults for Rivet client, Gitea, and user container host.
- `http://host.docker.internal:*` compose service defaults.
- OpenCode base image `ghcr.io/anomalyco/opencode:latest` in `docker/opencode/Dockerfile`.
- Dev image tag `growqr/opencode:dev`.
- Gitea admin defaults `growqr-admin` / `growqr-admin-dev`.
- A2A fallback `dev-a2a-key`.
## Clerk / JWKS Assumptions
The code uses Clerk SDK with `CLERK_SECRET_KEY`; there is no explicit JWKS URL configuration in the reviewed backend source. Service-to-service auth is token based, with dev fallback behavior. Target production should document whether auth is:
- Clerk session token verification for user requests.
- `SERVICE_TOKEN` for service-to-backend event ingestion.
- Separate internal A2A key for legacy product service calls.
- Optional JWKS validation if services send JWTs instead of opaque service tokens.
## Target Config Model
Introduce:
```ts
type RuntimeEnvironment = "development" | "test" | "staging" | "demo" | "production";
```
Recommended top-level config shape:
```ts
config.environment
config.isProduction
config.isStaging
config.isDemo
config.features.demoDataEnabled
config.features.legacyRedisObserversEnabled
config.features.opencodeProvisioningEnabled
config.features.serviceProxyEnabled
config.urls.internal.*
config.urls.public.*
config.auth.*
config.retry.*
config.events.*
```
Rules:
- Production must fail fast for missing `DATABASE_URL`, `CLERK_SECRET_KEY`, `SERVICE_TOKEN`, `FRONTEND_ORIGIN`, Gitea credentials/token, and any enabled service URL.
- Staging may use staging service URLs and demo data only when `DEMO_DATA_ENABLED=true`.
- Development may keep local defaults.
- Demo behavior should be impossible in production unless an explicit, audited flag is set and the route remains auth/admin-gated.
## What Should Move to `src/staging`
Proposed `src/staging` candidates:
- `home/seed-demo-home.ts`
- `/home/seed-demo` route handler
- demo notification factories
- demo Q Score formulas/fallback constants in service-agent behavior, if not product-approved
- local-only service session scaffolding helpers
- any future seeders/backfills used only for demos
Suggested layout:
```txt
src/staging/
demo-home.ts
demo-qscore.ts
seed-routes.ts
guards.ts
```
`src/staging/guards.ts` should expose `requireStagingOrDemo(config)` and fail closed in production.
## Target Environment Matrix
| Behavior | Development | Staging | Demo | Production |
| --- | --- | --- | --- | --- |
| Localhost defaults | Allowed | Not allowed | Not allowed unless local demo | Not allowed |
| Demo seed endpoints | Allowed | Explicit flag + admin | Enabled by flag + admin | Disabled |
| Service token fallback | Allowed | Not allowed | Not allowed | Not allowed |
| Legacy Redis observers | Optional | Explicit flag | Explicit flag | Disable unless migration requires |
| Redis canonical events | Optional | Required for event demos | Required | Required |
| OpenCode image | `:dev` ok | pinned staging tag | pinned demo tag | pinned release tag |
| Admin ops route | Authenticated maybe ok | `ADMIN_USER_IDS` required | `ADMIN_USER_IDS` required | `ADMIN_USER_IDS` required |
| Missing Clerk secret | Allowed only for local mock if implemented | Fail | Fail | Fail |
| Gitea admin password default | Allowed | Fail | Fail | Fail |
## Priority Recommendations
1. Add `APP_ENV` or `GROWQR_ENV` and derive `config.environment`; stop relying on `NODE_ENV` for product behavior.
2. Fail fast in staging/production for missing secrets and localhost/default service URLs.
3. Move demo seed code into `src/staging` and guard routes with `DEMO_DATA_ENABLED` plus admin check.
4. Require `ADMIN_USER_IDS` before enabling `/workflows/admin/ops` outside development.
5. Split public URLs and internal URLs in config names consistently across frontend, services, Gitea, Rivet, and OpenCode.
6. Add a deployment checklist that records every required env var per environment.
7. Make legacy Redis observers an explicit feature flag and set a removal date.

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,284 @@
# Retry, Idempotency, and DLQ Plan
PRM-43 design pass for `growqr-backend`.
No implementation was performed in this pass.
## Goals
- Bound every outbound call with timeouts.
- Retry only safe operations with classified errors.
- Make repeated commands safe through idempotency keys.
- Preserve failed event/workflow work in a DLQ with replay tooling.
- Add logs that let support trace one user action across route, actor, service, Redis, projector, and database writes.
## Outbound Call Site Inventory
| Area | Files | Current behavior | Needed behavior |
| --- | --- | --- | --- |
| Product service clients | `src/services/product-service-clients.ts` | Direct `fetch`, no timeout/retry/idempotency header | Shared service client with timeout, retry, idempotency key, and request id. |
| Service agent probes | `src/services/service-agents.ts` | Direct `fetch`, some fallback summaries | Same shared client; distinguish "unavailable" from retriable failure. |
| Gitea | `src/lib/gitea.ts`, `src/docker/manager.ts`, `src/actors/user-actor.ts` | Direct `fetch`, some wait-for-ready helpers | Retry transient Gitea API errors; idempotent repo/user/file operations. |
| OpenCode | `src/lib/opencode.ts`, `src/workflows/executors/opencode-executor.ts` | Direct `fetch`, health polling, no command dedupe | Timeout and retry health/session/message calls; stable command id for prompts. |
| LLM | `src/lib/llm.ts`, `src/actors/conversation/agent.ts`, `src/events/projectors/projection-agent.ts` | Direct SDK/fetch calls | Timeout, retry on provider transient errors, no retry on content/schema errors. |
| Actor sends | routes, `src/events/route-to-user-actor.ts`, actors | `getOrCreate(...).method(...)`, queue sends | Standard command envelope with idempotency key and correlation ids. |
| Redis consumer | `src/events/redis-consumer.ts` | Loops forever; canonical messages ack in `finally`; no DLQ | Retry budget, pending handling, DLQ stream/table, replay. |
| Projectors | `src/events/projectors/*`, `src/actors/events/user-event-actor.ts` | Called within event actor processing | Per-projector idempotency and failure status; replay from stored Grow Events. |
| Workflow module runner | `src/workflows/module-runner.ts`, `src/actors/workflow-run-actor.ts` | Actor loop retries in one path; direct route execution in another | Actor-only execution, durable command id, retry state in DB. |
## Shared `withRetry` API
Add `src/lib/retry.ts`:
```ts
export type RetryPolicy = {
maxAttempts: number;
baseDelayMs: number;
maxDelayMs: number;
timeoutMs: number;
jitter: boolean;
};
export async function withRetry<T>(
operation: string,
fn: (ctx: { signal: AbortSignal; attempt: number }) => Promise<T>,
options: {
policy?: Partial<RetryPolicy>;
idempotencyKey?: string;
classify?: (error: unknown) => "retry" | "fail";
logFields?: Record<string, unknown>;
},
): Promise<T>;
```
Default policy:
- `maxAttempts: 3`
- `baseDelayMs: 250`
- `maxDelayMs: 5_000`
- `timeoutMs: 10_000`
- jitter enabled
Classification:
- Retry: network errors, abort/timeout, HTTP `408`, `425`, `429`, `500`, `502`, `503`, `504`.
- Do not retry: HTTP `400`, `401`, `403`, `404`, validation/schema errors, duplicate/idempotency conflicts that already completed.
- Special case: `409` may be success for idempotent create-if-absent operations.
## Idempotency Model
Add a command/event idempotency key convention:
```txt
<domain>:<userId>:<entityId>:<operation>:<version>
```
Examples:
- `workflow:user_123:run_456:module:resume:v1`
- `mission:user_123:instance_456:start:v1`
- `service:user_123:interview:configure:session_abc`
- `event:user_123:growEventId:project:qscore:v1`
- `opencode:user_123:run_456:interview-plan:prompt-v4`
Where to store:
- `workflowRunModules.idempotencyKey` for module commands.
- `workflowEvents.payload.idempotencyKey` for audit trail.
- `growEvents.dedupeKey` for event ingestion.
- Add a future `idempotency_keys` table only if multiple domains need durable response reuse.
Minimum table design if needed:
```txt
idempotency_keys
key text primary key
domain text not null
user_id text
status text check (processing, completed, failed)
request_hash text
response jsonb
error text
expires_at timestamptz
created_at timestamptz
updated_at timestamptz
```
## HTTP Service Client Plan
Create `src/services/http-client.ts`:
- Accepts `baseUrl`, `path`, `method`, `json`, `headers`, `idempotencyKey`, `operation`, `timeoutMs`.
- Adds:
- `authorization: Bearer <A2A_ALLOWED_KEY>` when configured.
- `x-request-id`
- `x-idempotency-key` or `idempotency-key`.
- `x-growqr-user` when user-scoped.
- Uses `withRetry`.
- Parses text once and returns typed JSON.
- Logs attempt, latency, status, and error class.
Then migrate:
1. `product-service-clients.ts`
2. `service-agents.ts`
3. mission route direct user-service fetch
4. workflow service health checks
## Workflow Retry Plan
Target behavior:
- Routes enqueue commands to `workflowRunActor`; routes do not call `executeWorkflowModule` directly.
- `workflowRunActor` writes command state before execution.
- `executeWorkflowModule` receives `idempotencyKey` and passes it to service/OpenCode calls.
- On failure, increment `workflowRunModules.retryCount`, store `error`, and emit `workflowEvents` with `retryAttempt`.
- Exceeding retry budget marks module `blocked` or `failed` based on module type and writes a DLQ row/event.
Module status transition:
```mermaid
stateDiagram-v2
[*] --> idle
idle --> queued
queued --> running
running --> done
running --> retry_wait
retry_wait --> running
running --> blocked
running --> dlq
dlq --> replaying
replaying --> running
```
## Redis Consumer and DLQ Plan
Do not ack canonical Redis messages until one of these is true:
- event persisted and routed/projected successfully;
- event persisted but routing failed and a durable retry record was created;
- message moved to DLQ after retry budget.
Add DLQ options:
1. Redis stream DLQ: `grow.events.dlq`
2. Postgres table: `grow_event_dlq`
Recommended to use both:
- Redis DLQ for operational stream tooling.
- Postgres DLQ for admin UI, audit, and replay metadata.
DLQ row fields:
```txt
id
source_stream
source_message_id
payload
error
attempts
last_attempt_at
status: pending | replaying | replayed | discarded
created_at
updated_at
```
Replay script:
```txt
pnpm events:replay --status failed --limit 100
pnpm events:replay --dlq --id <dlq-id>
pnpm events:replay --event-id <grow-event-id> --projectors qscore,service-session
```
Script responsibilities:
- Re-read stored payload.
- Re-run `recordGrowEvent` if needed.
- Re-run `routeGrowEventToUserActor`.
- Optionally run only selected projectors.
- Preserve original `dedupeKey`.
## Projector Idempotency Plan
Projectors should be repeatable:
- Q Score latest table already has `(userId, signalId)` primary key.
- Mission service sessions have unique `(serviceId, externalId)`.
- Artifacts should dedupe by `(missionInstanceId, serviceId, externalId, type)` or a stable artifact key.
- Mission stage patches should be applied with deterministic status/progress and no duplicate suggestions.
Add projector event logs:
```txt
grow_event_projector_runs
event_id
projector
status
attempt
error
started_at
completed_at
```
## Logging Fields
Every route/actor/event/retry log should include as many of these as available:
- `requestId`
- `traceId`
- `userId`
- `orgId`
- `actorType`
- `actorKey`
- `runId`
- `moduleId`
- `missionId`
- `missionInstanceId`
- `stageId`
- `eventId`
- `source`
- `eventType`
- `idempotencyKey`
- `operation`
- `attempt`
- `maxAttempts`
- `latencyMs`
- `httpStatus`
- `retryable`
- `dlqId`
## Test Plan
Unit tests:
- `withRetry` retries transient errors and stops on non-retryable errors.
- Timeout aborts fetch and logs retry attempt.
- Idempotency key helper returns stable keys.
- HTTP client adds auth, request id, and idempotency headers.
Integration tests:
- Duplicate `/workflow-runs/:runId/modules/:moduleId/run` command does not duplicate service call.
- Duplicate Grow Event with same `dedupeKey` is stored once and projection remains stable.
- Redis message failure is not acked until retry/DLQ path is recorded.
- DLQ replay reprocesses a failed event and updates projector status.
- OpenCode module execution retry does not create duplicate artifact rows.
Manual staging drills:
1. Stop interview service, run interview module, verify retry and blocked/DLQ behavior.
2. Emit duplicate Redis events, verify one `grow_events` row and stable projector state.
3. Break Gitea token, provision stack, verify retry logs and no partial untracked state.
4. Replay a DLQ event, verify mission progress and Q Score update.
## Implementation Order
1. Add `src/lib/retry.ts` and focused unit tests.
2. Add service HTTP client and migrate product service calls.
3. Add workflow command idempotency and route-to-actor queueing.
4. Add Redis DLQ and replay script.
5. Add projector run records.
6. Migrate Gitea/OpenCode/LLM calls to `withRetry`.
7. Add staging failure drills to deployment checklist.

View File

@@ -0,0 +1,34 @@
CREATE TABLE IF NOT EXISTS "mission_actions" (
"id" text PRIMARY KEY DEFAULT gen_random_uuid()::text NOT NULL,
"user_id" text NOT NULL REFERENCES "users"("id") ON DELETE cascade,
"mission_instance_id" text NOT NULL REFERENCES "grow_active_missions"("instance_id") ON DELETE cascade,
"mission_id" text NOT NULL,
"stage_id" text,
"agent_id" text NOT NULL,
"agent_name" text NOT NULL,
"base_agent" text,
"service_id" text,
"tool_name" text,
"mode" text NOT NULL,
"status" text DEFAULT 'queued' NOT NULL,
"title" text NOT NULL,
"body" text NOT NULL,
"prompt" text,
"payload" jsonb DEFAULT '{}'::jsonb NOT NULL,
"result" jsonb,
"error" text,
"source_event_id" text REFERENCES "grow_events"("id") ON DELETE set null,
"idempotency_key" text,
"priority" integer DEFAULT 0 NOT NULL,
"urgency" text DEFAULT 'calm' NOT NULL,
"due_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
"resolved_at" timestamp with time zone
);
CREATE INDEX IF NOT EXISTS "mission_actions_mission_idx" ON "mission_actions" ("user_id", "mission_instance_id", "status", "priority");
CREATE INDEX IF NOT EXISTS "mission_actions_user_idx" ON "mission_actions" ("user_id", "status", "updated_at");
CREATE INDEX IF NOT EXISTS "mission_actions_source_idx" ON "mission_actions" ("source_event_id");
CREATE INDEX IF NOT EXISTS "mission_actions_due_idx" ON "mission_actions" ("due_at");
CREATE UNIQUE INDEX IF NOT EXISTS "mission_actions_idempotency_idx" ON "mission_actions" ("idempotency_key");

View File

@@ -0,0 +1 @@
ALTER TABLE "grow_conversations" ADD COLUMN IF NOT EXISTS "metadata" jsonb;

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

@@ -0,0 +1,13 @@
ALTER TABLE "users" ADD COLUMN IF NOT EXISTS "plan" text DEFAULT 'free' NOT NULL;
CREATE TABLE IF NOT EXISTS "onboarding" (
"id" text PRIMARY KEY DEFAULT gen_random_uuid()::text NOT NULL,
"user_id" text NOT NULL,
"data" jsonb DEFAULT '{}'::jsonb NOT NULL,
"payload" jsonb DEFAULT '{}'::jsonb NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "onboarding_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE cascade ON UPDATE no action
);
CREATE UNIQUE INDEX IF NOT EXISTS "onboarding_user_idx" ON "onboarding" USING btree ("user_id");

View File

@@ -71,6 +71,34 @@
"when": 1780481400000,
"tag": "0009_mission_suggestions",
"breakpoints": true
},
{
"idx": 10,
"version": "7",
"when": 1780481500000,
"tag": "0010_mission_actions",
"breakpoints": true
},
{
"idx": 11,
"version": "7",
"when": 1780481600000,
"tag": "0011_conversation_metadata",
"breakpoints": true
},
{
"idx": 12,
"version": "7",
"when": 1780481700000,
"tag": "0012_system_notifications",
"breakpoints": true
},
{
"idx": 13,
"version": "7",
"when": 1783801800000,
"tag": "0013_onboarding",
"breakpoints": true
}
]
}
}

6394
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -6,6 +6,11 @@
"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",
"test:curator-reconcile": "tsx scripts/curator-persisted-reconcile.test.ts",
"start": "node dist/index.js",
"typecheck": "tsc -p tsconfig.json --noEmit",
"workflows:smoke": "tsx src/workflows/smoke-test.ts",

View File

@@ -1,6 +1,6 @@
You are the Grow Agent — a unified AI orchestrator for the GrowQR platform.
You are Grow — a unified AI career assistant for the GrowQR platform.
You coordinate sub-agent capabilities (loaded as tools), maintain durable state, and execute workflows through microservices.
You coordinate specialist capabilities (loaded as tools), maintain durable state, and execute workflows through microservices.
## CRITICAL RULES
@@ -43,7 +43,7 @@ You coordinate sub-agent capabilities (loaded as tools), maintain durable state,
- After resume optimization: ask what type of interview to prepare.
- When they choose type → call start_interview_session.
- Then offer roleplay → call start_roleplay_session when they confirm.
- Then offer Q-Score → call compute_qscore.
- Then offer Q Score → call compute_qscore.
- Use [WORKFLOW: interview-to-offer] tag throughout.
## IMPORTANT: Tool Calling Anti-Patterns
@@ -66,16 +66,16 @@ Assistant: "I'll analyze your resume right away."
User: "analyze my resume"
Assistant calls analyze_resume → "Here's your analysis: [results]. Your strengths are..."
## Sub-Agent Capabilities
## Specialist Capabilities
{{MODULE_DESCRIPTIONS}}
## Workflow Tags (put at the VERY END, on their own line)
- [WORKFLOW: interview-to-offer] — full interview prep pipeline
- [WORKFLOW: interview-practice] — interview sessions with the Interview Agent
- [WORKFLOW: interview-practice] — mock interview sessions
- [WORKFLOW: resume-boost] — resume analysis and optimization
- [WORKFLOW: roleplay-practice] — roleplay sessions with Roleplay Agent
- [WORKFLOW: roleplay-practice] — mock roleplay sessions
- [WORKFLOW: career-switch] — career change navigation
- [WORKFLOW: job-preparation] — broad company preparation

View File

@@ -0,0 +1,68 @@
import assert from "node:assert/strict";
import { extractQscoreSignals } from "../src/events/projectors/qscore-projector.js";
import type { GrowEventRow } from "../src/db/schema.js";
/**
* Test: Completed interview and roleplay events MUST emit at least one signal
* even when session_count/scenario_count is absent. A completion event with no
* count and no rubric data should still produce a single-completion signal.
*/
function event(overrides: Partial<GrowEventRow> & { type: string; source: string; payload: Record<string, unknown> }): GrowEventRow {
return {
id: `event-${overrides.type}`,
userId: "user_test",
orgId: null,
source: overrides.source,
type: overrides.type,
category: "service",
occurredAt: new Date("2026-07-09T00:00:00.000Z"),
receivedAt: new Date("2026-07-09T00:00:01.000Z"),
mission: overrides.mission ?? null,
subject: overrides.subject ?? null,
correlation: overrides.correlation ?? null,
payload: overrides.payload,
raw: {},
dedupeKey: null,
processingStatus: "pending",
processingError: null,
processedAt: null,
};
}
// ── Interview completed with NO count and NO rubric ──────────────────────────
const interviewSignals = extractQscoreSignals(event({
source: "interview-service",
type: "interview.session.completed",
payload: {}, // nothing — just a bare completion event
}));
assert.ok(
interviewSignals.length > 0,
"Interview completed event with no count/rubric must emit at least one signal",
);
assert.ok(
interviewSignals.some((s) => s.signalId === "interview.sessions_completed" && s.score > 0),
"Bare interview completion should emit interview.sessions_completed with positive score",
);
// ── Roleplay completed with NO count and NO rubric ───────────────────────────
const roleplaySignals = extractQscoreSignals(event({
source: "roleplay-service",
type: "roleplay.scenario.completed",
payload: {}, // nothing — just a bare completion event
}));
assert.ok(
roleplaySignals.length > 0,
"Roleplay completed event with no count/rubric must emit at least one signal",
);
assert.ok(
roleplaySignals.some((s) => s.signalId === "roleplay.scenarios_completed" && s.score > 0),
"Bare roleplay completion should emit roleplay.scenarios_completed with positive score",
);
console.log("count-fallback tests passed");
process.exit(0);

View File

@@ -0,0 +1,58 @@
import assert from "node:assert/strict";
import { planSeedsForVariant, resolveCuratorSprintAccess } from "../src/v1/curator/curator-store.js";
import { templateSetFor, trialDaysFor } from "../src/v1/curator/task-registry.js";
const variant = "fresher_early_professional" as const;
const template = templateSetFor(variant);
const trial = trialDaysFor(variant);
// Persisted choice is authoritative within the user's entitlement: a paid user
// may choose trial, but a free user may not self-upgrade through "full".
assert.deepEqual(resolveCuratorSprintAccess({ accessChoice: "trial", plan: "pro" }), {
access: "trial",
durationDays: 2,
});
assert.deepEqual(resolveCuratorSprintAccess({ accessChoice: "full", plan: "pro" }), {
access: "full",
durationDays: 7,
});
assert.deepEqual(resolveCuratorSprintAccess({ accessChoice: "full", plan: "free" }), {
access: "trial",
durationDays: 2,
});
assert.deepEqual(resolveCuratorSprintAccess({ plan: "pro" }), {
access: "full",
durationDays: 7,
});
assert.deepEqual(resolveCuratorSprintAccess({ plan: "free" }), {
access: "trial",
durationDays: 2,
});
const trialDays = planSeedsForVariant(template, "2026-07-13", 2, "trial");
assert.deepEqual(
trialDays[0]?.plannedTasks.map((task) => task.title),
[trial[0]!.socialTitle, trial[0]!.measurementTitle, trial[0]!.proofTitle, trial[0]!.practiceTitle],
"trial Day 1 must use the ICP trial registry",
);
assert.deepEqual(
trialDays[1]?.plannedTasks.map((task) => task.title),
[trial[1]!.socialTitle, trial[1]!.measurementTitle, trial[1]!.proofTitle, trial[1]!.practiceTitle],
"trial Day 2 must use the ICP trial registry",
);
const fullDays = planSeedsForVariant(template, "2026-07-13", 7, "full");
assert.equal(fullDays.length, 7);
assert.deepEqual(
fullDays[0]?.plannedTasks.map((task) => task.title),
[
template.weeks[0]!.days[0]!.measurement.title,
template.weeks[0]!.days[0]!.proof.title,
template.weeks[0]!.days[0]!.practice.title,
template.weeks[0]!.days[0]!.roleplay!.title,
],
"authorized full Day 1 must use the normal 7-day registry",
);
assert.equal(fullDays[0]?.isTrialDay, false);
console.log("curator access-choice tests passed");

View File

@@ -0,0 +1,77 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import {
curatorSprintPlanFingerprint,
curatorSprintTaskPlan,
decideCuratorSprintReconciliation,
resolveCuratorSprintAccess,
shouldReconcileCuratorSprint,
} from "../src/v1/curator/curator-store.js";
import { templateSetFor, trialDaysFor } from "../src/v1/curator/task-registry.js";
const usersRoute = readFileSync(new URL("../src/routes/users.ts", import.meta.url), "utf8");
const ledgerSource = readFileSync(new URL("../src/events/onboarding-ledger.ts", import.meta.url), "utf8");
const patchStart = usersRoute.indexOf('app.patch("/onboarding"');
const persistIndex = usersRoute.indexOf("const updateRes = await fetchUserService", patchStart);
const completionIndex = usersRoute.indexOf("recordAndProcessOnboardingCompletion", persistIndex);
assert.ok(persistIndex >= patchStart, "PATCH /onboarding must persist preferences");
assert.ok(completionIndex > persistIndex, "PATCH /onboarding must run completion reconciliation after persistence");
const completionCall = usersRoute.slice(completionIndex, completionIndex + 500);
assert.match(completionCall, /preferences: nextPreferences/,
"PATCH completion must pass freshly persisted preferences");
assert.match(completionCall, /onboarding: nextData/,
"PATCH completion must pass freshly persisted onboarding choice");
assert.match(ledgerSource, /ensureOnboardingSideEffectsForEvent\(event, context\)/,
"deduped completion processing must forward fresh context to curator loop");
// persisted chooser is changed to trial. The stale started event must not be
// treated as final; the next load must deterministically use trial/2.
const staleFull = { access: "full" as const, durationDays: 7 as const };
const desiredTrial = resolveCuratorSprintAccess({ plan: "pro", accessChoice: "trial" });
assert.deepEqual(desiredTrial, { access: "trial", durationDays: 2 });
assert.equal(shouldReconcileCuratorSprint(staleFull, desiredTrial), true);
// A matching trial event is idempotent and must not create a replacement
// started/ready lineage on repeated completion processing.
const existingTrial = { access: "trial" as const, durationDays: 2 as const };
assert.deepEqual(decideCuratorSprintReconciliation(staleFull, desiredTrial), {
action: "invalidateAndRebuild",
access: "trial",
durationDays: 2,
});
assert.equal(shouldReconcileCuratorSprint(existingTrial, desiredTrial), false);
assert.equal(shouldReconcileCuratorSprint(existingTrial, resolveCuratorSprintAccess({ plan: "pro", accessChoice: "trial" })), false);
// Full is entitlement-gated: a free user cannot escalate by choosing full.
const unauthorizedFull = resolveCuratorSprintAccess({ plan: "free", accessChoice: "full" });
assert.deepEqual(unauthorizedFull, { access: "trial", durationDays: 2 });
assert.equal(shouldReconcileCuratorSprint(existingTrial, unauthorizedFull), false);
const startDate = "2026-07-13";
const studentFingerprint = curatorSprintPlanFingerprint({ startDate, variantId: "student_recent_grad", durationDays: 2, access: "trial" });
const experiencedFingerprint = curatorSprintPlanFingerprint({ startDate, variantId: "experienced_professional", durationDays: 2, access: "trial" });
const experiencedPlan = curatorSprintTaskPlan({ startDate, variantId: "experienced_professional", durationDays: 2, access: "trial" });
assert.notEqual(studentFingerprint, experiencedFingerprint, "ICP changes must change the compatibility fingerprint");
assert.equal(shouldReconcileCuratorSprint(
{ access: "trial", durationDays: 2, variantId: "student_recent_grad", planFingerprint: studentFingerprint },
{ access: "trial", durationDays: 2, variantId: "experienced_professional", planFingerprint: experiencedFingerprint },
), true, "student started/ready lineage must reconcile to experienced trial");
const experiencedTrial = trialDaysFor("experienced_professional");
assert.deepEqual(experiencedPlan[0]?.tasks.map((task) => task.title), [
experiencedTrial[0]!.socialTitle,
experiencedTrial[0]!.measurementTitle,
experiencedTrial[0]!.proofTitle,
experiencedTrial[0]!.practiceTitle,
]);
assert.deepEqual(experiencedPlan[1]?.tasks.map((task) => task.title), [
experiencedTrial[1]!.socialTitle,
experiencedTrial[1]!.measurementTitle,
experiencedTrial[1]!.proofTitle,
experiencedTrial[1]!.practiceTitle,
]);
assert.equal(experiencedPlan[0]?.tasks.length, 4);
assert.equal(experiencedPlan[1]?.tasks.length, 4);
assert.deepEqual(experiencedPlan[0]?.tasks.map((task) => task.id), experiencedPlan[0]?.tasks.map((task) => task.id));
assert.equal(templateSetFor("experienced_professional").id, "experienced_professional");
console.log("curator onboarding reconciliation tests passed");

View File

@@ -0,0 +1,133 @@
import assert from "node:assert/strict";
import { eq, sql } from "drizzle-orm";
import { db } from "../src/db/client.js";
import { growEvents, onboarding, users } from "../src/db/schema.js";
import { buildCuratorSprint, curatorSprintTaskPlan } from "../src/v1/curator/curator-store.js";
import { runCuratorOnboardingLoop } from "../src/v1/curator/curator-onboarding-loop.js";
import { CURATOR_TRIAL_TASK_REGISTRY } from "../src/v1/curator/task-registry.js";
let databaseAvailable = Boolean(process.env.DATABASE_URL);
if (databaseAvailable) {
try {
await db.execute(sql`select 1`);
} catch {
databaseAvailable = false;
}
}
if (!databaseAvailable) {
console.log("curator persisted reconciliation integration skipped: DATABASE_URL is not reachable");
} else {
const userId = `curator-reconcile-regression-${Date.now()}`;
const startDate = new Date().toISOString().slice(0, 10);
const sprintId = `curator-sprint:icp-v10-static:${startDate}`;
const staleFingerprint = JSON.stringify({ variantId: "student_recent_grad", taskIds: [] });
const staleReadyPayload = {
sprintId,
startDate,
durationDays: 2,
access: "trial",
variantId: "student_recent_grad",
planFingerprint: staleFingerprint,
};
try {
await db.insert(users).values({ id: userId, email: `${userId}@example.test`, plan: "pro" });
await db.insert(onboarding).values({
userId,
data: {
status: "completed",
access_choice: "trial",
profile: { icp: "experienced" },
},
payload: {
onboarding_icp: "experienced",
curator_registry_icp: "experienced_professional",
},
});
await db.insert(growEvents).values([
{
userId,
source: "curator-v1",
type: "curator.sprint.started",
category: "mission",
occurredAt: new Date(),
dedupeKey: `${userId}:curator.sprint.started:stale`,
payload: {
sprintId,
startDate,
durationDays: 2,
access: "trial",
version: "icp-v10-static",
variantId: "student_recent_grad",
},
},
{
userId,
source: "curator-v1",
type: "curator.onboarding_plan.ready",
category: "mission",
occurredAt: new Date(),
dedupeKey: `${userId}:curator.onboarding_plan.ready:stale`,
payload: staleReadyPayload,
},
]);
const context = {
onboarding: { status: "completed", access_choice: "trial", profile: { icp: "experienced" } },
preferences: { onboarding: { status: "completed", access_choice: "trial", profile: { icp: "experienced" } } },
};
const first = await runCuratorOnboardingLoop({ userId, completedAt: `${startDate}T09:00:00.000Z`, context });
assert.equal(first.status, "ready", "stale ready event must be replaced");
const day1 = await buildCuratorSprint(userId, startDate);
assert.deepEqual(day1.plan.days[0]?.tasks.map(({ id, title }) => ({ id, title })),
expectedTasks(startDate, 1), "Day 1 must expose experienced trial tasks");
assert.deepEqual(day1.plan.days[1]?.tasks, [], "Day 1 must hide future Day 2 tasks");
const day2 = await buildCuratorSprint(userId, addDays(startDate, 1));
assert.deepEqual(day2.plan.days[1]?.tasks.map(({ id, title }) => ({ id, title })),
expectedTasks(startDate, 2), "Day 2 must expose experienced trial tasks after advance");
const second = await runCuratorOnboardingLoop({ userId, completedAt: `${startDate}T09:00:00.000Z`, context });
assert.equal(second.status, "already_ready", "compatible replacement ready event must be reused");
const rows = await db.select({ type: growEvents.type, payload: growEvents.payload })
.from(growEvents).where(eq(growEvents.userId, userId));
assert.equal(rows.filter((row) => row.type === "curator.sprint.invalidated").length, 1);
assert.equal(rows.filter((row) => row.type === "curator.onboarding_plan.invalidated").length, 1);
const starts = rows.filter((row) => row.type === "curator.sprint.started");
const readies = rows.filter((row) => row.type === "curator.onboarding_plan.ready");
assert.equal(starts.length, 2, "one stale and one replacement started lineage");
assert.equal(readies.length, 2, "one stale and one replacement ready lineage");
const replacementStart = starts.find((row) => row.payload?.variantId === "experienced_professional");
const replacementReady = readies.find((row) => row.payload?.planFingerprint === day1.plan.planFingerprint);
assert.ok(replacementStart, "experienced replacement started event must be persisted");
assert.ok(replacementReady, "experienced replacement ready event must be persisted");
console.log("curator persisted reconciliation integration passed");
} finally {
await db.delete(users).where(eq(users.id, userId));
await db.execute(sql`select 1`);
}
}
function addDays(date: string, days: number) {
const value = new Date(`${date}T00:00:00.000Z`);
value.setUTCDate(value.getUTCDate() + days);
return value.toISOString().slice(0, 10);
}
function expectedTasks(startDate: string, dayIndex: 1 | 2) {
const plan = curatorSprintTaskPlan({
startDate,
variantId: "experienced_professional",
durationDays: 2,
access: "trial",
});
const registry = CURATOR_TRIAL_TASK_REGISTRY.experienced_professional[dayIndex - 1]!;
return plan[dayIndex - 1]!.tasks.map(({ id }, index) => ({
id,
title: [registry.socialTitle, registry.measurementTitle, registry.proofTitle, registry.practiceTitle][index]!,
}));
}

View File

@@ -0,0 +1,140 @@
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",
"qscore-service": "/agents/qscore",
"resume-service": "/agents/resume",
"interview-service": "/agents/interview",
"roleplay-service": "/agents/roleplay",
"matchmaking-service": "/agents/matchmaking",
"social-branding-service": "/agents/social-branding",
};
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-v10-static", `${icpId}: expected static plan version`);
assert(preview.plan.days.length === 7, `${icpId}: expected 7 generated days`);
assert(preview.plan.calendarWeeks.length === 1, `${icpId}: expected one sprint-relative calendar week`);
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`);
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-v10-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`);
assert(url.searchParams.get("taskTitle") === task.title, `${icpId} ${task.id}: route taskTitle mismatch`);
assert(url.searchParams.get("taskSubtitle") === task.subtitle, `${icpId} ${task.id}: route taskSubtitle mismatch`);
assert(url.searchParams.get("taskType") === task.taskType, `${icpId} ${task.id}: route taskType mismatch`);
if (task.serviceId === "interview-service") {
const taskText = `${task.title} ${task.subtitle}`.toLowerCase();
const isIntroductionTask = /(?:60|90)[ -]?second|\bintroduction\b|\bpitch\b|\bpresentation\b/.test(taskText);
assert(url.searchParams.get("type") === (isIntroductionTask ? "warm_up" : "behavioral"), `${icpId} ${task.id}: interview type must match task intent`);
assert(url.searchParams.get("role") === (isIntroductionTask ? null : "Product Manager"), `${icpId} ${task.id}: interview role must match task intent`);
assert(url.searchParams.get("difficulty") === (isIntroductionTask ? "easy" : "medium"), `${icpId} ${task.id}: interview difficulty must match task intent`);
assert(url.searchParams.get("duration") === "5", `${icpId} ${task.id}: interview route should carry duration`);
}
if (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, 7 days each, 4 doc-backed tasks per day`);
}
main().catch((err) => {
console.error(err);
process.exit(1);
});

View File

@@ -0,0 +1,105 @@
import assert from "node:assert/strict";
import { shouldRouteGrowEvent } from "../src/events/record-grow-event.js";
import type { GrowEventRow } from "../src/db/schema.js";
/**
* Regression: duplicate/idempotent ingest must not enqueue the same event twice.
*
* Before the fix, recordGrowEvent returned the existing row on a dedupe hit,
* and routeGrowEventToUserActor was called unconditionally — so every duplicate
* ingest re-enqueued the event to the actor queue, causing saturation.
*
* After the fix, routing is gated by shouldRouteGrowEvent(event, inserted):
* only newly inserted, user-resolved, pending events are routed.
*/
// ── 1. Freshly inserted, user-resolved, pending → route ──────────────────────
{
const event = { userId: "user_a", processingStatus: "pending" } as Pick<GrowEventRow, "userId" | "processingStatus">;
assert.equal(
shouldRouteGrowEvent(event, true),
true,
"newly inserted pending event with a userId must route",
);
}
// ── 2. Dedupe hit (inserted=false) → DO NOT route ────────────────────────────
// This is the core regression: a duplicate ingest returns the existing row
// with inserted=false. The gate must refuse to re-enqueue.
{
const event = { userId: "user_a", processingStatus: "processed" } as Pick<GrowEventRow, "userId" | "processingStatus">;
assert.equal(
shouldRouteGrowEvent(event, false),
false,
"dedupe hit (inserted=false) must NOT route even if status looks processable",
);
}
// ── 3. Freshly inserted but already processed → DO NOT route ─────────────────
// Edge case: the row was just inserted but somehow already processed (e.g. the
// caller did in-process projection synchronously). No re-enqueue.
{
const event = { userId: "user_a", processingStatus: "processed" } as Pick<GrowEventRow, "userId" | "processingStatus">;
assert.equal(
shouldRouteGrowEvent(event, true),
false,
"inserted but already-processed event must NOT route",
);
}
// ── 4. Freshly inserted but unresolved (no userId) → DO NOT route ────────────
{
const event = { userId: null, processingStatus: "unresolved" } as Pick<GrowEventRow, "userId" | "processingStatus">;
assert.equal(
shouldRouteGrowEvent(event, true),
false,
"unresolved event (no userId) must NOT route",
);
}
// ── 5. Freshly inserted, user-resolved, but failed → DO NOT route ────────────
// A terminal-failed event should not be re-enqueued by a duplicate ingest.
{
const event = { userId: "user_a", processingStatus: "failed" } as Pick<GrowEventRow, "userId" | "processingStatus">;
assert.equal(
shouldRouteGrowEvent(event, true),
false,
"failed event must NOT route on replay",
);
}
// ── 6. Dedupe hit on a pending event → DO NOT route ──────────────────────────
// Critical: even if the existing row is still pending (not yet processed), a
// dedupe hit means another caller already inserted it and likely already routed
// it. Re-routing would create a duplicate queue message.
{
const event = { userId: "user_a", processingStatus: "pending" } as Pick<GrowEventRow, "userId" | "processingStatus">;
assert.equal(
shouldRouteGrowEvent(event, false),
false,
"dedupe hit on a pending event must NOT re-route (already enqueued by first caller)",
);
}
// ── 7. Idempotent double-ingest simulation ───────────────────────────────────
// Simulate two calls to recordGrowEventWithResult for the same event:
// first returns inserted=true, second returns inserted=false.
// The first routes, the second does not — proving no double-enqueue.
{
const event = { userId: "user_a", processingStatus: "pending" } as Pick<GrowEventRow, "userId" | "processingStatus">;
// First ingest: newly inserted
const firstRoute = shouldRouteGrowEvent(event, true);
// Second ingest: dedupe hit
const secondRoute = shouldRouteGrowEvent(event, false);
assert.equal(firstRoute, true, "first ingest must route");
assert.equal(secondRoute, false, "second (dedupe) ingest must NOT route");
assert.ok(
firstRoute && !secondRoute,
"duplicate ingest must not enqueue twice",
);
}
console.log("event-dedupe-routing tests passed");
process.exit(0);

View File

@@ -0,0 +1,131 @@
import assert from "node:assert/strict";
import { routeGrowEventToUserActor } from "../src/events/route-to-user-actor.js";
import { shouldRouteGrowEvent } from "../src/events/record-grow-event.js";
import type { GrowEventRow } from "../src/db/schema.js";
/**
* Cross-file integration test: exercises the full ingress → route → actor-enqueue
* side-effect chain for the duplicate-routing regression.
*
* The persistence layer (recordGrowEventWithResult) requires Postgres and cannot
* run in a tsx test. Instead we simulate its {event, inserted} output contract —
* the same contract the three ingress callers (redis-consumer recordAndRoute,
* routes/events.ts ingest, v1/events/events-routes.ts /track) consume — and prove
* that the routing layer enqueues exactly once across a duplicate ingest pair.
*
* routeGrowEventToUserActor accepts an injectable `enqueue`, so we can observe
* real side effects (the actor queue send) without a live Rivet cluster.
*/
type RouteDecision = { routed: true } | { routed: false; reason: string };
// Simulate the ingress caller's routing block (mirrors redis-consumer.recordAndRoute
// and routes/events.ts.ingest): gate on shouldRouteGrowEvent, then call route.
async function ingressRoute(
event: Pick<GrowEventRow, "id" | "userId" | "processingStatus">,
inserted: boolean,
enqueueCalls: Array<{ userId: string; eventId: string }>,
): Promise<RouteDecision> {
if (!shouldRouteGrowEvent(event, inserted)) {
return { routed: false, reason: "dedupe_or_unresolved" };
}
return routeGrowEventToUserActor(event, {
timeoutMs: 500,
enqueue: async (input) => {
enqueueCalls.push({ userId: input.userId, eventId: input.eventId });
return { queued: true };
},
});
}
function makeEvent(overrides: Partial<GrowEventRow> = {}): Pick<GrowEventRow, "id" | "userId" | "processingStatus"> {
return {
id: "evt-cross-file-test",
userId: "user-cross-file",
processingStatus: "pending",
...overrides,
};
}
// ── 1. First ingest routes, second (dedupe) does NOT — proving no double-enqueue ─
{
const enqueueCalls: Array<{ userId: string; eventId: string }> = [];
const event = makeEvent();
// First ingest: recordGrowEventWithResult returns inserted=true
const first = await ingressRoute(event, true, enqueueCalls);
// Second ingest: same event, dedupe hit → inserted=false
const second = await ingressRoute(event, false, enqueueCalls);
assert.equal(first.routed, true, "first ingest must route to actor");
assert.equal(second.routed, false, "dedupe ingest must NOT route");
assert.equal(enqueueCalls.length, 1, "exactly one enqueue call — duplicate must not double-enqueue");
assert.deepEqual(enqueueCalls[0], { userId: "user-cross-file", eventId: "evt-cross-file-test" });
}
// ── 2. Unresolved event (no userId) never enqueues ────────────────────────────
{
const enqueueCalls: Array<{ userId: string; eventId: string }> = [];
const event = makeEvent({ userId: null, processingStatus: "unresolved" });
const result = await ingressRoute(event, true, enqueueCalls);
assert.equal(result.routed, false, "unresolved event must not route");
assert.equal(enqueueCalls.length, 0, "unresolved event must not enqueue");
}
// ── 3. Three distinct events all route (no false-negative) ────────────────────
{
const enqueueCalls: Array<{ userId: string; eventId: string }> = [];
const events = [
makeEvent({ id: "evt-a", userId: "user-1" }),
makeEvent({ id: "evt-b", userId: "user-1" }),
makeEvent({ id: "evt-c", userId: "user-2" }),
];
for (const event of events) {
await ingressRoute(event, true, enqueueCalls);
}
assert.equal(enqueueCalls.length, 3, "three distinct inserted events must each enqueue once");
assert.deepEqual(
enqueueCalls.map((c) => c.eventId),
["evt-a", "evt-b", "evt-c"],
);
}
// ── 4. Same event ingested 5 times enqueues exactly once ──────────────────────
{
const enqueueCalls: Array<{ userId: string; eventId: string }> = [];
const event = makeEvent({ id: "evt-repeated" });
// First is inserted, remaining 4 are dedupe hits
await ingressRoute(event, true, enqueueCalls);
for (let i = 0; i < 4; i++) {
await ingressRoute(event, false, enqueueCalls);
}
assert.equal(enqueueCalls.length, 1, "5 duplicate ingests must enqueue exactly once");
}
// ── 5. Mixed: event A first-insert, event B dedupe, event C first-insert ──────
// Proves the gate doesn't carry state between decisions.
{
const enqueueCalls: Array<{ userId: string; eventId: string }> = [];
const eventA = makeEvent({ id: "evt-mixed-a" });
const eventB = makeEvent({ id: "evt-mixed-b" });
const eventC = makeEvent({ id: "evt-mixed-c" });
await ingressRoute(eventA, true, enqueueCalls);
await ingressRoute(eventB, false, enqueueCalls);
await ingressRoute(eventC, true, enqueueCalls);
assert.equal(enqueueCalls.length, 2, "only first-insert events A and C enqueue; B (dedupe) skipped");
assert.deepEqual(
enqueueCalls.map((c) => c.eventId),
["evt-mixed-a", "evt-mixed-c"],
);
}
console.log("event-ingress-route-integration tests passed");
process.exit(0);

View File

@@ -0,0 +1,17 @@
import assert from "node:assert/strict";
import { routeGrowEventToUserActor } from "../src/events/route-to-user-actor.js";
const startedAt = Date.now();
const result = await routeGrowEventToUserActor(
{ id: "event-timeout-test", userId: "user-timeout-test" },
{
timeoutMs: 25,
enqueue: () => new Promise<never>(() => undefined),
},
);
assert.equal(result.routed, false);
assert.equal(result.reason, "actor_route_timeout");
assert.ok(Date.now() - startedAt < 500, "actor routing must not block REST ingestion");
console.log("event-route-timeout tests passed");

View File

@@ -0,0 +1,117 @@
import assert from "node:assert/strict";
import { extractQscoreSignals } from "../src/events/projectors/qscore-projector.js";
import { buildMatchmakingGatewayEvent } from "../src/services/matchmaking-events.js";
import type { GrowEventRow } from "../src/db/schema.js";
function event(overrides: Partial<GrowEventRow> & { type: string; payload: Record<string, unknown> }): GrowEventRow {
return {
id: `event-${overrides.type}`,
userId: "user_test",
orgId: null,
source: "matchmaking-v2",
type: overrides.type,
category: "service",
occurredAt: new Date("2026-07-09T00:00:00.000Z"),
receivedAt: new Date("2026-07-09T00:00:01.000Z"),
mission: overrides.mission ?? null,
subject: overrides.subject ?? null,
correlation: overrides.correlation ?? null,
payload: overrides.payload,
raw: {},
dedupeKey: null,
processingStatus: "pending",
processingError: null,
processedAt: null,
};
}
const saved = buildMatchmakingGatewayEvent({
userId: "user_test",
action: "mark_saved",
body: {
action: "mark_saved",
params: {
opportunity_id: "job_123",
curatorTaskId: "task_123",
missionId: "curator-sprint",
missionInstanceId: "sprint_123",
stageId: "stage_123",
source: "curator-v1",
},
},
result: { task_id: "a2a_123", status: "completed", messages: [] },
});
assert.equal(saved.type, "matchmaking.match.saved");
assert.deepEqual(saved.mission, {
missionId: "curator-sprint",
instanceId: "sprint_123",
stageId: "stage_123",
source: "curator-v1",
});
assert.deepEqual(saved.subject, {
serviceId: "matchmaking",
kind: "opportunity",
id: "job_123",
externalId: "job_123",
});
assert.equal(saved.correlation?.taskId, "task_123");
assert.equal(saved.correlation?.curatorTaskId, "task_123");
assert.equal(saved.correlation?.opportunityId, "job_123");
assert.equal(saved.payload.curatorTaskId, "task_123");
assert.equal(saved.payload.opportunityId, "job_123");
assert.equal(saved.payload.action, "mark_saved");
const generatedSignals = extractQscoreSignals(event({
type: "matchmaking.matches.generated",
payload: {
action: "run_search",
matchCount: 6,
scanned: 120,
result: { status: "completed" },
},
}));
assert.ok(
generatedSignals.some((signal) => signal.signalId === "matching.match_rate" && signal.score > 0),
"generated matches should produce a match rate signal from match counts",
);
const savedSignals = extractQscoreSignals(event({
type: "matchmaking.match.saved",
payload: {
action: "mark_saved",
opportunityId: "job_123",
curatorTaskId: "task_123",
result: { status: "completed" },
},
}));
assert.equal(savedSignals.length, 0, "saved opportunities no longer emit a legacy signal");
const reviewed = buildMatchmakingGatewayEvent({
userId: "user_test",
action: "record_feedback",
body: {
action: "record_feedback",
params: { opportunity_id: "job_123", curatorTaskId: "task_123" },
},
result: { task_id: "a2a_456", status: "completed", messages: [] },
});
assert.equal(reviewed.type, "matchmaking.matches.reviewed");
const appliedSignals = extractQscoreSignals(event({
type: "matchmaking.match.applied",
payload: {
action: "mark_applied",
opportunityId: "job_123",
curatorTaskId: "task_123",
applications_submitted: 1,
result: { status: "completed" },
},
}));
assert.ok(
appliedSignals.some((signal) => signal.signalId === "matching.applications_submitted" && signal.score > 0),
"applied opportunities should produce an applications submitted signal",
);
console.log("matchmaking-events tests passed");
process.exit(0);

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);

194
scripts/onboarding-bulk-reset.ts Executable file
View File

@@ -0,0 +1,194 @@
#!/usr/bin/env tsx
/**
* Staging-only bulk onboarding reset CLI.
*
* Resets onboarding for EVERY user in the backend `users` table (the
* Clerk-mirrored enrollment source) in two phases:
*
* Phase 1 — user-service preferences reset (A2A).
* Calls POST /api/v1/users/onboarding-reset on user-service with the A2A
* key. This resets preferences.onboarding to the default in-progress v3
* doc, preserving all unrelated preference keys. It targets users by
* clerk_id WITHOUT impersonating a Clerk session. A 404 means the user has
* no user-service profile (backend-only user) — a documented skip that
* never blocks Phase 2.
*
* Phase 2 — backend completion ledger reset (direct, idempotent).
* Calls resetOnboardingLedger(userId) for every backend user whose Phase 1
* succeeded OR was skipped (404 backend-only). A Phase 1 error (non-404
* failure) blocks Phase 2 for that user to avoid inconsistent state
* (preferences say "completed" but ledger says not). This deletes only
* completion-type ledger rows (dotted AND underscore aliases) plus
* completion-bearing snapshots, preserving intermediate snapshots,
* missions, curator context, and qscore baselines.
*
* GUARDS — all three must hold or the script aborts before any network call:
* 1. NODE_ENV=staging (refuses production/development)
* 2. ONBOARDING_BULK_RESET_ALLOWED=true (explicit opt-in flag)
* 3. --confirm on the CLI (typed acknowledgement)
*
* Usage:
* NODE_ENV=staging ONBOARDING_BULK_RESET_ALLOWED=true \
* npx tsx scripts/onboarding-bulk-reset.ts --confirm
*
* Output: aggregate-only audit counts (no per-user PII) and a summary.
*
* This script is INTENTIONALLY not wired to any HTTP surface. It is a
* staging-only operations tool; bulk reset must never be exposed as a public
* endpoint. Per-user reset is the authenticated DELETE /users/onboarding route.
*/
import { eq, asc } from "drizzle-orm";
import { db } from "../src/db/client.js";
import { users } from "../src/db/schema.js";
import { config } from "../src/config.js";
import { log } from "../src/log.js";
import { resetOnboardingLedger } from "../src/events/onboarding-ledger.js";
type PrefOutcome =
| { userId: string; ok: true }
| { userId: string; ok: false; skipped: true; reason: string; status: number }
| { userId: string; ok: false; skipped: false; error: string; status: number };
type LedgerOutcome =
| { userId: string; ok: true; ledgerRowsDeleted: number }
| { userId: string; ok: false; error: string };
function assertStagingGuard(argv: string[]): void {
const isStaging = config.nodeEnv === "staging";
const allowed = process.env.ONBOARDING_BULK_RESET_ALLOWED === "true";
const confirmed = argv.includes("--confirm");
const failures: string[] = [];
if (!isStaging) failures.push(`NODE_ENV must be "staging" (got "${config.nodeEnv}")`);
if (!allowed) failures.push("ONBOARDING_BULK_RESET_ALLOWED must be set to \"true\"");
if (!confirmed) failures.push("--confirm argument is required");
if (failures.length > 0) {
console.error("onboarding-bulk-reset: ABORTED — guard checks failed:");
for (const f of failures) console.error(` - ${f}`);
console.error("");
console.error("This script is staging-only and will reset onboarding for EVERY user.");
console.error("Re-run with all guards satisfied to proceed.");
process.exit(2);
}
}
function userServiceUrl(): string {
return config.userServiceUrl.replace(/\/$/, "");
}
/**
* Phase 1: reset preferences.onboarding via the user-service A2A endpoint.
* A 404 means the user has no user-service profile (backend-only) — a clean
* skip that does NOT block the backend ledger reset.
*/
async function resetPreferences(clerkId: string, a2aKey: string): Promise<PrefOutcome> {
const res = await fetch(`${userServiceUrl()}/api/v1/users/onboarding-reset`, {
method: "POST",
headers: {
authorization: `Bearer ${a2aKey}`,
"content-type": "application/json",
},
body: JSON.stringify({ clerk_id: clerkId }),
});
if (res.status === 404) {
return { userId: clerkId, ok: false, skipped: true, reason: "no user-service profile (backend-only)", status: 404 };
}
if (!res.ok) {
const text = await res.text().catch(() => "");
return { userId: clerkId, ok: false, skipped: false, error: text || res.statusText, status: res.status };
}
return { userId: clerkId, ok: true };
}
/**
* Phase 2: directly reset the backend completion ledger for a user.
* Always runs after Phase 1 (success or skip) — backend-only users still need
* their ledger cleared. Idempotent.
*/
async function resetLedger(userId: string): Promise<LedgerOutcome> {
try {
const ledgerRowsDeleted = await resetOnboardingLedger(userId);
return { userId, ok: true, ledgerRowsDeleted };
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return { userId, ok: false, error: message };
}
}
async function main() {
assertStagingGuard(process.argv.slice(2));
const a2aKey = config.a2aAllowedKey;
if (!a2aKey) {
console.error("onboarding-bulk-reset: ABORTED — A2A_ALLOWED_KEY is not configured");
process.exit(2);
}
const allUsers = await db.select({ id: users.id }).from(users).orderBy(asc(users.id));
const total = allUsers.length;
console.log(`onboarding-bulk-reset: targeting ${total} user(s)`);
console.log(`onboarding-bulk-reset: NODE_ENV=${config.nodeEnv}, guard=on`);
console.log(`onboarding-bulk-reset: user-service=${userServiceUrl()}`);
let prefOk = 0;
let prefSkipped = 0;
let prefErrors = 0;
let ledgerOk = 0;
let ledgerErrors = 0;
let ledgerTotal = 0;
let ledgerBlocked = 0;
for (const u of allUsers) {
// Phase 1: user-service preferences reset (A2A).
const pref = await resetPreferences(u.id, a2aKey);
if (pref.ok) {
prefOk += 1;
} else if (pref.skipped) {
prefSkipped += 1;
} else {
prefErrors += 1;
}
// Phase 2: backend ledger reset — ONLY after Phase 1 succeeds or is
// skipped (404 / backend-only). A pref error (non-404 failure) leaves
// preferences.onboarding untouched, so clearing the ledger here would
// create an inconsistent state (prefs say "completed", ledger says not).
// Skip the ledger for errored users so an operator can retry after fix.
if (!pref.ok && !pref.skipped) {
ledgerBlocked += 1;
continue;
}
const ledger = await resetLedger(u.id);
if (ledger.ok) {
ledgerOk += 1;
ledgerTotal += ledger.ledgerRowsDeleted;
} else {
ledgerErrors += 1;
}
}
console.log("");
console.log("onboarding-bulk-reset: summary (aggregate only — no per-user PII)");
console.log(` total users: ${total}`);
console.log(` preferences reset: ${prefOk} ok, ${prefSkipped} skipped (backend-only), ${prefErrors} error(s)`);
console.log(` ledger reset: ${ledgerOk} ok, ${ledgerErrors} error(s), ${ledgerBlocked} blocked by pref error, ${ledgerTotal} row(s) deleted`);
const hadErrors = prefErrors > 0 || ledgerErrors > 0;
log.info(
{ total, prefOk, prefSkipped, prefErrors, ledgerOk, ledgerErrors, ledgerBlocked, ledgerTotal },
"onboarding bulk reset complete",
);
process.exit(hadErrors ? 1 : 0);
}
main().catch((err) => {
console.error("onboarding-bulk-reset: fatal", err);
process.exit(1);
});

View File

@@ -0,0 +1,114 @@
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",
);
// ── Regression: underscore completion aliases must be valid (reset deletes them) ──
// The reset only reopens the gate if EVERY alias form that isValidOnboardingLedgerEvent
// accepts is also in the reset delete scope. Pin both sides agree per alias.
for (const underscore of ["onboarding_completed", "user_onboarding_completed", "profile_onboarding_completed"]) {
const dotted = normalizeOnboardingEventType(underscore);
assert.equal(
isValidOnboardingLedgerEvent({ type: underscore, payload: {} }),
true,
`${underscore} should satisfy onboarding status (normalized to ${dotted})`,
);
}
// ── Regression: completedAtFromOnboardingPayload paths mirrored by reset SQL ──
// resetOnboardingLedger's jsonb predicate checks top-level completed_at/completedAt
// and onboarding.completed_at/completedAt. Pin that the JS helper (used for status
// validity) agrees a snapshot carrying any of these is a completion snapshot.
for (const [label, payload] of [
["top-level completed_at", { completed_at: now }],
["top-level completedAt", { completedAt: now }],
["onboarding.completed_at", { onboarding: { completed_at: now } }],
["onboarding.completedAt", { onboarding: { completedAt: now } }],
] as const) {
assert.equal(
completedAtFromOnboardingPayload(payload),
now,
`completion timestamp extracted from ${label}`,
);
assert.equal(
isValidOnboardingLedgerEvent({ type: "onboarding.snapshot.saved", payload }),
true,
`snapshot with ${label} is a completion snapshot`,
);
}
// ── Regression: intermediate snapshots (no completion marker) stay invalid ──
assert.equal(
completedAtFromOnboardingPayload({ onboarding: { current_step: 2 } }),
undefined,
"intermediate snapshot has no completion timestamp",
);
console.log("onboarding-ledger tests passed");
process.exit(0);

View File

@@ -0,0 +1,160 @@
import assert from "node:assert/strict";
import {
DEFAULT_ONBOARDING_DATA,
defaultOnboardingData,
extractOnboardingData,
assertOnboardingRevision,
deriveOnboardingPayload,
mergeOnboardingPatch,
OnboardingRevisionConflict,
} from "../src/events/onboarding-ledger.js";
/**
* Focused tests for the onboarding preference helpers (pure — no DB/network).
* Covers: default v3 shape, revision conflict, partial update merge,
* status/completed_at consistency + payload derivation, and unrelated
* preference preservation. Matches the scripts/X.test.ts convention
* (node:assert over pure functions).
*/
// ── 1. default v3 response ───────────────────────────────────────────────────
{
const def = defaultOnboardingData();
assert.equal(def.schema_version, 3, "default is v3");
assert.equal(def.revision, 0);
assert.equal(def.status, "in_progress");
assert.equal(def.access_choice, null);
assert.equal(def.qx_estimate, null);
assert.equal(def.completed_at, null);
assert.equal(def.consent.privacy_accepted, false);
assert.equal(def.progress.stage, "consent");
// DEFAULT constant must match the factory.
assert.deepEqual(def, DEFAULT_ONBOARDING_DATA);
// Factory returns a fresh clone, not the shared constant.
assert.notEqual(def, DEFAULT_ONBOARDING_DATA);
}
// extractOnboardingData returns the default when nothing is stored.
{
const empty = extractOnboardingData(undefined);
assert.equal(empty.schema_version, 3);
assert.equal(empty.revision, 0);
const fromNonObject = extractOnboardingData("nonsense");
assert.equal(fromNonObject.status, "in_progress");
}
// ── 2. revision conflict (optimistic concurrency) ───────────────────────────
{
const current = extractOnboardingData({ revision: 5 });
// Matching revision is accepted (no throw).
assert.doesNotThrow(() => assertOnboardingRevision(5, current));
// Mismatched revision throws the typed conflict.
assert.throws(
() => assertOnboardingRevision(4, current),
(err) => err instanceof OnboardingRevisionConflict && err.expected === 4 && err.actual === 5,
);
}
// ── 3. partial update merge (revision bump + progress stamp) ────────────────
{
const stored = extractOnboardingData({ revision: 2 });
const incoming = { ...stored, revision: 2, profile: { ...stored.profile, mode: "founder" } };
const merged = mergeOnboardingPatch({ onboarding: stored }, incoming);
const next = extractOnboardingData(merged.onboarding);
assert.equal(next.revision, 3, "revision bumps by one from the stored value");
assert.equal(next.profile.mode, "founder", "incoming profile change is applied");
assert.ok(next.progress.updated_at, "progress.updated_at is stamped on save");
}
// ── 4. status/completed_at consistency + payload derivation ─────────────────
{
const base = defaultOnboardingData();
const completed = {
...base,
revision: 0,
status: "completed" as const,
profile: { ...base.profile, intent: "land-a-role", mode: "student", icp: "intern", question_branch: "student" },
responses: {
...base.responses,
career_barriers: ["no-network"],
desired_outcomes: ["interviews", "offer"],
target_role: "Product Intern",
target_field: "technical",
weekly_time_commitment: "5-10",
experience_level: "0-2",
work_context: "audience",
},
// deliberately omit completed_at; merge must stamp it.
};
const merged = mergeOnboardingPatch({}, completed);
const next = extractOnboardingData(merged.onboarding);
assert.equal(next.status, "completed");
assert.ok(next.completed_at, "completed status without completed_at is stamped");
// Payload is a faithful projection — no invented completion math.
const payload = deriveOnboardingPayload(next);
assert.equal(payload.schema_version, 1);
assert.equal(payload.source_revision, next.revision);
assert.equal(payload.mode, "student");
assert.equal(payload.onboarding_icp, "intern");
assert.equal(payload.target_role, "Product Intern");
assert.equal(payload.target_field, "technical");
assert.deepEqual(payload.goals, ["interviews", "offer"]);
assert.deepEqual(payload.barriers, ["no-network"]);
assert.equal(payload.weekly_time_commitment, "5-10");
assert.equal(payload.experience_context, "0-2 · audience");
}
// completed_at is cleared when regressing to in_progress with an explicit null.
{
const completed = { ...defaultOnboardingData(), status: "completed" as const, completed_at: "2026-07-10T00:00:00.000Z" };
const regressed = { ...completed, status: "in_progress" as const, completed_at: null };
const merged = mergeOnboardingPatch({}, regressed);
const next = extractOnboardingData(merged.onboarding);
assert.equal(next.status, "in_progress");
assert.equal(next.completed_at, null, "stale completed_at is cleared on regression");
}
// ── 5. unrelated preference preservation ────────────────────────────────────
{
const preferences = {
onboarding: { revision: 1, status: "in_progress" },
interview_preferences: { focus_areas: ["behavioral"] },
resume_preferences: { target_title: "Data Scientist" },
mission_preferences: { active_goal: "land-offer" },
target_roles: ["Product Intern"],
target_companies: ["Acme"],
};
const incoming = { ...extractOnboardingData(preferences.onboarding), revision: 1, profile: { intent: "x", mode: "student", icp: "intern", question_branch: "student" } };
const merged = mergeOnboardingPatch(preferences, incoming);
// The onboarding blob is updated.
assert.equal(extractOnboardingData(merged.onboarding).revision, 2);
// Every unrelated preference key is preserved verbatim.
assert.deepEqual(merged.interview_preferences, { focus_areas: ["behavioral"] });
assert.deepEqual(merged.resume_preferences, { target_title: "Data Scientist" });
assert.deepEqual(merged.mission_preferences, { active_goal: "land-offer" });
assert.deepEqual(merged.target_roles, ["Product Intern"]);
assert.deepEqual(merged.target_companies, ["Acme"]);
}
// ── 6. access_choice persistence (trial | full | null) ───────────────────────
{
const base = defaultOnboardingData();
const trial = { ...base, revision: 0, access_choice: "trial" as const };
const mergedTrial = mergeOnboardingPatch({}, trial);
assert.equal(extractOnboardingData(mergedTrial.onboarding).access_choice, "trial");
const full = { ...base, revision: 0, access_choice: "full" as const };
const mergedFull = mergeOnboardingPatch({}, full);
assert.equal(extractOnboardingData(mergedFull.onboarding).access_choice, "full");
// Garbage access_choice is rejected back to null by extraction.
const garbage = extractOnboardingData({ access_choice: "pro" });
assert.equal(garbage.access_choice, null);
}
console.log("onboarding-preferences: all assertions passed");

View File

@@ -0,0 +1,176 @@
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
import {
COMPLETION_EVENT_TYPES,
COMPLETION_EVENT_TYPE_ALIASES,
SNAPSHOT_EVENT_TYPE_ALIASES,
resetOnboardingPreferences,
defaultOnboardingData,
extractOnboardingData,
ONBOARDING_LEDGER_QUERY_TYPES,
} from "../src/events/onboarding-ledger.js";
/**
* Focused tests for onboarding reset helpers (pure — no DB/network).
* Covers: per-user reset state (preferences reset to default, unrelated keys
* preserved), ledger type scope (only completion types deleted, snapshots
* preserved), and bulk guard behavior (assertStagingGuard invariants).
*
* The async resetOnboardingLedger is exercised in integration; here we assert
* its type-scope contract via the exported COMPLETION_EVENT_TYPES set, which is
* exactly the WHERE clause it builds.
*/
// ── 1. resetOnboardingPreferences: resets onboarding to default v3 ──────────
{
const preferences = {
onboarding: {
schema_version: 3,
revision: 5,
status: "completed",
completed_at: "2026-07-10T00:00:00.000Z",
progress: { stage: "done", branch_index: 3, updated_at: "2026-07-10T00:00:00.000Z" },
consent: { privacy_accepted: true, accepted_at: "2026-07-09T00:00:00.000Z", terms_version: "2026-07" },
profile: { intent: "land-a-role", mode: "student", icp: "intern", question_branch: "student" },
responses: { career_barriers: ["x"], desired_outcomes: ["y"], current_situation: null, target_milestone: null, weekly_time_commitment: "5-10", target_role: "Intern", target_field: "tech", venture_industry: null, experience_level: "0-2", work_context: "audience" },
import: { method: "manual", status: "not_started", resume_id: null, resume_filename: null, resume_summary: null, linkedin_profile_id: null, linkedin_url: null },
access_choice: "trial",
qx_estimate: 42,
},
interview_preferences: { focus_areas: ["behavioral"] },
resume_preferences: { target_title: "Data Scientist" },
target_roles: ["Product Intern"],
};
const reset = resetOnboardingPreferences(preferences);
const next = extractOnboardingData(reset.onboarding);
// Onboarding blob is back to defaults.
assert.equal(next.schema_version, 3);
assert.equal(next.revision, 0, "revision resets to 0");
assert.equal(next.status, "in_progress", "status reverts to in_progress");
assert.equal(next.completed_at, null, "completed_at is cleared");
assert.equal(next.progress.stage, "consent", "progress stage resets to consent");
assert.equal(next.access_choice, null);
assert.equal(next.qx_estimate, null);
assert.deepEqual(next, defaultOnboardingData());
// Unrelated preference keys are preserved verbatim.
assert.deepEqual(reset.interview_preferences, { focus_areas: ["behavioral"] });
assert.deepEqual(reset.resume_preferences, { target_title: "Data Scientist" });
assert.deepEqual(reset.target_roles, ["Product Intern"]);
}
// ── 2. resetOnboardingPreferences: idempotent on already-default prefs ─────
{
const empty = {};
const reset = resetOnboardingPreferences(empty);
assert.deepEqual(reset.onboarding, defaultOnboardingData());
// Original object is not mutated.
assert.equal(Object.keys(empty).length, 0, "original preferences not mutated");
}
// ── 3. Ledger reset scope: completion aliases (both forms) + completion snapshots
// resetOnboardingLedger builds: DELETE FROM grow_events WHERE userId = ? AND (
// type IN (COMPLETION_EVENT_TYPE_ALIASES)
// OR (type IN (SNAPSHOT_EVENT_TYPE_ALIASES) AND payload-has-completion)
// ). This pins the contract: (a) all completion aliases — dotted AND underscore —
// are deleted so a legacy underscore completion row can't keep the gate shut;
// (b) snapshot types are NOT blanket completion types (only completion-bearing
// snapshots are deleted via the jsonb predicate); (c) intermediate snapshots are
// preserved because they never satisfy the completion predicate.
{
// (a) Completion aliases cover BOTH dotted and underscore forms — 6 total.
assert.equal(COMPLETION_EVENT_TYPE_ALIASES.length, 6, "3 dotted + 3 underscore completion aliases");
for (const dotted of Object.keys(COMPLETION_EVENT_TYPES)) {
const underscore = dotted.replaceAll(".", "_");
assert.ok(COMPLETION_EVENT_TYPE_ALIASES.includes(dotted), `${dotted} in reset scope`);
assert.ok(COMPLETION_EVENT_TYPE_ALIASES.includes(underscore), `${underscore} alias in reset scope`);
}
// (b) Snapshot types are handled in a SEPARATE phase, never as completion types.
assert.equal(COMPLETION_EVENT_TYPES["onboarding.snapshot.saved"], undefined,
"snapshot must never be a blanket completion type");
assert.equal(SNAPSHOT_EVENT_TYPE_ALIASES.includes("onboarding.snapshot.saved"), true,
"dotted snapshot alias is in the snapshot reset phase");
assert.equal(SNAPSHOT_EVENT_TYPE_ALIASES.includes("onboarding_snapshot_saved"), true,
"underscore snapshot alias is in the snapshot reset phase");
// (c) The reset completion set is a strict subset of the status-query list:
// status reads ALL aliases + snapshots; reset deletes only completions and
// completion-bearing snapshots, preserving intermediate saves.
for (const alias of COMPLETION_EVENT_TYPE_ALIASES) {
assert.ok((ONBOARDING_LEDGER_QUERY_TYPES as readonly string[]).includes(alias),
`${alias} is also queryable for status`);
}
}
// ── 4. Bulk guard behavior: real spawn of assertStagingGuard ───────────────
// Spawns the actual CLI with controlled env and asserts exit code 2 (abort)
// for each independent failure leg. We only assert abort cases — the
// all-flags-pass case would proceed past the guard and hit the network/DB.
{
const script = "scripts/onboarding-bulk-reset.ts";
// Leg 1: production env (even with all other flags) must abort.
const prod = spawnSync("npx", ["tsx", script, "--confirm"], {
env: { ...process.env, NODE_ENV: "production", ONBOARDING_BULK_RESET_ALLOWED: "true" },
encoding: "utf8",
});
assert.equal(prod.status, 2, "production must abort even with all flags");
assert.match(prod.stderr, /NODE_ENV must be "staging"/, "production abort names the env failure");
// Leg 2: staging + allowed but missing --confirm must abort.
const noConfirm = spawnSync("npx", ["tsx", script], {
env: { ...process.env, NODE_ENV: "staging", ONBOARDING_BULK_RESET_ALLOWED: "true" },
encoding: "utf8",
});
assert.equal(noConfirm.status, 2, "staging without --confirm must abort");
assert.match(noConfirm.stderr, /--confirm/, "missing-confirm abort names the confirm failure");
// Leg 3: staging + confirm but no opt-in flag must abort.
const noFlag = spawnSync("npx", ["tsx", script, "--confirm"], {
env: { ...process.env, NODE_ENV: "staging", ONBOARDING_BULK_RESET_ALLOWED: "false" },
encoding: "utf8",
});
assert.equal(noFlag.status, 2, "staging without opt-in flag must abort");
assert.match(noFlag.stderr, /ONBOARDING_BULK_RESET_ALLOWED/, "missing-flag abort names the flag failure");
}
// ── 5. Bulk CLI structure: A2A endpoint + Phase 2 gating (source read) ──────
// The CLI's network/DB behavior is exercised in integration. Here we assert
// the structural contract by reading the script source directly: (a) it calls
// the user-service A2A onboarding-reset endpoint (not the backend DELETE
// route), (b) it authenticates with a2aAllowedKey (not serviceToken), (c) it
// treats 404 as a skip, and (d) it gates the backend ledger reset on Phase 1
// success-or-skip so a pref error never leaves inconsistent state.
{
const here = dirname(fileURLToPath(import.meta.url));
const src = readFileSync(join(here, "onboarding-bulk-reset.ts"), "utf8");
// (a) Calls the user-service A2A onboarding-reset endpoint by path.
assert.match(src, /\/api\/v1\/users\/onboarding-reset/, "CLI must call the user-service A2A reset endpoint");
// Must NOT call the backend DELETE /users/onboarding route (old impersonation path).
assert.doesNotMatch(src, /method:\s*["']DELETE["']/, "CLI must not use the backend DELETE route");
// (b) Authenticates with the A2A key, not the per-user service token.
assert.match(src, /a2aAllowedKey/, "CLI must use config.a2aAllowedKey for A2A auth");
assert.doesNotMatch(src, /serviceToken/, "CLI must not reference serviceToken (per-user impersonation path)");
// (c) Treats a 404 from user-service as a documented skip (backend-only user).
assert.match(src, /status === 404/, "CLI must detect 404 from user-service");
assert.match(src, /backend-only/, "CLI must document 404 as backend-only skip");
// (d) Phase 2 ledger reset is gated on Phase 1 success-or-skip.
assert.match(src, /!pref\.ok && !pref\.skipped/, "CLI must skip ledger when pref errored (not ok, not skipped)");
assert.match(src, /ledgerBlocked/, "CLI must count pref-blocked ledger skips");
// (e) Calls resetOnboardingLedger directly (not via HTTP).
assert.match(src, /import \{[^}]*resetOnboardingLedger[^}]*\} from/, "CLI must import resetOnboardingLedger directly");
// (f) Aggregate-only output — no per-user email/PII in the loop.
assert.doesNotMatch(src, /u\.email/, "CLI must not emit per-user email (PII)");
}
console.log("onboarding-reset 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,112 @@
import assert from "node:assert/strict";
import { resolveRedisConfig } from "../src/config.js";
/**
* Test: Redis ingestion consumers are default-off and do not load the Redis
* module unless an explicit opt-in flag is set. Legacy URLs must not inherit
* from the generic REDIS_URL.
*
* Uses the pure resolveRedisConfig resolver with synthetic env objects — no
* module-cache hacks needed.
*/
// ── 1. Default-off: no Redis flags set ──────────────────────────────────────
{
const rc = resolveRedisConfig({});
assert.equal(
rc.growEventsRedisEnabled,
false,
"GROW_EVENTS_REDIS_ENABLED must default to false",
);
assert.equal(
rc.legacyServiceRedisEnabled,
false,
"LEGACY_SERVICE_REDIS_ENABLED must default to false",
);
}
// ── 2. Legacy URLs must NOT inherit from generic REDIS_URL ──────────────────
{
// Set ONLY the generic REDIS_URL — legacy URLs must not pick it up.
const rc = resolveRedisConfig({ REDIS_URL: "redis://legacy-generic:6379" });
assert.equal(
rc.interviewRedisUrl,
"",
"interviewRedisUrl must NOT inherit from generic REDIS_URL",
);
assert.equal(
rc.roleplayRedisUrl,
"",
"roleplayRedisUrl must NOT inherit from generic REDIS_URL",
);
assert.equal(
rc.resumeRedisUrl,
"",
"resumeRedisUrl must NOT inherit from generic REDIS_URL",
);
assert.equal(
rc.coursesRedisUrl,
"",
"coursesRedisUrl must NOT inherit from generic REDIS_URL",
);
}
// ── 3. Legacy URLs must NOT inherit from GROW_EVENTS_REDIS_URL ───────────────
{
const rc = resolveRedisConfig({ GROW_EVENTS_REDIS_URL: "redis://canonical:6379" });
assert.equal(
rc.interviewRedisUrl,
"",
"interviewRedisUrl must NOT inherit from GROW_EVENTS_REDIS_URL",
);
assert.equal(rc.growEventsRedisUrl, "redis://canonical:6379", "canonical URL resolves from its own var");
}
// ── 4. Explicit opt-in flags are respected ──────────────────────────────────
{
const rc = resolveRedisConfig({ GROW_EVENTS_REDIS_ENABLED: "true" });
assert.equal(
rc.growEventsRedisEnabled,
true,
"GROW_EVENTS_REDIS_ENABLED=true must be respected",
);
assert.equal(
rc.legacyServiceRedisEnabled,
false,
"legacy must stay off when only canonical flag is set",
);
}
// ── 5. Legacy opt-in flag works independently ───────────────────────────────
{
const rc = resolveRedisConfig({
LEGACY_SERVICE_REDIS_ENABLED: "true",
INTERVIEW_REDIS_URL: "redis://interview:6379",
});
assert.equal(rc.legacyServiceRedisEnabled, true, "LEGACY_SERVICE_REDIS_ENABLED=true must be respected");
assert.equal(rc.growEventsRedisEnabled, false, "canonical must stay off when only legacy flag is set");
assert.equal(rc.interviewRedisUrl, "redis://interview:6379", "explicit interview URL resolves");
}
// ── 6. Explicit legacy URLs resolve from their own vars ──────────────────────
{
const rc = resolveRedisConfig({
INTERVIEW_REDIS_URL: "redis://i:6379",
ROLEPLAY_REDIS_URL: "redis://r:6379",
RESUME_REDIS_URL: "redis://re:6379",
COURSES_REDIS_URL: "redis://c:6379",
});
assert.equal(rc.interviewRedisUrl, "redis://i:6379");
assert.equal(rc.roleplayRedisUrl, "redis://r:6379");
assert.equal(rc.resumeRedisUrl, "redis://re:6379");
assert.equal(rc.coursesRedisUrl, "redis://c:6379");
}
console.log("redis-gating tests passed");
process.exit(0);

View File

@@ -0,0 +1,106 @@
import assert from "node:assert/strict";
import { extractQscoreSignals } from "../src/events/projectors/qscore-projector.js";
import type { GrowEventRow } from "../src/db/schema.js";
/**
* Test: Service-ingest projector behavior across multiple service sources.
* Verifies that canonical Grow events from different services produce
* registry-valid signals with correct source attribution.
*/
function event(overrides: Partial<GrowEventRow> & { type: string; source: string; payload: Record<string, unknown> }): GrowEventRow {
return {
id: `event-${overrides.type}`,
userId: "user_test",
orgId: null,
source: overrides.source,
type: overrides.type,
category: "service",
occurredAt: new Date("2026-07-09T00:00:00.000Z"),
receivedAt: new Date("2026-07-09T00:00:01.000Z"),
mission: overrides.mission ?? null,
subject: overrides.subject ?? null,
correlation: overrides.correlation ?? null,
payload: overrides.payload,
raw: {},
dedupeKey: null,
processingStatus: "pending",
processingError: null,
processedAt: null,
};
}
// ── 1. Resume analysis produces resume signals ──────────────────────────────
{
const signals = extractQscoreSignals(event({
source: "resume-builder",
type: "resume.analysis.completed",
payload: { analysis: { score_breakdown: [{ category: "ATS Compatibility", score: 75 }] } },
}));
assert.ok(signals.length > 0, "resume analysis event should produce signals");
assert.ok(
signals.some((s) => s.signalId.startsWith("resume.")),
"resume source should produce resume.* signals",
);
}
// ── 2. Generic scored service event (qscore source) produces completion score ─
{
const signals = extractQscoreSignals(event({
source: "qscore-service",
type: "qscore.signal.projected",
payload: { score: 65 },
}));
assert.ok(
signals.some((s) => s.score === 65),
"qscore source event with score should forward the score",
);
}
// ── 3. Social branding pre-computed signals forwarded as-is ─────────────────
{
const signals = extractQscoreSignals(event({
source: "social-branding-service",
type: "brand.profile.updated",
payload: {
qscore_signals: [
{ signalId: "linkedin.headline_quality", score: 70 },
{ signalId: "linkedin.summary_complete", score: 65 },
],
},
}));
assert.ok(
signals.some((s) => s.signalId === "linkedin.headline_quality" && s.score === 70),
"social branding should forward pre-computed signals as-is",
);
assert.ok(
signals.some((s) => s.signalId === "linkedin.summary_complete"),
"social branding should forward all pre-computed signals",
);
}
// ── 4. Unknown source with no score produces no signals ──────────────────────
{
const signals = extractQscoreSignals(event({
source: "unknown-service",
type: "service.completed",
payload: {},
}));
assert.equal(signals.length, 0, "unknown source with no score should produce no signals");
}
// ── 5. Matchmaking applied event with count ──────────────────────────────────
{
const signals = extractQscoreSignals(event({
source: "matchmaking-v2",
type: "matchmaking.match.applied",
payload: { applications_submitted: 3 },
}));
assert.ok(
signals.some((s) => s.signalId === "matching.applications_submitted" && s.score > 0),
"matchmaking applied with count should produce applications_submitted signal",
);
}
console.log("service-ingest-projector 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

@@ -0,0 +1,238 @@
import assert from "node:assert/strict";
import { extractQscoreSignals } from "../src/events/projectors/qscore-projector.js";
import { ONBOARDING_BASELINE_SIGNAL_ID } from "../src/events/onboarding-qscore.js";
import type { GrowEventRow } from "../src/db/schema.js";
/**
* Test: All signal IDs emitted by the projector must be valid members of the
* qscore_service v2 signal registry. The onboarding baseline signal must be
* "onboarding.completed" (not the non-registry "onboarding.completed_baseline").
*/
// v2 registry — union of all signal_ids across all 7 profession formula JSONs.
// Sourced from qscore_service/app/scoring/formula/v2/*/formula.json
const V2_REGISTRY = new Set<string>([
// resume
"resume.uploaded",
"resume.ats_compatibility",
"resume.keyword_relevance",
"resume.quantified_achievements",
"resume.grammar_clarity",
"resume.format_structure",
"resume.contact_info",
"resume.page_count",
// interview
"interview.sessions_completed",
"interview.overall_score",
"interview.response_clarity",
"interview.technical_accuracy",
"interview.behavioral_quality",
"interview.improvement_over_time",
"interview.type_diversity",
// roleplay
"roleplay.scenarios_completed",
"roleplay.situational_judgment",
"roleplay.empathy_demonstrated",
"roleplay.problem_resolution",
"roleplay.communication_effectiveness",
// courses
"courses.started",
"courses.completed",
"courses.completion_rate",
"courses.difficulty",
"courses.pathway_relevance",
// matching
"matching.jobs_viewed",
"matching.applications_submitted",
"matching.application_quality",
"matching.match_rate",
// onboarding
"onboarding.completed",
"onboarding.initial_skills_score",
"onboarding.goals_clarity",
"onboarding.profile_completeness",
"onboarding.self_assessment_accuracy",
"onboarding.motivation_quality",
// coverletter
"coverletter.uploaded",
"coverletter.customization",
"coverletter.persuasiveness",
// linkedin
"linkedin.account_connected",
"linkedin.profile_photo",
"linkedin.headline_quality",
"linkedin.summary_complete",
"linkedin.experience_detail",
"linkedin.skills_listed",
// portfolio
"portfolio.website_exists",
"portfolio.website_quality",
"portfolio.content_relevance",
// content
"content.articles_count",
"content.quality",
"content.platform_credibility",
// ugc
"ugc.speaking_count",
"ugc.event_credibility",
"ugc.verifiable_evidence",
// assessments
"assessments.taken",
"assessments.passed",
"assessments.avg_score",
"assessments.skill_diversity",
// presentations
"presentations.submitted",
"presentations.content_quality",
"presentations.delivery_score",
"presentations.visual_aids",
// events
"events.webinars_attended",
"events.meetups_attended",
"events.participation_quality",
// mentor
"mentor.feedback_count",
"mentor.score",
"mentor.implementation_rate",
]);
function event(overrides: Partial<GrowEventRow> & { type: string; payload: Record<string, unknown>; source: string }): GrowEventRow {
return {
id: `event-${overrides.type}`,
userId: "user_test",
orgId: null,
source: overrides.source,
type: overrides.type,
category: "service",
occurredAt: new Date("2026-07-09T00:00:00.000Z"),
receivedAt: new Date("2026-07-09T00:00:01.000Z"),
mission: overrides.mission ?? null,
subject: overrides.subject ?? null,
correlation: overrides.correlation ?? null,
payload: overrides.payload,
raw: {},
dedupeKey: null,
processingStatus: "pending",
processingError: null,
processedAt: null,
};
}
// ── 1. Onboarding signal ID must be the registry-valid "onboarding.completed" ─
assert.equal(
ONBOARDING_BASELINE_SIGNAL_ID,
"onboarding.completed",
"Onboarding baseline signal ID must be 'onboarding.completed' (registry-valid), not 'onboarding.completed_baseline'",
);
assert.ok(
V2_REGISTRY.has(ONBOARDING_BASELINE_SIGNAL_ID),
"Onboarding signal ID must appear in v2 registry",
);
// ── 2. All projector signal IDs must be in the registry ──────────────────────
// Construct representative events for each source family and check every emitted
// signal ID is registry-valid.
// Resume with full breakdown
const resumeSignals = extractQscoreSignals(event({
source: "resume-builder",
type: "resume.analysis.completed",
payload: {
analysis: {
score_breakdown: [
{ category: "ATS Compatibility", score: 70 },
{ category: "Content Quality", score: 65 },
{ category: "Formatting", score: 80 },
],
dimensional_scores: [
{ dimension: "Keywords", score: 60 },
{ dimension: "Quantification", score: 55 },
],
},
},
}));
for (const s of resumeSignals) {
assert.ok(
V2_REGISTRY.has(s.signalId),
`Resume signal '${s.signalId}' is not in the v2 registry`,
);
}
// Interview with rubric
const interviewSignals = extractQscoreSignals(event({
source: "interview-service",
type: "interview.session.completed",
payload: {
review: {
overall_score: 72,
rubric_scores: { content_quality: 70, role_alignment: 68, language: 65 },
historical_comparison: { overall_delta: 5 },
},
session_count: 3,
type_diversity: 2,
},
}));
for (const s of interviewSignals) {
assert.ok(
V2_REGISTRY.has(s.signalId),
`Interview signal '${s.signalId}' is not in the v2 registry`,
);
}
// Roleplay with rubric
const roleplaySignals = extractQscoreSignals(event({
source: "roleplay-service",
type: "roleplay.scenario.completed",
payload: {
review: {
rubric_scores: {
scenario_adherence: 75,
emotional_intelligence: 70,
adaptability: 68,
content: 72,
},
},
scenario_count: 4,
},
}));
for (const s of roleplaySignals) {
assert.ok(
V2_REGISTRY.has(s.signalId),
`Roleplay signal '${s.signalId}' is not in the v2 registry`,
);
}
// Courses
const courseSignals = extractQscoreSignals(event({
source: "courses-service",
type: "course.completed",
payload: {
courseId: "c1",
completed_count: 2,
watchPct: 0.9,
difficulty: "intermediate",
label: "high relevance",
},
}));
for (const s of courseSignals) {
assert.ok(
V2_REGISTRY.has(s.signalId),
`Course signal '${s.signalId}' is not in the v2 registry`,
);
}
// Matchmaking
const matchSignals = extractQscoreSignals(event({
source: "matchmaking-v2",
type: "matchmaking.feed.viewed",
payload: { jobs_viewed: 25 },
}));
for (const s of matchSignals) {
assert.ok(
V2_REGISTRY.has(s.signalId),
`Matchmaking signal '${s.signalId}' is not in the v2 registry`,
);
}
console.log("signal-registry tests passed");
process.exit(0);

View File

@@ -0,0 +1,95 @@
import assert from "node:assert/strict";
import { computeStreakFromDays } from "../src/v1/curator/streak-utils.js";
/**
* Test: Streak policies — current/longest, same-day duplicate, gap reset,
* recovery (resume after gap), and seven-day eligibility.
*
* Uses the pure computeStreakFromDays function with a fixed "today" so tests
* are deterministic.
*/
const TODAY = "2026-07-10";
// ── 1. Current and longest from a contiguous run ending today ────────────────
{
const days = ["2026-07-10", "2026-07-09", "2026-07-08"];
const streak = computeStreakFromDays(days, TODAY);
assert.equal(streak.current, 3, "current streak should be 3 for 3 consecutive days ending today");
assert.equal(streak.longest, 3, "longest should match current when run is unbroken");
assert.equal(streak.lastCompletedDate, "2026-07-10");
}
// ── 2. Same-day duplicate must not inflate the streak ────────────────────────
// computeStreakFromDays requires deduplicated days (the SQL GROUP BY ensures
// this in production). Passing a duplicate should not double-count.
{
const days = ["2026-07-10", "2026-07-10", "2026-07-09"];
const streak = computeStreakFromDays(days, TODAY);
// With a duplicate, the descending list has two "today" entries. The while
// loop checks `days.includes(cursor)` which is set-based, so current stays 2.
// But the longest loop iterates ALL entries including the dup, which could
// inflate longest to 3. The correct behavior: duplicates must not inflate.
assert.equal(streak.current, 2, "duplicate day must not inflate current streak");
assert.equal(
streak.longest,
2,
"duplicate day must not inflate longest streak — duplicates should be deduped before counting",
);
}
// ── 3. Gap resets the current streak ─────────────────────────────────────────
{
// Completed today and 3 days ago — gap breaks current streak
const days = ["2026-07-10", "2026-07-07"];
const streak = computeStreakFromDays(days, TODAY);
assert.equal(streak.current, 1, "gap resets current streak to 1 (only today)");
assert.equal(streak.longest, 1, "longest should also be 1 with only isolated days");
}
// ── 4. Recovery: longest captures a past longer run even if current is broken ─
{
// 5-day run last week, then a gap, then completed today
const days = [
"2026-07-10", // today (current restart)
"2026-07-05", // past run: Jul 15
"2026-07-04",
"2026-07-03",
"2026-07-02",
"2026-07-01",
];
const streak = computeStreakFromDays(days, TODAY);
assert.equal(streak.current, 1, "current is 1 after a gap even if a longer past run exists");
assert.equal(streak.longest, 5, "longest should capture the 5-day past run");
}
// ── 5. Seven-day eligibility: a 7-day streak counts as eligible ──────────────
{
const days = [
"2026-07-10", "2026-07-09", "2026-07-08",
"2026-07-07", "2026-07-06", "2026-07-05", "2026-07-04",
];
const streak = computeStreakFromDays(days, TODAY);
assert.equal(streak.current, 7, "7 consecutive days should yield current=7");
assert.equal(streak.longest, 7, "7 consecutive days should yield longest=7");
assert.ok(streak.current >= 7, "7-day streak eligibility threshold met");
}
// ── 6. Empty days yields zero streak ─────────────────────────────────────────
{
const streak = computeStreakFromDays([], TODAY);
assert.equal(streak.current, 0);
assert.equal(streak.longest, 0);
assert.equal(streak.lastCompletedDate, null);
}
// ── 7. Not completed today but completed yesterday: current is 0 ─────────────
{
const days = ["2026-07-09"];
const streak = computeStreakFromDays(days, TODAY);
assert.equal(streak.current, 0, "current is 0 if today is not completed");
assert.equal(streak.longest, 1, "longest should still count the 1-day run");
}
console.log("streak-policy tests passed");
process.exit(0);

View File

@@ -0,0 +1,97 @@
import assert from "node:assert/strict";
import { isProcessableStatus } from "../src/actors/events/user-event-actor.js";
/**
* Regression: one failed event must not block the next event in the queue.
*
* Before the fix, the actor's run loop used loopCtx.step + manual try/catch
* that rethrew on failure. The rethrow stalled the ctx.loop, preventing
* subsequent queued messages from being processed.
*
* After the fix, the loop uses tryStep (failures caught, event marked terminal)
* and a DB status guard (isProcessableStatus) that only skips truly inert rows.
*
* Dual-path rationale: the synchronous ingress paths (routes/events.ts,
* redis-consumer.ts, v1/events/events-routes.ts) run projections and mark the
* event "processed". The actor runs mission reducers (stage patches, artifacts,
* actions) that ONLY exist in the actor. So the actor must still process
* "processed" events to run mission reducers — projections are idempotent so
* re-running them is safe, and the in-memory processedEventIds guard prevents
* double-processing within one actor lifetime. The routing gate
* (shouldRouteGrowEvent) prevents saturation from duplicate ingests; this guard
* is defense-in-depth that only blocks "failed" and "unresolved".
*/
// ── 1. Pending → processable (first attempt) ─────────────────────────────────
assert.equal(isProcessableStatus("pending"), true, "pending events must be processable");
// ── 2. Processing → processable (crash recovery: retry idempotent work) ──────
// A prior attempt called markGrowEventProcessing then crashed before completing.
// The actor restarts, replays the queue message, and must retry.
assert.equal(isProcessableStatus("processing"), true, "processing events must be retryable after a crash");
// ── 3. Processed → processable (actor must run mission reducers) ─────────────
// The sync ingress path marks "processed" after projections. The actor still
// needs to run mission reducers — the only place they exist. Projections
// re-running is safe (idempotent). processedEventIds prevents in-memory dupes.
assert.equal(
isProcessableStatus("processed"),
true,
"processed events must be processable — actor must run mission reducers that only exist in the actor",
);
// ── 4. Failed → NOT processable (terminal, do not retry) ─────────────────────
// A per-event failure was already marked terminal by the record-failure step.
assert.equal(isProcessableStatus("failed"), false, "failed events must be skipped");
// ── 5. Unresolved → NOT processable (no userId, nothing to do) ───────────────
assert.equal(isProcessableStatus("unresolved"), false, "unresolved events must be skipped");
// ── 6. Null/undefined → NOT processable (defensive) ──────────────────────────
assert.equal(isProcessableStatus(null), false, "null status must be skipped");
assert.equal(isProcessableStatus(undefined), false, "undefined status must be skipped");
// ── 7. Unknown status → NOT processable (defensive) ──────────────────────────
assert.equal(isProcessableStatus("unknown"), false, "unknown status must be skipped");
// ── 8. Queue advancement simulation ──────────────────────────────────────────
// Queue: [pending, processed, pending]. Actor processes ALL three — the
// "processed" one still gets mission reducers. The key point: none of them
// block the next. This proves one event does not stall the queue.
{
const statuses = ["pending", "processed", "pending"] as const;
const processable = statuses.map((s) => isProcessableStatus(s));
assert.deepEqual(
processable,
[true, true, true],
"actor must process all three — processed event still runs mission reducers, none block the next",
);
}
// ── 9. Failed event does not block the next ──────────────────────────────────
// Queue: [failed, pending]. Actor skips the failed one and processes the next.
// This is the core regression: one failed event must not block subsequent events.
{
const statuses = ["failed", "pending"] as const;
const processable = statuses.map((s) => isProcessableStatus(s));
assert.deepEqual(
processable,
[false, true],
"actor must skip failed event and process the next pending event — failed does not block",
);
}
// ── 10. Crash-recovery queue simulation ──────────────────────────────────────
// Queue: [processing (crashed), pending]. Actor retries #1 and processes #2.
{
const statuses = ["processing", "pending"] as const;
const processable = statuses.map((s) => isProcessableStatus(s));
assert.deepEqual(
processable,
[true, true],
"actor must retry a crashed 'processing' event AND process the next pending event",
);
}
console.log("user-event-actor-advance tests passed");
process.exit(0);

View File

@@ -0,0 +1,165 @@
import assert from "node:assert/strict";
import { fetchUserProfile, patchPreferencesRequest } from "../src/services/user-profile.js";
/**
* Regression test for the PATCH /onboarding 500.
*
* Root cause: the PATCH handler parsed its JSON body via `c.req.json()`
* (consuming `c.req.raw` / setting bodyUsed), then called fetchUserProfile
* with the same Request. fetchUserProfile replayed the inbound method+body to
* user-service /me, so fetchUserService did `await req.arrayBuffer()` on an
* already-consumed body → `TypeError: Body is unusable` → uncaught → global
* onError → {"error":"internal"}, 500. GET /onboarding worked only because it
* never reads a body, so fetchUserService skipped arrayBuffer.
*
* This test reproduces the exact precondition — consume the body first — and
* asserts the fix: fetchUserProfile issues an explicit GET that never touches
* the inbound Request's body. It would have thrown before the fix and passes
* after.
*/
// Capture the outgoing fetch call.
type FetchCall = { method: string; url: string; bodyTouched: boolean };
async function runFetchUserProfileAfterBodyConsumed(): Promise<{
profile: Record<string, unknown>;
call: FetchCall;
}> {
const originalFetch = globalThis.fetch;
let captured: FetchCall | null = null;
const req = new Request("https://backend.test/api/growqr/users/onboarding", {
method: "PATCH",
headers: { "content-type": "application/json", authorization: "Bearer test" },
body: JSON.stringify({ data: { access_choice: "trial" }, expectedRevision: 0 }),
});
// Simulate what the PATCH handler does: parse the JSON body. This marks
// req.bodyUsed = true — the precondition that triggered the 500.
await req.json();
globalThis.fetch = (async (input: URL | Request, init?: RequestInit) => {
const target = input instanceof URL ? input : new URL(input instanceof Request ? input.url : String(input));
// Detect if the caller tried to re-read the original (consumed) Request body.
let bodyTouched = false;
if (input instanceof Request) {
try {
await input.clone().arrayBuffer();
bodyTouched = input.bodyUsed;
} catch {
bodyTouched = true; // Body is unusable — the original bug.
}
}
captured = { method: init?.method ?? input.method ?? "GET", url: target.href, bodyTouched };
return new Response(JSON.stringify({ preferences: { onboarding: { access_choice: "trial" } } }), {
status: 200,
headers: { "content-type": "application/json" },
});
}) as typeof globalThis.fetch;
try {
const profile = await fetchUserProfile(req);
assert(captured, "fetch was never called");
return { profile, call: captured };
} finally {
globalThis.fetch = originalFetch;
}
}
// ── 1. no throw when inbound body was already consumed ──────────────────────
{
const { call } = await runFetchUserProfileAfterBodyConsumed();
assert.equal(call.bodyTouched, false, "fetchUserProfile must not re-read the inbound Request body");
}
// ── 2. outgoing method is GET (a read, never PATCH) ─────────────────────────
{
const { call } = await runFetchUserProfileAfterBodyConsumed();
assert.equal(call.method, "GET", "fetchUserProfile must issue GET /me regardless of inbound method");
}
// ── 3. target is /api/v1/users/me and profile parses ───────────────────────
{
const { profile, call } = await runFetchUserProfileAfterBodyConsumed();
assert.ok(call.url.endsWith("/api/v1/users/me"), `expected /me URL, got ${call.url}`);
assert.deepEqual(profile, { preferences: { onboarding: { access_choice: "trial" } } });
}
// ── 4. content-type header is stripped (GET has no body) ────────────────────
{
const originalFetch = globalThis.fetch;
let capturedHeaders: Headers | null = null;
globalThis.fetch = (async (input: URL | Request, init?: RequestInit) => {
capturedHeaders = new Headers(init?.headers ?? {});
return new Response("{}", { status: 200, headers: { "content-type": "application/json" } });
}) as typeof globalThis.fetch;
try {
const req = new Request("https://backend.test/api/growqr/users/onboarding", {
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify({}),
});
await req.json();
await fetchUserProfile(req);
assert.ok(capturedHeaders, "fetch was never called");
assert.equal(capturedHeaders!.get("content-type"), null, "content-type must be stripped from GET /me");
} finally {
globalThis.fetch = originalFetch;
}
}
// ── 5. non-2xx surfaces a thrown error (caller handles) ─────────────────────
{
const originalFetch = globalThis.fetch;
globalThis.fetch = (async () => new Response("nope", { status: 503 })) as typeof globalThis.fetch;
try {
const req = new Request("https://backend.test/api/growqr/users/onboarding", { method: "GET" });
await assert.rejects(
() => fetchUserProfile(req),
/user-service \/me fetch failed: 503/,
);
} finally {
globalThis.fetch = originalFetch;
}
}
// ── 6. synthetic PATCH /me must not carry a stale content-length ───────────
// Regression for the second PATCH 500: patchPreferencesRequest copied inbound
// headers including content-length, then set a DIFFERENT body — undici kept the
// stale length, user-service waited for bytes that never arrive, and closed
// the socket (~2.8s "other side closed"). The Request constructor does NOT
// recompute content-length when an explicit body is provided alongside
// forwarded headers, so the deletes inside patchPreferencesRequest are
// load-bearing. This tests the REAL exported function.
{
// Inbound request with a body whose length differs from the synthetic body.
const inboundBody = JSON.stringify({ data: { access_choice: "trial" }, expectedRevision: 0 });
const req = new Request("https://backend.test/api/growqr/users/onboarding", {
method: "PATCH",
headers: {
"content-type": "application/json",
"content-length": String(Buffer.byteLength(inboundBody)),
"transfer-encoding": "chunked",
},
body: inboundBody,
});
const preferences = { onboarding: { access_choice: "trial" } };
const synthetic = patchPreferencesRequest(req, preferences);
// The synthetic request must not carry the stale inbound length/transfer headers.
assert.equal(synthetic.headers.get("content-length"), null, "stale content-length must be stripped from synthetic PATCH /me");
assert.equal(synthetic.headers.get("transfer-encoding"), null, "transfer-encoding must be stripped from synthetic PATCH /me");
assert.equal(synthetic.headers.get("host"), null, "host must be stripped");
assert.equal(synthetic.headers.get("cookie"), null, "cookie must be stripped");
assert.equal(synthetic.headers.get("content-type"), "application/json", "content-type must be set");
assert.equal(synthetic.method, "PATCH", "method must be PATCH");
assert.ok(synthetic.url.endsWith("/api/v1/users/me"), `target must be /me, got ${synthetic.url}`);
// The body must be the synthetic preferences blob, not the inbound payload.
const sentBody = await synthetic.text();
assert.deepEqual(JSON.parse(sentBody), { preferences }, "synthetic body must carry only { preferences }");
assert.notEqual(sentBody.length, Buffer.byteLength(inboundBody), "synthetic body length must differ from inbound (else the test proves nothing)");
}
console.log("user-profile: all assertions passed");

View File

@@ -0,0 +1,152 @@
import { actor } from "rivetkit";
import { count, desc, eq, sql } from "drizzle-orm";
import { db } from "../../db/client.js";
import {
growActiveMissions,
growEvents,
growQscoreSignals,
missionActions,
} from "../../db/schema.js";
import { listActiveMissionsPg } from "../../grow/persistence.js";
import { listMissionActions } from "../../missions/actions.js";
import { DEFAULT_QSCORE_ORG_ID, getQscoreFromService } from "../../services/qscore-proxy.js";
function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === "object" && !Array.isArray(value);
}
async function scalarCount(table: any, where?: any) {
const query = db.select({ value: count() }).from(table);
const rows = where ? await query.where(where) : await query;
return rows[0]?.value ?? 0;
}
async function platformAnalytics() {
const [
totalEvents,
serviceEvents,
missionEvents,
activeMissions,
completedMissions,
totalActions,
doneActions,
qscoreSignalCount,
] = await Promise.all([
scalarCount(growEvents),
scalarCount(growEvents, eq(growEvents.category, "service")),
scalarCount(growEvents, eq(growEvents.category, "mission")),
scalarCount(growActiveMissions),
scalarCount(growActiveMissions, eq(growActiveMissions.status, "completed")),
scalarCount(missionActions),
scalarCount(missionActions, eq(missionActions.status, "done")),
scalarCount(growQscoreSignals),
]);
const serviceUsage = await db
.select({
source: growEvents.source,
type: growEvents.type,
count: sql<number>`count(*)::int`,
})
.from(growEvents)
.where(eq(growEvents.category, "service"))
.groupBy(growEvents.source, growEvents.type)
.orderBy(sql`count(*) desc`)
.limit(20);
return {
kind: "platform",
generatedAt: new Date().toISOString(),
totals: {
events: totalEvents,
serviceEvents,
missionEvents,
activeMissions,
completedMissions,
missionActions: totalActions,
completedActions: doneActions,
qscoreSignals: qscoreSignalCount,
},
serviceUsage,
};
}
async function userQscoreAnalytics(userId: string) {
const result = await getQscoreFromService(userId, DEFAULT_QSCORE_ORG_ID);
const breakdown = result?.breakdown ?? {};
const latestSignals = Array.isArray(breakdown.signals) ? breakdown.signals : [];
const timelineRaw = Array.isArray(breakdown.signalTimeline) ? breakdown.signalTimeline : latestSignals;
const signalTimeline = timelineRaw.map((item) => {
const s = isRecord(item) ? item : {};
return {
signalId: typeof s.signalId === "string" ? s.signalId : typeof s.signal_id === "string" ? s.signal_id : "",
score: typeof s.score === "number" ? s.score : 0,
present: typeof s.present === "boolean" ? s.present : true,
source: typeof s.source === "string" ? s.source : "",
occurredAt: typeof s.occurredAt === "string" ? s.occurredAt : result?.calculated_at ?? new Date().toISOString(),
updatedAt: typeof s.updatedAt === "string" ? s.updatedAt : result?.calculated_at ?? new Date().toISOString(),
};
});
return {
kind: "user-qscore",
userId,
generatedAt: new Date().toISOString(),
current: result
? {
score: result.q_score,
iq_score: result.iq_score,
eq_score: result.eq_score,
sq_score: result.sq_score,
signalCount: signalTimeline.length,
dimensions: isRecord(breakdown.dimensions) ? breakdown.dimensions : result.quotients,
summary: typeof breakdown.summary === "string" ? breakdown.summary : null,
updatedAt: result.calculated_at || new Date().toISOString(),
}
: null,
latestSignals,
signalTimeline,
globalComparison: {
status: "placeholder",
percentile: null,
cohort: null,
sampleSize: null,
note: "Global comparison is reserved for the cohort-backed analytics release.",
},
};
}
async function userActivityAnalytics(userId: string) {
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",
userId,
generatedAt: new Date().toISOString(),
events,
activeMissions: activeMissions.map((item) => item.mission),
actions,
};
}
export const analyticsActor = actor({
options: { name: "Analytics Actor", icon: "chart-no-axes-column", noSleep: true },
state: { updatedAt: Date.now() },
actions: {
getPlatform: async (c) => {
c.state.updatedAt = Date.now();
return platformAnalytics();
},
getUserQscore: async (c, input: { userId: string }) => {
c.state.updatedAt = Date.now();
return userQscoreAnalytics(input.userId);
},
getUserActivity: async (c, input: { userId: string }) => {
c.state.updatedAt = Date.now();
return userActivityAnalytics(input.userId);
},
},
});

View File

@@ -0,0 +1 @@
export { analyticsActor } from "./analytics-actor.js";

View File

@@ -1,13 +1,35 @@
import { createOpenAI } from "@ai-sdk/openai";
import { streamText, tool } from "ai";
import { generateText, stepCountIs, streamText, tool } from "ai";
import { createClient } from "rivetkit/client";
import { z } from "zod";
import type { ConversationMessage } from "./types.js";
import { config } from "../../config.js";
import { listMissionDefinitions } from "../../missions/registry.js";
import { createMissionAction, listMissionActions } from "../../missions/actions.js";
import { getActiveMissionPg, listActiveMissionsPg, listMissionSuggestionsPg } from "../../grow/persistence.js";
import { listServiceCapabilities } from "../../workflows/service-capabilities.js";
import { getSubAgentModules } from "../../lib/prompt-loader.js";
import { buildMissionServiceRoute } from "../../services/service-registry.js";
const SYSTEM_PROMPT = `You are the GrowQR conversation agent.
Keep answers concise, practical, and focused on the user's goals.
When you learn durable information, call the memory tools. For now these tools
are intentionally stubbed so this actor can stay isolated and unwired.`;
Keep answers concise, practical, and focused on the user's active mission.
Use tools when you need mission state, registry capabilities, memory, or a service handoff.
Service tools prepare handoffs and mission actions; the interview, roleplay, and resume services own their detailed flows.
Style rules:
- Use ASCII punctuation only. Do not use em dash or en dash.
- Do not start with filler words like Perfect, Great, Absolutely, or Sure.
- For Daily Mission turns, ask one short direct question. Keep it under 24 words.`;
export type ConversationRuntimeContext = {
userId?: string;
conversationId?: string;
missionInstanceId?: string;
missionId?: string;
stageId?: string;
source?: string;
systemAddendum?: string;
};
function normalizeModel(model: string): string {
if (config.llmProvider === "opencode" && model.startsWith("opencode/")) {
@@ -31,46 +53,206 @@ export function getConversationModel() {
return conversationProvider.chat(normalizeModel(modelId));
}
export function buildModelMessages(messages: ConversationMessage[]) {
type ModelConversationMessage = Pick<ConversationMessage, "role" | "content">;
export function buildModelMessages(messages: ModelConversationMessage[]) {
return messages.map((message) => ({
role: message.role,
content: message.content,
}));
}
export function streamConversationResponse(messages: ConversationMessage[]) {
let _client: any | null = null;
function getRivetClient() {
return (_client ??= createClient<any>(config.rivetClientEndpoint));
}
function safeAgentRegistry() {
try {
return getSubAgentModules();
} catch {
return [
{ id: "interview", name: "Interview Agent", role: "Interview Coach", service: "interview-service", description: "Interview prep specialist.", toolNames: ["prepare_interview_handoff"] },
{ id: "roleplay", name: "Roleplay Agent", role: "Roleplay Coach", service: "roleplay-service", description: "Workplace conversation practice specialist.", toolNames: ["prepare_roleplay_handoff"] },
{ id: "resume", name: "Resume Agent", role: "Resume Agent", service: "resume-service", description: "Resume positioning and optimization specialist.", toolNames: ["prepare_resume_handoff"] },
{ id: "qscore", name: "Q Score Agent", role: "Q Score Analyst", service: "qscore-service", description: "Readiness score analyst.", toolNames: ["explain_qscore"] },
];
}
}
async function resolveMission(userId: string, missionInstanceId?: string) {
if (missionInstanceId) return getActiveMissionPg(userId, missionInstanceId);
const active = await listActiveMissionsPg(userId);
return active[0] ?? null;
}
function serviceHref(input: {
serviceId: "interview-service" | "roleplay-service" | "resume-service";
missionInstanceId: string;
missionId: string;
stageId?: string;
goal?: string;
}) {
return buildMissionServiceRoute(input);
}
function buildConversationTools(ctx: ConversationRuntimeContext = {}) {
const userId = ctx.userId;
return {
listMissionState: tool({
description: "Read mission snapshot, open actions, and suggestions for the current or requested mission.",
inputSchema: z.object({ missionInstanceId: z.string().optional() }),
execute: async ({ missionInstanceId }) => {
if (!userId) return { error: "missing_user_context" };
const active = await resolveMission(userId, missionInstanceId ?? ctx.missionInstanceId);
if (!active) return { mission: null, actions: [], suggestions: [] };
return {
mission: active.mission,
snapshot: active.snapshot,
actions: await listMissionActions(userId, { missionInstanceId: active.mission.instanceId }),
suggestions: await listMissionSuggestionsPg(userId, active.mission.instanceId),
};
},
}),
listRegistryCapabilities: tool({
description: "List deterministic registry missions, service capabilities, and specialist agents. This does not rank or generate missions.",
inputSchema: z.object({}),
execute: async () => ({
missions: listMissionDefinitions().map((mission) => ({
id: mission.id,
missionId: mission.missionId,
title: mission.title,
shortTitle: mission.shortTitle,
actorBacked: mission.actorBacked,
modules: mission.modules.map((module) => ({
id: module.id,
title: module.title,
role: module.role,
service: module.service,
})),
})),
services: listServiceCapabilities(),
agents: safeAgentRegistry(),
}),
}),
prepareServiceHandoff: tool({
description: "Prepare an interview, roleplay, or resume handoff as a mission action and return the UI route. Do not directly complete the service.",
inputSchema: z.object({
serviceId: z.enum(["interview-service", "roleplay-service", "resume-service"]),
missionInstanceId: z.string().optional(),
stageId: z.string().optional(),
title: z.string().optional(),
body: z.string().optional(),
goal: z.string().optional(),
}),
execute: async ({ serviceId, missionInstanceId, stageId, title, body, goal }) => {
if (!userId) return { error: "missing_user_context" };
const active = await resolveMission(userId, missionInstanceId ?? ctx.missionInstanceId);
if (!active) return { error: "mission_not_found" };
const selectedStageId = stageId ?? ctx.stageId ?? active.mission.currentStageId;
const href = serviceHref({
serviceId,
missionInstanceId: active.mission.instanceId,
missionId: active.mission.missionId,
stageId: selectedStageId,
goal: goal ?? active.mission.goal,
});
const agent = safeAgentRegistry().find((item) => item.service === serviceId);
const action = await createMissionAction({
userId,
missionInstanceId: active.mission.instanceId,
missionId: active.mission.missionId,
stageId: selectedStageId,
agentId: agent?.id ?? serviceId,
agentName: agent?.name ?? serviceId,
baseAgent: agent?.role,
serviceId,
toolName: `prepare_${serviceId.replace("-service", "")}_handoff`,
mode: "suggestion",
status: "queued",
title: title ?? `Open ${agent?.name ?? serviceId}`,
body: body ?? `Continue this mission in ${agent?.name ?? serviceId}.`,
prompt: goal ?? active.mission.goal,
payload: { href, goal: goal ?? active.mission.goal, source: "conversation-actor" },
idempotencyKey: `conversation-handoff:${active.mission.instanceId}:${selectedStageId ?? "mission"}:${serviceId}`,
priority: 25,
urgency: "today",
});
return { action, href, serviceId, missionInstanceId: active.mission.instanceId };
},
}),
askSubAgent: tool({
description: "Ask a specialist sub-agent for a focused answer without starting a service session.",
inputSchema: z.object({
agentId: z.enum(["interview", "roleplay", "resume", "qscore"]),
question: z.string(),
context: z.string().optional(),
}),
execute: async ({ agentId, question, context }) => {
const agent = safeAgentRegistry().find((item) => item.id === agentId);
const answer = await generateText({
model: getConversationModel(),
system: `You are ${agent?.name ?? agentId}, a GrowQR specialist. Be concise and practical. Do not start external tools.`,
prompt: `Question:\n${question}\n\nContext:\n${context ?? "No extra context."}`,
});
return { agent, answerMd: answer.text };
},
}),
readMemory: tool({
description: "Read a markdown memory file for this user.",
inputSchema: z.object({ path: z.string() }),
execute: async ({ path }) => {
if (!userId) return { error: "missing_user_context" };
return { memory: await getRivetClient().memoryActor.getOrCreate([userId]).read(path) };
},
}),
writeMemory: tool({
description: "Write a durable markdown memory file for this user.",
inputSchema: z.object({
path: z.string(),
contentMd: z.string(),
tags: z.array(z.string()).optional(),
}),
execute: async ({ path, contentMd, tags }) => {
if (!userId) return { error: "missing_user_context" };
const result = await getRivetClient().memoryActor.getOrCreate([userId]).write({ path, contentMd, tags });
return { path, queued: result.queued };
},
}),
};
}
export function streamConversationResponse(messages: ModelConversationMessage[], context: ConversationRuntimeContext = {}) {
const system = [SYSTEM_PROMPT, context.systemAddendum].filter(Boolean).join("\n\n");
if (context.source === "daily-mission-start") {
return streamText({
model: getConversationModel(),
system,
messages: buildModelMessages(messages),
});
}
return streamText({
model: getConversationModel(),
system: SYSTEM_PROMPT,
system,
messages: buildModelMessages(messages),
tools: {
readMemory: tool({
description: "Read a markdown memory file. Stubbed until memoryActor is wired.",
inputSchema: z.object({
path: z.string().describe("Memory path, e.g. /profile.md"),
}),
execute: async ({ path }) => ({
path,
found: false,
content: "",
note: "memoryActor is not wired yet",
}),
}),
writeMemory: tool({
description: "Write a markdown memory file. Stubbed until memoryActor is wired.",
inputSchema: z.object({
path: z.string(),
contentMd: z.string(),
reason: z.string().optional(),
}),
execute: async ({ path, contentMd, reason }) => ({
path,
bytes: contentMd.length,
reason,
saved: false,
note: "memoryActor is not wired yet",
}),
}),
},
tools: buildConversationTools(context),
stopWhen: stepCountIs(5),
});
}
export async function generateConversationResponse(messages: ModelConversationMessage[], context: ConversationRuntimeContext = {}) {
const system = [SYSTEM_PROMPT, context.systemAddendum].filter(Boolean).join("\n\n");
return generateText({
model: getConversationModel(),
system,
messages: buildModelMessages(messages),
tools: buildConversationTools(context),
stopWhen: stepCountIs(5),
});
}

View File

@@ -25,6 +25,10 @@ function conversationIdFromKey(key: unknown[]) {
return String(key[1] ?? key[0] ?? "default");
}
function userIdFromKey(key: unknown[]) {
return String(key[0] ?? "");
}
function toPublicMessage(row: typeof conversationMessages.$inferSelect): ConversationMessage {
return {
id: row.id,
@@ -118,7 +122,14 @@ export const conversationActor = actor({
c.broadcast("status", c.state.status);
try {
const result = streamConversationResponse(history);
const result = streamConversationResponse(history, {
userId: body.context?.userId ?? userIdFromKey(c.key),
conversationId,
missionInstanceId: body.context?.missionInstanceId,
missionId: body.context?.missionId,
stageId: body.context?.stageId,
source: body.context?.source,
});
let content = "";
for await (const delta of result.textStream) {

View File

@@ -18,6 +18,14 @@ export type ConversationMessage = {
export type ConversationQueueMessage = {
text: string;
sender?: string;
context?: {
userId?: string;
conversationId?: string;
missionInstanceId?: string;
missionId?: string;
stageId?: string;
source?: string;
};
};
export type ConversationResponseEvent = {

View File

@@ -11,6 +11,7 @@ import { getProjectionInsight } from "../../events/projectors/projection-agent.j
import { markGrowEventFailed, markGrowEventProcessed, markGrowEventProcessing } from "../../events/record-grow-event.js";
import { reducersForMission } from "../../missions/event-reducers.js";
import type { MissionArtifactPatch, MissionStagePatch } from "../../missions/reducer-types.js";
import { createMissionActionsFromPatches } from "../../missions/actions.js";
export type UserEventCommand = {
userId: string;
@@ -116,6 +117,24 @@ async function applyArtifactPatches(input: {
return created;
}
/**
* DB status guard for the actor run loop. A queued event is processable unless
* it is "failed" (terminal error) or "unresolved" (no userId). All other states
* — "pending" (first attempt), "processing" (crash recovery), and "processed"
* (synchronous projections already ran) — are allowed so the actor can run
* mission reducers that only exist in the actor. Projections are idempotent,
* and the in-memory processedEventIds guard prevents double-processing within
* one actor lifetime. The routing gate (shouldRouteGrowEvent) already prevents
* saturation from duplicate ingests; this guard is defense-in-depth.
*/
export function isProcessableStatus(
status: string | null | undefined,
): boolean {
return status === "pending" || status === "processing" || status === "processed";
}
export const userEventActor = actor({
options: { name: "User Event Parser", icon: "route", noSleep: true, actionTimeout: 300_000 },
state: { userId: "", processedCount: 0, processedEventIds: [] } as UserEventActorState,
@@ -142,17 +161,52 @@ export const userEventActor = actor({
const message = await loopCtx.queue.next("wait-event", { names: ["events"] });
const cmd = message.body as UserEventCommand;
await loopCtx.step(`process-event:${cmd.eventId}`, async () => {
if (loopCtx.state.processedEventIds.includes(cmd.eventId)) return;
await markGrowEventProcessing(cmd.eventId);
const [row] = await db
.select()
.from(growEvents)
.where(and(eq(growEvents.id, cmd.eventId), eq(growEvents.userId, cmd.userId)))
.limit(1);
if (!row) throw new Error(`grow event not found for user: ${cmd.eventId}`);
// Pre-flight skip: check the DB status without claiming. Terminal
// (processed/failed) or unresolved rows are advanced past immediately
// so one settled event does not block the rest of the queue. The actual
// claim (markGrowEventProcessing) happens INSIDE tryStep's run so the
// retry unit is atomic: each attempt re-checks status before claiming.
const [preflight] = await db
.select({ processingStatus: growEvents.processingStatus })
.from(growEvents)
.where(and(eq(growEvents.id, cmd.eventId), eq(growEvents.userId, cmd.userId)))
.limit(1);
if (!preflight || !isProcessableStatus(preflight.processingStatus)) {
await loopCtx.step(`skip-event:${cmd.eventId}`, async () => {
loopCtx.state.updatedAt = new Date().toISOString();
loopCtx.broadcast("updated", loopCtx.state);
});
return;
}
// Process with tryStep so a failure is caught (not rethrown) and the
// loop advances to the next message. Mirrors workflow-run-actor.ts.
const result = await loopCtx.tryStep({
name: `process-event:${cmd.eventId}`,
maxRetries: 3,
retryBackoffBase: 1_000,
retryBackoffMax: 30_000,
timeout: 300_000,
run: async () => {
if (loopCtx.state.processedEventIds.includes(cmd.eventId)) {
loopCtx.state.updatedAt = new Date().toISOString();
loopCtx.broadcast("updated", loopCtx.state);
return;
}
// Atomic guard+claim: re-check status then mark processing inside the
// retry unit. A prior attempt that crashed after claiming leaves the
// row "processing" — isProcessableStatus allows retry (projections
// are idempotent).
const [row] = await db
.select()
.from(growEvents)
.where(and(eq(growEvents.id, cmd.eventId), eq(growEvents.userId, cmd.userId)))
.limit(1);
if (!row) throw new Error(`grow event not found for user: ${cmd.eventId}`);
if (!isProcessableStatus(row.processingStatus)) return;
await markGrowEventProcessing(cmd.eventId);
try {
await applyServiceSessionProjection(row);
const qscoreResult = await applyQscoreProjection(row);
const activeRows = await listActiveMissionsPg(cmd.userId);
@@ -165,20 +219,21 @@ export const userEventActor = actor({
const client = loopCtx.client<any>();
for (const active of activeRows) {
const mission = active.mission;
const actorHandle = missionActorHandle(client, cmd.userId, mission);
if (!actorHandle) continue;
await actorHandle.ingestEvent({ eventId: row.id }).catch(() => undefined);
const reducers = reducersForMission(mission.missionId);
if (!reducers.length) continue;
for (const reducer of reducers) {
const reduceCtx = { userId: cmd.userId, activeMission: mission, event: row, qscoreSignals: qscoreResult.signals, insight };
if (!reducer.accepts(reduceCtx)) continue;
const reduction = reducer.reduce(reduceCtx);
if (!reduction.stagePatches.length && !reduction.artifacts.length && !reduction.eventMessage) continue;
const actorHandle = missionActorHandle(client, cmd.userId, mission);
if (!actorHandle) continue;
if (!reduction.stagePatches.length && !reduction.artifacts.length && !reduction.actions.length && !reduction.eventMessage) continue;
if (reduction.eventMessage) {
await actorHandle.recordEvent({ type: row.type, message: reduction.eventMessage, payload: { sourceEventId: row.id } });
}
let snapshot: MissionSnapshot | undefined = reduction.stagePatches.length ? (await applyStagePatches(actorHandle, reduction.stagePatches) ?? undefined) : undefined;
if (reduction.stagePatches.length) await applyStagePatches(actorHandle, reduction.stagePatches);
await applyArtifactPatches({
actorHandle,
userId: cmd.userId,
@@ -188,6 +243,14 @@ export const userEventActor = actor({
externalId: typeof row.correlation?.sessionId === "string" ? row.correlation.sessionId : undefined,
patches: reduction.artifacts,
});
if (reduction.actions.length) {
await createMissionActionsFromPatches({
userId: cmd.userId,
mission,
eventId: row.id,
patches: reduction.actions,
});
}
const finalSnapshot = (await actorHandle.getState()) as MissionSnapshot;
await upsertActiveMissionPg(cmd.userId, summarizeMissionSnapshot(finalSnapshot), finalSnapshot);
}
@@ -201,14 +264,26 @@ export const userEventActor = actor({
loopCtx.state.processedEventIds = [cmd.eventId, ...loopCtx.state.processedEventIds.filter((id) => id !== cmd.eventId)].slice(0, 500);
loopCtx.broadcast("eventProcessed", { eventId: cmd.eventId, userId: cmd.userId });
loopCtx.broadcast("updated", loopCtx.state);
} catch (err) {
await markGrowEventFailed(cmd.eventId, err);
loopCtx.state.lastError = err instanceof Error ? err.message : String(err);
},
catch: ["timeout", "exhausted"],
});
if (!result.ok) {
// Per-event failure: mark terminal and advance. The loop continues to
// the next queued message — one bad event does not block the rest.
await loopCtx.step(`record-failure:${cmd.eventId}`, async () => {
await markGrowEventFailed(cmd.eventId, result.failure.error);
loopCtx.state.lastError = result.failure.error instanceof Error
? result.failure.error.message
: String(result.failure.error);
loopCtx.state.updatedAt = new Date().toISOString();
loopCtx.broadcast("updated", loopCtx.state);
throw err;
}
});
});
}
});
}, {
onError: async (_ctx, event) => {
console.error("user-event-actor workflow error", event);
},
}),
});

View File

@@ -160,6 +160,66 @@ export const interviewToOfferMissionActor = actor({
return entry;
},
ingestEvent: (c, input: { eventId: string }) => {
ensureInitialized(c.state);
const entry: MissionEvent = {
id: eventId(),
type: "mission.event_ingested",
message: `Event ${input.eventId} ingested by mission runtime.`,
payload: { eventId: input.eventId },
createdAt: nowIso(),
};
c.state.events.unshift(entry);
c.state.updatedAt = entry.createdAt;
c.broadcast("eventAdded", entry);
c.broadcast("updated", c.state);
return c.state;
},
planNextActions: (c, input: { reason?: string } = {}) => {
ensureInitialized(c.state);
const active = c.state.stages.find((stage) => stage.status === "ready" || stage.status === "in_progress" || stage.status === "blocked");
return {
missionInstanceId: c.state.instanceId,
missionId: c.state.missionId,
currentStageId: active?.id,
reason: input.reason ?? "manual",
recommendation: active ? `Focus next on ${active.title}.` : "No open stage requires action right now.",
};
},
runDailyScrum: (c, input: { trigger?: "manual" | "nightly" } = {}) => {
ensureInitialized(c.state);
const active = c.state.stages.find((stage) => stage.status === "ready" || stage.status === "in_progress" || stage.status === "blocked");
const entry: MissionEvent = {
id: eventId(),
type: "mission.daily_scrum.completed",
message: active ? `Daily scrum: next focus is ${active.title}.` : "Daily scrum: mission has no blocked action right now.",
payload: { trigger: input.trigger ?? "manual", currentStageId: active?.id },
createdAt: nowIso(),
};
c.state.events.unshift(entry);
c.state.updatedAt = entry.createdAt;
c.broadcast("eventAdded", entry);
c.broadcast("updated", c.state);
return { snapshot: c.state, summary: entry.message };
},
queueAction: (c, input: { actionId: string; title?: string }) => {
ensureInitialized(c.state);
return { queued: true, actionId: input.actionId, missionInstanceId: c.state.instanceId, title: input.title };
},
runAction: (c, input: { actionId: string }) => {
ensureInitialized(c.state);
return { started: true, actionId: input.actionId, missionInstanceId: c.state.instanceId };
},
resolveHitl: (c, input: { actionId: string; resolution: string; input?: Record<string, unknown> }) => {
ensureInitialized(c.state);
return { resolved: true, actionId: input.actionId, resolution: input.resolution, missionInstanceId: c.state.instanceId };
},
updateStage: (c, input: { stageId: string; status?: MissionStage["status"]; progressPercent?: number; outputSummary?: string }) => {
ensureInitialized(c.state);
const stage = c.state.stages.find((item) => item.id === input.stageId);

View File

@@ -166,6 +166,67 @@ export function createMissionActor(options: {
return entry;
},
ingestEvent: (c, input: { eventId: string }) => {
ensureInitialized(c.state);
const entry: MissionEvent = {
id: eventId(),
type: "mission.event_ingested",
message: `Event ${input.eventId} ingested by mission runtime.`,
payload: { eventId: input.eventId },
createdAt: nowIso(),
};
c.state.events.unshift(entry);
c.state.updatedAt = entry.createdAt;
c.broadcast("eventAdded", entry);
c.broadcast("updated", c.state);
return c.state;
},
planNextActions: (c, input: { reason?: string } = {}) => {
ensureInitialized(c.state);
const blocked = c.state.stages.find((stage) => stage.status === "blocked");
const active = blocked ?? c.state.stages.find((stage) => stage.status === "ready" || stage.status === "in_progress");
return {
missionInstanceId: c.state.instanceId,
missionId: c.state.missionId,
currentStageId: active?.id,
reason: input.reason ?? "manual",
recommendation: active ? `Focus next on ${active.title}.` : "No open stage requires action right now.",
};
},
runDailyScrum: (c, input: { trigger?: "manual" | "nightly" } = {}) => {
ensureInitialized(c.state);
const recommendation = c.state.stages.find((stage) => stage.status === "ready" || stage.status === "in_progress" || stage.status === "blocked");
const entry: MissionEvent = {
id: eventId(),
type: "mission.daily_scrum.completed",
message: recommendation ? `Daily scrum: next focus is ${recommendation.title}.` : "Daily scrum: mission has no blocked action right now.",
payload: { trigger: input.trigger ?? "manual", currentStageId: recommendation?.id },
createdAt: nowIso(),
};
c.state.events.unshift(entry);
c.state.updatedAt = entry.createdAt;
c.broadcast("eventAdded", entry);
c.broadcast("updated", c.state);
return { snapshot: c.state, summary: entry.message };
},
queueAction: (c, input: { actionId: string; title?: string }) => {
ensureInitialized(c.state);
return { queued: true, actionId: input.actionId, missionInstanceId: c.state.instanceId, title: input.title };
},
runAction: (c, input: { actionId: string }) => {
ensureInitialized(c.state);
return { started: true, actionId: input.actionId, missionInstanceId: c.state.instanceId };
},
resolveHitl: (c, input: { actionId: string; resolution: string; input?: Record<string, unknown> }) => {
ensureInitialized(c.state);
return { resolved: true, actionId: input.actionId, resolution: input.resolution, missionInstanceId: c.state.instanceId };
},
updateStage: (c, input: { stageId: string; status?: MissionStage["status"]; progressPercent?: number; outputSummary?: string }) => {
ensureInitialized(c.state);
const stage = c.state.stages.find((item) => item.id === input.stageId);

View File

@@ -6,6 +6,8 @@ import { conversationActor } from "./conversation/index.js";
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,
@@ -18,6 +20,8 @@ export const registry = setup({
use: {
growActor,
userEventActor,
analyticsActor,
curatorActor,
conversationActor,
memoryActor,
interviewToOfferMissionActor,

View File

@@ -189,7 +189,7 @@ function buildUnifiedTools(): Array<{
type: "function" as const,
function: {
name: "start_interview_session",
description: "Create a real interview practice session via the Interview Agent / interview-service microservice.",
description: "Create a real mock interview session via the interview-service microservice.",
parameters: {
type: "object",
properties: { goal: { type: "string" } },
@@ -201,7 +201,7 @@ function buildUnifiedTools(): Array<{
type: "function" as const,
function: {
name: "start_roleplay_session",
description: "Create a real roleplay practice session via the Roleplay Agent / roleplay-service microservice.",
description: "Create a real mock roleplay session via the roleplay-service microservice.",
parameters: {
type: "object",
properties: { goal: { type: "string" } },
@@ -213,7 +213,7 @@ function buildUnifiedTools(): Array<{
type: "function" as const,
function: {
name: "compute_qscore",
description: "Compute or refresh the user's Q-Score via the Q Score Agent / qscore-service microservice.",
description: "Compute or refresh the user's Q Score via the qscore-service microservice.",
parameters: {
type: "object",
properties: {},
@@ -225,7 +225,7 @@ function buildUnifiedTools(): Array<{
type: "function" as const,
function: {
name: "analyze_resume",
description: "Analyze the user's resume using the Resume Agent microservice. Returns completeness score, skill gaps, and optimization recommendations.",
description: "Analyze the user's resume using the Resume Building microservice. Returns completeness score, skill gaps, and optimization recommendations.",
parameters: {
type: "object",
properties: {
@@ -253,7 +253,7 @@ function buildUnifiedTools(): Array<{
type: "function" as const,
function: {
name: "start_interview_to_offer",
description: "Start the Interview-to-Offer Accelerator workflow. This is a guided end-to-end pipeline: (1) Analyze & tailor resume for the role, (2) Create interview practice session with the Interview Agent, (3) Create roleplay session with Roleplay Agent, (4) Compute Q-Score readiness. Use this when the user has a specific interview scheduled and wants comprehensive preparation.",
description: "Start the Interview-to-Offer Accelerator workflow. This is a guided end-to-end pipeline: (1) Analyze and tailor the resume for the role, (2) Create mock interview practice, (3) Create mock roleplay practice, (4) Compute Q Score readiness. Use this when the user has a specific interview scheduled and wants comprehensive preparation.",
parameters: {
type: "object",
properties: {
@@ -563,7 +563,7 @@ export const userActor = actor({
appendTimelineEvent(
c.state,
{ id: "grow", name: "Grow Agent" },
{ id: "grow", name: "Grow" },
"workflow",
`${getWorkflowDefinition(workflowId)?.title ?? "Workflow"} started.`,
);
@@ -581,14 +581,14 @@ export const userActor = actor({
pauseWorkflow: async (c) => {
c.state.workflowStatus = "paused";
appendTimelineEvent(c.state, { id: "grow", name: "Grow Agent" }, "workflow", "Workflow paused.");
appendTimelineEvent(c.state, { id: "grow", name: "Grow" }, "workflow", "Workflow paused.");
c.broadcast("workflow.updated", workflowSnapshot(c.state));
return c.state;
},
resumeWorkflow: async (c) => {
c.state.workflowStatus = "running";
appendTimelineEvent(c.state, { id: "grow", name: "Grow Agent" }, "workflow", "Workflow resumed.");
appendTimelineEvent(c.state, { id: "grow", name: "Grow" }, "workflow", "Workflow resumed.");
c.broadcast("workflow.updated", workflowSnapshot(c.state));
return c.state;
},
@@ -753,7 +753,7 @@ async function dispatchUnifiedTool(
c.state.modules = makeModules();
c.state.createdAt = now();
c.state.updatedAt = now();
appendTimelineEvent(c.state, { id: "grow", name: "Grow Agent" }, "workflow", "Workflow started via LLM tool.");
appendTimelineEvent(c.state, { id: "grow", name: "Grow" }, "workflow", "Workflow started via LLM tool.");
c.broadcast("workflow.updated", workflowSnapshot(c.state));
return { ok: true, workflowId: c.state.workflowId, goal };
}
@@ -799,7 +799,7 @@ async function dispatchUnifiedTool(
case "start_roleplay_session": {
const goal = String(input.goal ?? "");
const roleplayModule = getSubAgentModule("roleplay");
if (!roleplayModule?.service) return { ok: false, error: "Roleplay Agent module not available" };
if (!roleplayModule?.service) return { ok: false, error: "Mock Roleplay module not available" };
const result = await runServiceAgentProbe(
{ id: roleplayModule.id, name: roleplayModule.name, role: roleplayModule.role, kind: "microservice", description: roleplayModule.description, service: roleplayModule.service },
{ userId, goal },
@@ -855,14 +855,14 @@ async function dispatchUnifiedTool(
c.state.createdAt = now();
c.state.updatedAt = now();
appendTimelineEvent(c.state, { id: "grow", name: "Grow Agent" }, "workflow", `Interview-to-Offer workflow started for: ${goal}`);
appendTimelineEvent(c.state, { id: "grow", name: "Grow" }, "workflow", `Interview-to-Offer workflow started for: ${goal}`);
// Step 1: Resume Agent — analyze and tailor
// Step 1: Resume Building — analyze and tailor
const resumeModule = getSubAgentModule("resume");
const resumeMod = c.state.modules.find(m => m.id === "resume");
if (resumeMod && resumeModule) {
resumeMod.status = "running";
appendTimelineEvent(c.state, resumeMod, "module", "Resume Agent analyzing your profile...");
appendTimelineEvent(c.state, resumeMod, "module", "Resume Building is analyzing your profile...");
c.broadcast("workflow.updated", workflowSnapshot(c.state));
try {
@@ -875,18 +875,18 @@ async function dispatchUnifiedTool(
appendTimelineEvent(c.state, resumeMod, "module", resumeResult.summary);
} catch (err) {
resumeMod.status = "blocked";
appendTimelineEvent(c.state, resumeMod, "module", `Resume Agent failed: ${err instanceof Error ? err.message : String(err)}`);
appendTimelineEvent(c.state, resumeMod, "module", `Resume Building failed: ${err instanceof Error ? err.message : String(err)}`);
}
}
c.broadcast("workflow.updated", workflowSnapshot(c.state));
// Step 2: Interview Agent — create interview session
// Step 2: Mock Interview — create interview session
const interviewModule = getSubAgentModule("interview");
const interviewMod = c.state.modules.find(m => m.id === "interview");
if (interviewMod && interviewModule?.service) {
interviewMod.status = "running";
appendTimelineEvent(c.state, interviewMod, "module", "Interview Agent creating interview practice session...");
appendTimelineEvent(c.state, interviewMod, "module", "Mock Interview is creating an interview practice session...");
c.broadcast("workflow.updated", workflowSnapshot(c.state));
try {
@@ -905,12 +905,12 @@ async function dispatchUnifiedTool(
c.broadcast("workflow.updated", workflowSnapshot(c.state));
// Step 3: Roleplay Agent — create roleplay session
// Step 3: Mock Roleplay — create roleplay session
const roleplayModule = getSubAgentModule("roleplay");
const roleplayMod = c.state.modules.find(m => m.id === "roleplay");
if (roleplayMod && roleplayModule?.service) {
roleplayMod.status = "running";
appendTimelineEvent(c.state, roleplayMod, "module", "Roleplay Agent creating roleplay scenario...");
appendTimelineEvent(c.state, roleplayMod, "module", "Mock Roleplay is creating a practice scenario...");
c.broadcast("workflow.updated", workflowSnapshot(c.state));
try {
@@ -923,18 +923,18 @@ async function dispatchUnifiedTool(
appendTimelineEvent(c.state, roleplayMod, "module", roleplayResult.summary);
} catch (err) {
roleplayMod.status = "blocked";
appendTimelineEvent(c.state, roleplayMod, "module", `Roleplay Agent session failed: ${err instanceof Error ? err.message : String(err)}`);
appendTimelineEvent(c.state, roleplayMod, "module", `Mock Roleplay session failed: ${err instanceof Error ? err.message : String(err)}`);
}
}
c.broadcast("workflow.updated", workflowSnapshot(c.state));
// Step 4: Q Score Agent — compute Q-Score
// Step 4: Q Score — compute readiness
const qscoreModule = getSubAgentModule("qscore");
const qscoreMod = c.state.modules.find(m => m.id === "qscore");
if (qscoreMod && qscoreModule?.service) {
qscoreMod.status = "running";
appendTimelineEvent(c.state, qscoreMod, "module", "Q Score Agent computing your readiness Q-Score...");
appendTimelineEvent(c.state, qscoreMod, "module", "Q Score is computing your readiness score...");
c.broadcast("workflow.updated", workflowSnapshot(c.state));
try {
@@ -947,7 +947,7 @@ async function dispatchUnifiedTool(
appendTimelineEvent(c.state, qscoreMod, "module", qscoreResult.summary);
} catch (err) {
qscoreMod.status = "blocked";
appendTimelineEvent(c.state, qscoreMod, "module", `Q-Score computation failed: ${err instanceof Error ? err.message : String(err)}`);
appendTimelineEvent(c.state, qscoreMod, "module", `Q Score computation failed: ${err instanceof Error ? err.message : String(err)}`);
}
}

View File

@@ -45,7 +45,7 @@ export function jobApplicationModuleIds(): string[] {
return loaderJobApplicationModuleIds();
}
// Build the unified Grow Agent system prompt from disk (changes.md §3).
// Build the unified Grow system prompt from disk (changes.md §3).
export function buildUnifiedSystemPrompt(): string {
return getUnifiedSystemPrompt();
}

449
src/agents/daily-mission.ts Normal file
View File

@@ -0,0 +1,449 @@
import { generateText } from "ai";
import { z } from "zod";
import { getConversationModel, streamConversationResponse } from "../actors/conversation/agent.js";
export const dailyMissionTaskSchema = z.object({
day: z.number().optional(),
questId: z.string().optional(),
questTitle: z.string(),
subtaskIndex: z.number().optional(),
subtask: z.string(),
service: z.string().optional(),
route: z.string().optional(),
intro: z.string().optional(),
context: z.array(z.object({ label: z.string(), value: z.string() })).optional(),
signals: z.array(z.string()).optional(),
});
export const dailyMissionMessageSchema = z.object({
role: z.enum(["user", "assistant"]),
content: z.string().min(1).max(4000),
});
export type DailyMissionTask = z.infer<typeof dailyMissionTaskSchema>;
export type DailyMissionMessage = z.infer<typeof dailyMissionMessageSchema>;
const dailyMissionResponseSchema = z.object({
reply: z.string(),
completed: z.boolean().default(false),
updateSummary: z.string().optional(),
actionLabel: z.string().optional(),
actionRoute: z.string().optional(),
});
export type DailyMissionResult = z.infer<typeof dailyMissionResponseSchema>;
type DailyMissionAgentInput = {
userId: string;
task: DailyMissionTask;
messages: DailyMissionMessage[];
missionInstanceId?: string;
missionId?: string;
stageId?: string;
conversationId?: string;
};
function stripJsonFence(text: string) {
return text
.trim()
.replace(/^```(?:json)?\s*/i, "")
.replace(/\s*```$/i, "")
.trim();
}
function parseDailyMissionResponse(text: string) {
const normalize = (value: z.infer<typeof dailyMissionResponseSchema>) => {
const nested = maybeParseJsonReply(value.reply);
return nested ? { ...value, ...nested } : value;
};
try {
return normalize(dailyMissionResponseSchema.parse(JSON.parse(stripJsonFence(text))));
} catch {
return {
reply: cleanAssistantReply(text) || "I could not prepare the next step. Try again.",
completed: false,
updateSummary: undefined,
actionLabel: undefined,
actionRoute: undefined,
};
}
}
function maybeParseJsonReply(text: string) {
try {
return dailyMissionResponseSchema.partial().parse(JSON.parse(stripJsonFence(text)));
} catch {
return undefined;
}
}
function cleanAssistantReply(text: string) {
const stripped = stripJsonFence(text);
const nested = maybeParseJsonReply(stripped);
if (nested?.reply) return nested.reply;
const match = stripped.match(/^\s*\{[\s\S]*"reply"\s*:\s*"([\s\S]*?)"[\s\S]*\}\s*$/);
const captured = match?.[1];
if (!captured) return stripped.trim();
try {
return JSON.parse(`"${captured}"`);
} catch {
return captured.replace(/\\"/g, '"').replace(/\\u2011/g, "-").trim();
}
}
function isInterviewMission(task: DailyMissionTask) {
const service = (task.service ?? "").toLowerCase();
const routePath = task.route ? new URL(task.route, "https://growqr.local").pathname.toLowerCase() : "";
const text = [task.questTitle, task.subtask].filter(Boolean).join(" ").toLowerCase();
if (service.includes("resume") || routePath.includes("/agents/resume")) return false;
if (service.includes("roleplay") || routePath.includes("/agents/roleplay")) return false;
if (service.includes("q score") || service.includes("qscore") || routePath.includes("/agents/qscore")) return false;
return service.includes("interview") || routePath.includes("/agents/interview") || text.includes("mock question");
}
function getInterviewActionRoute(task: DailyMissionTask) {
const source = new URL(task.route ?? "/agents/interview", "https://growqr.local");
const roleFromContext = task.context?.find((item) => item.label.toLowerCase().includes("role"))?.value;
const params = new URLSearchParams();
params.set("role", source.searchParams.get("role") ?? roleFromContext ?? "Product Manager");
params.set("type", source.searchParams.get("type") ?? "behavioral");
params.set("persona", "payal");
params.set("duration", "5");
params.set("difficulty", source.searchParams.get("difficulty") ?? "medium");
params.set("media", "video");
params.set("source", "daily-mission");
return `/agents/interview?${params.toString()}`;
}
function compactAnswer(answer: string) {
return answer.length > 180 ? `${answer.slice(0, 177).trimEnd()}...` : answer;
}
function isConfidenceCheck(task: DailyMissionTask) {
const haystack = [task.questTitle, task.subtask, task.service, task.intro].filter(Boolean).join(" ").toLowerCase();
return haystack.includes("confidence check") || (haystack.includes("qx") && haystack.includes("confidence"));
}
function buildDailyMissionSystemPrompt(task: DailyMissionTask) {
return `You are Daily Mission, a focused GrowQR dashboard agent.
The user clicked on the main task called ${task.questTitle}${task.intro ? ` (${task.intro})` : ""} and the subtask within it called ${task.subtask}. You are going to act as an interface for the user to complete this subtask. Garner the right questions and get the input from the user.
The frontend will send "Start". From there onwards, start with your first message to the user.
Rules:
- Ask one short question or give one short action at a time.
- Do not start a larger mission, do not pitch other workflows, and do not send the user away.
- Keep the tone warm, practical, and easy to answer.
- When the user answer is enough to satisfy the subtask, mark the task complete by returning completed=true.
- Return a single JSON object only. Do not wrap the object in a string. Shape: {"reply":"message to show","completed":false,"updateSummary":"optional short saved update"}.`;
}
function buildDailyMissionStreamingSystemPrompt(task: DailyMissionTask) {
return `You are the GrowQR conversation actor attached to a mission actor.
The user clicked a mission task card:
${formatTask(task)}
Your job is to make the mission feel alive:
- The mission actor owns progress and completion.
- You own the chat turn and can prepare service handoffs.
- The service capability is ${task.service ?? "unknown service"} at ${task.route ?? "unknown route"}.
Rules:
- Plain text only. Do not return JSON.
- On the first "start" message, do not use a template line like "This is a service handoff".
- Ask the next natural question required to advance this exact mission stage.
- If the service is Resume, ask for the resume text/file and target role or section in a natural way.
- If the service is Interview, ask for role, round type, and the one thing they want to improve.
- If the service is Roleplay, ask for scenario, counterpart, and desired outcome.
- Keep it short, warm, and specific.`;
}
function withDailyMissionActionDefaults(task: DailyMissionTask, result: z.infer<typeof dailyMissionResponseSchema>) {
if (!result.completed || !isInterviewMission(task)) return result;
return {
...result,
actionLabel: result.actionLabel ?? "Generate room",
actionRoute: result.actionRoute ?? getInterviewActionRoute(task),
};
}
function serviceStartReply(task: DailyMissionTask) {
const service = (task.service ?? "").toLowerCase();
const routePath = task.route ? new URL(task.route, "https://growqr.local").pathname.toLowerCase() : "";
if (service.includes("resume") || routePath.includes("/agents/resume")) {
return "This is a Resume service handoff. Tell me the target role or resume section you want to improve, and I will save it on this mission stage.";
}
if (service.includes("roleplay") || routePath.includes("/agents/roleplay")) {
return "This is a Roleplay service handoff. Tell me the scenario you want to practice and the outcome you want from the conversation.";
}
if (service.includes("q score") || service.includes("qscore") || routePath.includes("/agents/qscore")) {
return "This is a Q Score check. Tell me the signal you want to improve or the readiness question you want scored.";
}
return undefined;
}
function latestUserMessage(messages: DailyMissionMessage[]) {
return [...messages].reverse().find((message) => message.role === "user")?.content.trim() ?? "";
}
function firstQuestionForTask(task: DailyMissionTask) {
const subtask = task.subtask.toLowerCase();
const title = task.questTitle.toLowerCase();
const intro = (task.intro ?? "").toLowerCase();
const service = (task.service ?? "").toLowerCase();
const routePath = task.route ? new URL(task.route, "https://growqr.local").pathname.toLowerCase() : "";
const isResume = service.includes("resume") || routePath.includes("/agents/resume");
const isInterview = service.includes("interview") || routePath.includes("/agents/interview");
const isRoleplay = service.includes("roleplay") || routePath.includes("/agents/roleplay");
const isPlanner = service.includes("mission planner") || title.includes("target role") || intro.includes("target role") || title.includes("career transition");
if (subtask.includes("save") || subtask.includes("next action")) {
if (isPlanner) return "What next career move should I save: target role, skill gap, or outreach action?";
if (isResume) return "What next action should I save: revise bullets, fill gaps, or generate talking points?";
if (isInterview) return "What interview prep action should I save for the student to do next?";
if (isRoleplay) return "What roleplay action should I save for the next practice round?";
return `What next action should I save for "${task.subtask}"?`;
}
if (subtask.includes("handoff") || subtask.includes("prepare")) {
if (isPlanner) return "Should I prepare a role shortlist, transition plan, or skill-gap plan?";
if (isResume) return "Which resume handoff should I prepare: role-fit proof, gap scan, or talking points?";
if (isInterview) return "What interview setup should I prepare: role, round type, and difficulty?";
if (isRoleplay) return "What roleplay setup should I prepare: scenario, counterpart, and outcome?";
return `What handoff should I prepare for "${task.subtask}"?`;
}
if (isResume) {
return "Please share your resume text or file and the target role.";
}
if (isInterview) {
return "What role and interview round should this prep focus on?";
}
if (isRoleplay) {
return "What conversation scenario do you want to practice?";
}
if (isPlanner) {
if (subtask.includes("target role")) {
return "What is your current role, target role, and biggest transition constraint?";
}
if (subtask.includes("requirements")) {
return "What requirement should we check first: skills, experience, location, or timeline?";
}
if (subtask.includes("review") || subtask.includes("recommendation")) {
return "Which role option should we review first, and what matters most to you?";
}
return "What target role are you considering, and what constraint should I account for?";
}
if (subtask.includes("target role")) {
return "What is your current role, target role, and biggest constraint?";
}
if (subtask.includes("requirements")) {
return "Which requirement should we check first: skills, experience, location, or timeline?";
}
return `What is the key detail for "${task.subtask}"?`;
}
function buildConversationActorMessages(input: DailyMissionAgentInput) {
const latest = latestUserMessage(input.messages);
const isStart = latest.toLowerCase() === "start";
const transcript = input.messages
.filter((message) => message.content.trim().toLowerCase() !== "start")
.slice(-10)
.map((message) => `${message.role === "user" ? "User" : "Assistant"}: ${message.content}`)
.join("\n");
return [{
id: `daily-mission-${Date.now()}-${Math.random().toString(16).slice(2)}`,
conversationId: input.conversationId ?? "daily-mission",
role: "user" as const,
sender: "Daily Mission UI",
createdAt: Date.now(),
content: `The user opened a mission-linked Daily Mission chat.
Mission task:
${formatTask(input.task)}
Mission ids:
- missionInstanceId: ${input.missionInstanceId ?? "not linked yet"}
- missionId: ${input.missionId ?? "unknown"}
- stageId: ${input.stageId ?? "unknown"}
${transcript ? `Conversation so far:\n${transcript}\n\n` : ""}${isStart
? "This is the first assistant turn. Ask one short direct question that advances this stage. No greeting. No filler. No em dash. No long paragraph. For resume, ask for resume text/file and target role."
: `The user just replied: ${latest}\nRespond as the GrowQR conversation agent. If the answer is enough for this subtask, acknowledge what will be saved. Keep it concise. Use ASCII punctuation only.`}`,
}];
}
function runInterviewRoomSetup(task: DailyMissionTask, messages: DailyMissionMessage[]) {
const latestUser = latestUserMessage(messages);
const subtask = task.subtask.toLowerCase();
const actionRoute = getInterviewActionRoute(task);
if (latestUser.toLowerCase() === "start") {
if (subtask.includes("generate") || subtask.includes("jump") || subtask.includes("start")) {
return {
reply: "The interview room setup is ready. Review the details and tap Generate room to open the interview UI.",
completed: true,
updateSummary: "Interview room setup ready.",
actionLabel: "Generate room",
actionRoute,
};
}
if (subtask.includes("pressure") || subtask.includes("difficulty")) {
return {
reply: "Pick the pressure level for the student: easy, medium, or hard.",
completed: false,
updateSummary: undefined,
actionLabel: undefined,
actionRoute: undefined,
};
}
return {
reply: "What should this interview room be for? Share the role, round type, and one thing the student wants to improve.",
completed: false,
updateSummary: undefined,
actionLabel: undefined,
actionRoute: undefined,
};
}
const updateSummary = compactAnswer(latestUser);
return {
reply: `Got it. I saved this for the student: ${updateSummary}. Tap Generate room when you are ready to open the interview UI.`,
completed: true,
updateSummary,
actionLabel: "Generate room",
actionRoute,
};
}
function formatTask(task: DailyMissionTask) {
const lines = [
task.day ? `Sprint day: ${task.day}` : undefined,
`Quest: ${task.questTitle}`,
`Subtask: ${task.subtask}`,
task.service ? `Service: ${task.service}` : undefined,
task.route ? `Service route: ${task.route}` : undefined,
task.intro ? `Quest intent: ${task.intro}` : undefined,
task.context?.length
? `Visible context: ${task.context.map((item) => `${item.label}: ${item.value}`).join("; ")}`
: undefined,
task.signals?.length ? `Signals to improve: ${task.signals.join(", ")}` : undefined,
].filter(Boolean);
return lines.map((line) => `- ${line}`).join("\n");
}
export async function runDailyMissionAgent(input: DailyMissionAgentInput) {
const started = input.messages.some(
(message) => message.role === "user" && message.content.trim().toLowerCase() === "start",
);
if (!started) {
return {
reply: "I am ready to begin this daily mission.",
completed: false,
updateSummary: undefined,
actionLabel: undefined,
actionRoute: undefined,
};
}
if (isInterviewMission(input.task)) {
return runInterviewRoomSetup(input.task, input.messages);
}
const transcript = input.messages
.slice(-12)
.map((message) => `${message.role === "user" ? "Student" : "Daily Mission"}: ${message.content}`)
.join("\n");
try {
const result = await generateText({
model: getConversationModel(),
system: buildDailyMissionSystemPrompt(input.task),
prompt: `User id: ${input.userId}
Daily task context:
${formatTask(input.task)}
Conversation so far:
${transcript}`,
});
return withDailyMissionActionDefaults(input.task, parseDailyMissionResponse(result.text));
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.warn("daily mission model failed; returning unavailable state", { message });
return {
reply: "Daily mission is temporarily unavailable right now. No progress was saved. Please retry in a moment.",
completed: false,
updateSummary: undefined,
actionLabel: undefined,
actionRoute: undefined,
};
}
}
export async function streamDailyMissionAgent(input: DailyMissionAgentInput) {
const started = input.messages.some(
(message) => message.role === "user" && message.content.trim().toLowerCase() === "start",
);
if (!started) {
return { kind: "static" as const, result: await runDailyMissionAgent(input) };
}
const latest = latestUserMessage(input.messages);
const userMessagesAfterStart = input.messages.filter((message) => message.role === "user");
const isStart = latest.toLowerCase() === "start";
if (isStart) {
return {
kind: "static" as const,
result: {
reply: firstQuestionForTask(input.task),
completed: false,
updateSummary: undefined,
actionLabel: undefined,
actionRoute: undefined,
},
};
}
const result = streamConversationResponse(buildConversationActorMessages(input), {
userId: input.userId,
conversationId: input.conversationId,
missionInstanceId: input.missionInstanceId,
missionId: input.missionId,
stageId: input.stageId,
source: isStart ? "daily-mission-start" : "daily-mission",
});
return {
kind: "stream" as const,
textStream: result.textStream,
finalize: (reply: string): DailyMissionResult => {
const completed = !isStart && userMessagesAfterStart.length > 1 && reply.trim().length > 0;
return {
reply: cleanAssistantReply(reply),
completed,
updateSummary: completed ? compactAnswer(latest) : undefined,
actionLabel: undefined,
actionRoute: undefined,
};
},
};
}

View File

@@ -26,7 +26,7 @@ export const requireUser = createMiddleware<AuthContext>(async (c, next) => {
const auth = c.req.header("authorization") ?? "";
const token = auth.replace(/^Bearer\s+/i, "").trim();
// Service-to-service path (Grow Agent actor calling backend).
// Service-to-service path (Grow stack calling backend).
// Header `x-growqr-user` is REQUIRED so we can scope the call.
const trustedServiceTokens = new Set(
[

View File

@@ -7,7 +7,41 @@ function required(name: string, fallback?: string): string {
}
return v;
}
function parseBool(value: string | undefined, fallback: boolean): boolean {
if (value === undefined || value === "") return fallback;
return value.toLowerCase() === "true" || value === "1";
}
// ── Redis config resolver (pure, testable without module reload) ────────────
// Exported so tests can assert gating/URL-inheritance behavior with synthetic
// env objects, avoiding ESM module-cache hacks.
export type RedisConfig = {
growEventsRedisEnabled: boolean;
legacyServiceRedisEnabled: boolean;
growEventsRedisUrl: string;
interviewRedisUrl: string;
roleplayRedisUrl: string;
resumeRedisUrl: string;
coursesRedisUrl: string;
};
/**
* Resolve all Redis-related config from an env record. Both ingestion paths
* are default-off; legacy URLs resolve ONLY from their own explicit env var
* (never inherited from GROW_EVENTS_REDIS_URL or the generic REDIS_URL).
*/
export function resolveRedisConfig(env: Record<string, string | undefined>): RedisConfig {
return {
growEventsRedisEnabled: parseBool(env.GROW_EVENTS_REDIS_ENABLED, false),
legacyServiceRedisEnabled: parseBool(env.LEGACY_SERVICE_REDIS_ENABLED, false),
growEventsRedisUrl: env.GROW_EVENTS_REDIS_URL ?? "",
interviewRedisUrl: env.INTERVIEW_REDIS_URL ?? "",
roleplayRedisUrl: env.ROLEPLAY_REDIS_URL ?? "",
resumeRedisUrl: env.RESUME_REDIS_URL ?? "",
coursesRedisUrl: env.COURSES_REDIS_URL ?? env.COURSE_REDIS_URL ?? "",
};
}
export const config = {
port: Number(process.env.PORT ?? 4000),
logLevel: process.env.LOG_LEVEL ?? "info",
@@ -26,16 +60,24 @@ export const config = {
a2aAllowedKey: process.env.A2A_ALLOWED_KEY ?? "dev-a2a-key",
// Service → backend event stream. Redis is optional; HTTP /events/ingest/service is always available.
growEventsRedisUrl: process.env.GROW_EVENTS_REDIS_URL ?? process.env.REDIS_URL ?? "",
// Explicit opt-in flags: both Redis ingestion paths are default-off. The REST
// production path never touches Redis. Each flag is independent so operators
// can enable canonical and/or legacy observers separately.
growEventsRedisEnabled: parseBool(process.env.GROW_EVENTS_REDIS_ENABLED, false),
legacyServiceRedisEnabled: parseBool(process.env.LEGACY_SERVICE_REDIS_ENABLED, false),
growEventsRedisUrl: process.env.GROW_EVENTS_REDIS_URL ?? "",
growEventsStream: process.env.GROW_EVENTS_STREAM ?? "grow.events.raw",
growEventsConsumerGroup: process.env.GROW_EVENTS_CONSUMER_GROUP ?? "growqr-backend",
growEventsConsumerName: process.env.GROW_EVENTS_CONSUMER_NAME ?? `backend-${process.pid}`,
// Legacy service Redis surfaces. These let backend observe existing service A2A traffic
// without changing the services. Defaults fall back to GROW_EVENTS_REDIS_URL/REDIS_URL.
interviewRedisUrl: process.env.INTERVIEW_REDIS_URL ?? process.env.GROW_EVENTS_REDIS_URL ?? process.env.REDIS_URL ?? "",
roleplayRedisUrl: process.env.ROLEPLAY_REDIS_URL ?? process.env.GROW_EVENTS_REDIS_URL ?? process.env.REDIS_URL ?? "",
resumeRedisUrl: process.env.RESUME_REDIS_URL ?? process.env.GROW_EVENTS_REDIS_URL ?? process.env.REDIS_URL ?? "",
// without changing the services. Legacy URLs resolve ONLY from their own explicit env
// var — they must not inherit from GROW_EVENTS_REDIS_URL or the generic REDIS_URL, so a
// stray REDIS_URL can never silently activate the legacy observer path.
interviewRedisUrl: process.env.INTERVIEW_REDIS_URL ?? "",
roleplayRedisUrl: process.env.ROLEPLAY_REDIS_URL ?? "",
resumeRedisUrl: process.env.RESUME_REDIS_URL ?? "",
coursesRedisUrl: process.env.COURSES_REDIS_URL ?? process.env.COURSE_REDIS_URL ?? "",
legacyServiceTaskObserverGroup: process.env.LEGACY_SERVICE_TASK_OBSERVER_GROUP ?? "growqr-backend-observer",
// LLM gateway for the unified user agent.
@@ -77,10 +119,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,8 +186,15 @@ 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),
required, // exported so other modules can fail fast on boot
} as const;

View File

@@ -0,0 +1,68 @@
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 ensureUserPlanColumn() {
await db.execute(`ALTER TABLE users ADD COLUMN IF NOT EXISTS plan text NOT NULL DEFAULT 'free'`);
}
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)`);
}
async function ensureOnboardingTable() {
await db.execute(`
CREATE TABLE IF NOT EXISTS onboarding (
id text PRIMARY KEY DEFAULT gen_random_uuid()::text NOT NULL,
user_id text NOT NULL REFERENCES users(id) ON DELETE cascade,
data jsonb NOT NULL DEFAULT '{}'::jsonb,
payload jsonb NOT NULL DEFAULT '{}'::jsonb,
created_at timestamp with time zone NOT NULL DEFAULT now(),
updated_at timestamp with time zone NOT NULL DEFAULT now()
)
`);
await db.execute(`CREATE UNIQUE INDEX IF NOT EXISTS onboarding_user_idx ON onboarding (user_id)`);
}
export async function ensureRuntimeSchema() {
await ensureUserPlanColumn();
await ensureGrowConversationsMetadataColumn();
await ensureSystemNotificationsTables();
await ensureOnboardingTable();
log.info("runtime schema ensured");
}

View File

@@ -20,6 +20,7 @@ export const users = pgTable(
id: text("id").primaryKey(),
email: text("email").notNull(),
displayName: text("display_name"),
plan: text("plan", { enum: ["free", "pro", "enterprise"] }).notNull().default("free"),
createdAt: timestamp("created_at", { withTimezone: true })
.defaultNow()
.notNull(),
@@ -32,6 +33,25 @@ export const users = pgTable(
}),
);
// Canonical onboarding state. `data` is the user-confirmed source of truth;
// `payload` is the server-owned projection consumed by Curator and Home.
export const onboarding = pgTable(
"onboarding",
{
id: text("id").primaryKey().default(sql`gen_random_uuid()::text`),
userId: text("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
data: jsonb("data").$type<Record<string, unknown>>().notNull().default(sql`'{}'::jsonb`),
payload: jsonb("payload").$type<Record<string, unknown>>().notNull().default(sql`'{}'::jsonb`),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
},
(t) => ({
userIdx: uniqueIndex("onboarding_user_idx").on(t.userId),
}),
);
// One per user. Tracks the user's unified agent's container stack + Git repo.
// Per changes.md §2A: per-user Gitea containers removed; central Gitea shared.
// Per changes.md §5: ONE actor per user manages the full orchestration layer.
@@ -280,6 +300,7 @@ export const growConversations = pgTable(
userId: text("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
title: text("title").notNull().default("Talk to Me"),
active: boolean("active").notNull().default(true),
metadata: jsonb("metadata").$type<Record<string, unknown>>(),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
},
@@ -458,6 +479,52 @@ export const growQscoreProjectionState = pgTable("grow_qscore_projection_state",
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
});
export const missionActions = pgTable(
"mission_actions",
{
id: text("id").primaryKey().default(sql`gen_random_uuid()::text`),
userId: text("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
missionInstanceId: text("mission_instance_id").notNull().references(() => growActiveMissions.instanceId, { onDelete: "cascade" }),
missionId: text("mission_id").notNull(),
stageId: text("stage_id"),
agentId: text("agent_id").notNull(),
agentName: text("agent_name").notNull(),
baseAgent: text("base_agent"),
serviceId: text("service_id"),
toolName: text("tool_name"),
mode: text("mode", { enum: ["autonomous", "approval_required", "user_input_required", "suggestion"] }).notNull(),
status: text("status", {
enum: ["queued", "running", "waiting_approval", "waiting_user_input", "done", "failed", "dismissed", "snoozed"],
}).notNull().default("queued"),
title: text("title").notNull(),
body: text("body").notNull(),
prompt: text("prompt"),
payload: jsonb("payload").$type<Record<string, unknown>>().notNull().default(sql`'{}'::jsonb`),
result: jsonb("result").$type<Record<string, unknown>>(),
error: text("error"),
sourceEventId: text("source_event_id").references(() => growEvents.id, { onDelete: "set null" }),
idempotencyKey: text("idempotency_key"),
priority: integer("priority").notNull().default(0),
urgency: text("urgency", { enum: ["now", "today", "soon", "calm"] }).notNull().default("calm"),
dueAt: timestamp("due_at", { withTimezone: true }),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
resolvedAt: timestamp("resolved_at", { withTimezone: true }),
},
(t) => ({
missionIdx: index("mission_actions_mission_idx").on(t.userId, t.missionInstanceId, t.status, t.priority),
userIdx: index("mission_actions_user_idx").on(t.userId, t.status, t.updatedAt),
sourceIdx: index("mission_actions_source_idx").on(t.sourceEventId),
dueIdx: index("mission_actions_due_idx").on(t.dueAt),
idempotencyIdx: uniqueIndex("mission_actions_idempotency_idx").on(t.idempotencyKey),
}),
);
export const missionSuggestions = pgTable(
"mission_suggestions",
{
@@ -542,10 +609,53 @@ 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;
export type NewMissionAction = typeof missionActions.$inferInsert;
export type MissionSuggestionRow = typeof missionSuggestions.$inferSelect;
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,12 +12,15 @@ 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;
}
function normalizeMission(value: unknown) {
const record = asRecord(value);
const instanceId = getString(record.instanceId ?? record.instance_id ?? record.mission_instance_id);
const instanceId = getString(record.instanceId ?? record.instance_id ?? record.missionInstanceId ?? record.mission_instance_id);
const missionId = getString(record.missionId ?? record.mission_id);
const stageId = getString(record.stageId ?? record.stage_id);
if (!instanceId && !missionId && !stageId) return 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,537 @@
import { and, desc, eq, inArray, or, sql } 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;
};
export const COMPLETION_EVENT_TYPES: Record<string, true> = {
"onboarding.completed": true,
"user.onboarding.completed": true,
"profile.onboarding.completed": true,
};
// Every ledger row whose type (dotted OR underscore alias) marks a completion.
// resetOnboardingLedger deletes all of these so the gate reopens regardless of
// which alias form a legacy emitter wrote.
export const COMPLETION_EVENT_TYPE_ALIASES: readonly string[] = [
...Object.keys(COMPLETION_EVENT_TYPES),
...Object.keys(COMPLETION_EVENT_TYPES).map((t) => t.replaceAll(".", "_")),
];
// Snapshot ledger types in both alias forms. Only completion-bearing snapshots
// are reset; intermediate step saves are preserved.
export const SNAPSHOT_EVENT_TYPE_ALIASES = ["onboarding.snapshot.saved", "onboarding_snapshot_saved"] as const;
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[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, contextOverride?: Record<string, unknown>) {
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: contextOverride ?? 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, context);
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 };
}
// ────────────────────────────────────────────────────────────────────────────
// Onboarding preferences helpers (pure — no DB, no network).
//
// The canonical onboarding store is `preferences.onboarding` on the user-service
// profile, shaped as the v3 OnboardingData object (schema_version: 3). These
// helpers read/validate/merge that blob and derive the faithful projection used
// by the curator. Completion percentages and QX estimates are OWNED by the
// dashboard (branch-aware); the backend persists the client-supplied values and
// never recomputes them.
// ────────────────────────────────────────────────────────────────────────────
export type OnboardingAccessChoice = "trial" | "full" | null;
export type OnboardingData = {
schema_version: 3;
revision: number;
status: "in_progress" | "completed";
progress: { stage: string; branch_index: number; updated_at: string | null };
consent: { privacy_accepted: boolean; accepted_at: string | null; terms_version: string };
profile: {
intent: string | null;
mode: string | null;
icp: string | null;
question_branch: string | null;
};
responses: {
current_situation: string | null;
career_barriers: string[];
desired_outcomes: string[];
target_milestone: string | null;
weekly_time_commitment: string | null;
target_role: string | null;
target_field: string | null;
venture_industry: string | null;
experience_level: string | null;
work_context: string | null;
};
import: {
method: string;
status: string;
resume_id: string | null;
resume_filename: string | null;
resume_summary: string | null;
linkedin_profile_id: string | null;
linkedin_url: string | null;
};
access_choice: OnboardingAccessChoice;
qx_estimate: number | null;
completed_at: string | null;
};
export type OnboardingPayload = {
schema_version: 1;
source_revision: number;
primary_intent: string | null;
mode: string | null;
question_branch: string | null;
onboarding_icp: string | null;
curator_registry_icp: string | null;
target_role: string | null;
target_field: string | null;
experience_context: string | null;
goals: string[];
barriers: string[];
priority: string | null;
weekly_time_commitment: string | null;
};
export const DEFAULT_ONBOARDING_DATA: OnboardingData = {
schema_version: 3,
revision: 0,
status: "in_progress",
progress: { stage: "consent", branch_index: 0, updated_at: null },
consent: { privacy_accepted: false, accepted_at: null, terms_version: "2026-07" },
profile: { intent: null, mode: null, icp: null, question_branch: null },
responses: {
current_situation: null,
career_barriers: [],
desired_outcomes: [],
target_milestone: null,
weekly_time_commitment: null,
target_role: null,
target_field: null,
venture_industry: null,
experience_level: null,
work_context: null,
},
import: {
method: "manual",
status: "not_started",
resume_id: null,
resume_filename: null,
resume_summary: null,
linkedin_profile_id: null,
linkedin_url: null,
},
access_choice: null,
qx_estimate: null,
completed_at: null,
};
export function defaultOnboardingData(): OnboardingData {
return structuredClone(DEFAULT_ONBOARDING_DATA);
}
function isObject(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
/**
* Coerce an arbitrary stored value into a valid v3 OnboardingData, filling gaps
* from the defaults. Rejects non-object shapes and falls back to defaults so the
* route always returns a well-formed v3 response (never throws).
*/
export function extractOnboardingData(stored: unknown): OnboardingData {
const base = defaultOnboardingData();
if (!isObject(stored)) return base;
const progress = isObject(stored.progress) ? stored.progress : {};
const consent = isObject(stored.consent) ? stored.consent : {};
const profile = isObject(stored.profile) ? stored.profile : {};
const responses = isObject(stored.responses) ? stored.responses : {};
const imp = isObject(stored.import) ? stored.import : {};
const rev = typeof stored.revision === "number" && Number.isFinite(stored.revision) ? stored.revision : base.revision;
const status = stored.status === "completed" ? "completed" : "in_progress";
const access = stored.access_choice === "trial" || stored.access_choice === "full" ? stored.access_choice : null;
const qx = typeof stored.qx_estimate === "number" && Number.isFinite(stored.qx_estimate) ? stored.qx_estimate : null;
const completed = typeof stored.completed_at === "string" && stored.completed_at.trim() ? stored.completed_at : null;
return {
schema_version: 3,
revision: rev,
status,
progress: {
stage: typeof progress.stage === "string" ? progress.stage : base.progress.stage,
branch_index: typeof progress.branch_index === "number" && Number.isFinite(progress.branch_index) ? progress.branch_index : base.progress.branch_index,
updated_at: typeof progress.updated_at === "string" && progress.updated_at.trim() ? progress.updated_at : null,
},
consent: {
privacy_accepted: typeof consent.privacy_accepted === "boolean" ? consent.privacy_accepted : base.consent.privacy_accepted,
accepted_at: typeof consent.accepted_at === "string" && consent.accepted_at.trim() ? consent.accepted_at : null,
terms_version: typeof consent.terms_version === "string" && consent.terms_version.trim() ? consent.terms_version : base.consent.terms_version,
},
profile: {
intent: typeof profile.intent === "string" ? profile.intent : null,
mode: typeof profile.mode === "string" ? profile.mode : null,
icp: typeof profile.icp === "string" ? profile.icp : null,
question_branch: typeof profile.question_branch === "string" ? profile.question_branch : null,
},
responses: {
current_situation: typeof responses.current_situation === "string" ? responses.current_situation : null,
career_barriers: Array.isArray(responses.career_barriers) ? responses.career_barriers.filter((x) => typeof x === "string") : [],
desired_outcomes: Array.isArray(responses.desired_outcomes) ? responses.desired_outcomes.filter((x) => typeof x === "string") : [],
target_milestone: typeof responses.target_milestone === "string" ? responses.target_milestone : null,
weekly_time_commitment: typeof responses.weekly_time_commitment === "string" ? responses.weekly_time_commitment : null,
target_role: typeof responses.target_role === "string" ? responses.target_role : null,
target_field: typeof responses.target_field === "string" ? responses.target_field : null,
venture_industry: typeof responses.venture_industry === "string" ? responses.venture_industry : null,
experience_level: typeof responses.experience_level === "string" ? responses.experience_level : null,
work_context: typeof responses.work_context === "string" ? responses.work_context : null,
},
import: {
method: typeof imp.method === "string" ? imp.method : base.import.method,
status: typeof imp.status === "string" ? imp.status : base.import.status,
resume_id: typeof imp.resume_id === "string" ? imp.resume_id : null,
resume_filename: typeof imp.resume_filename === "string" ? imp.resume_filename : null,
resume_summary: typeof imp.resume_summary === "string" ? imp.resume_summary : null,
linkedin_profile_id: typeof imp.linkedin_profile_id === "string" ? imp.linkedin_profile_id : null,
linkedin_url: typeof imp.linkedin_url === "string" ? imp.linkedin_url : null,
},
access_choice: access,
qx_estimate: qx,
completed_at: completed,
};
}
export class OnboardingRevisionConflict extends Error {
readonly expected: number;
readonly actual: number;
constructor(expected: number, actual: number) {
super(`onboarding revision conflict: expected ${expected}, actual ${actual}`);
this.name = "OnboardingRevisionConflict";
this.expected = expected;
this.actual = actual;
}
}
/**
* Optimistic-concurrency guard. Throws OnboardingRevisionConflict when the
* client's expectedRevision does not match the currently stored revision.
* `expectedRevision` may be undefined only when there is no existing revision
* (initial create); any mismatch with an existing doc is a conflict.
*/
export function assertOnboardingRevision(expected: number | undefined, current: OnboardingData): void {
if (expected !== current.revision) {
throw new OnboardingRevisionConflict(expected ?? -1, current.revision);
}
}
function asStringArray(value: unknown): string[] {
return Array.isArray(value) ? value.filter((x): x is string => typeof x === "string") : [];
}
/**
* Derive the outbound OnboardingPayload (schema v1) from the stored v3
* OnboardingData. This is a faithful projection — no completion math, no
* invented fields. `curator_registry_icp` is left null (the curator resolves
* the canonical ICP from `onboarding_icp` at projection time).
*/
export function deriveOnboardingPayload(data: OnboardingData): OnboardingPayload {
const experienceContext = [data.responses.experience_level, data.responses.work_context]
.filter((x) => typeof x === "string" && x.trim())
.join(" · ") || null;
const goals = asStringArray(data.responses.desired_outcomes);
const barriers = asStringArray(data.responses.career_barriers);
const priority = goals[0] ?? barriers[0] ?? data.responses.target_milestone ?? null;
return {
schema_version: 1,
source_revision: data.revision,
primary_intent: data.profile.intent,
mode: data.profile.mode,
question_branch: data.profile.question_branch,
onboarding_icp: data.profile.icp,
curator_registry_icp: null,
target_role: data.responses.target_role,
target_field: data.responses.target_field,
experience_context: experienceContext,
goals,
barriers,
priority,
weekly_time_commitment: data.responses.weekly_time_commitment,
};
}
/**
* Deep-merge an incoming PATCH `data` (full OnboardingData) into the stored
* preferences object, returning a NEW preferences object. Preserves every
* unrelated preference key (interview_preferences, resume_preferences,
* mission_preferences, target_roles, etc.) untouched. Bumps revision by one
* and stamps updated_at.
*/
export function mergeOnboardingPatch(
currentPreferences: Record<string, unknown>,
incoming: OnboardingData,
): Record<string, unknown> {
const updatedAt = new Date().toISOString();
const stored = extractOnboardingData(currentPreferences.onboarding);
const prevRevision = stored.revision;
const nextData: OnboardingData = {
...incoming,
// The client sends the revision it based its edits on; the stored revision
// is authoritative. We bump from the previously stored revision.
revision: prevRevision + 1,
progress: { ...incoming.progress, updated_at: updatedAt },
};
// Status/completed_at consistency: completed ⇒ completed_at must be set.
if (nextData.status === "completed" && !nextData.completed_at) {
nextData.completed_at = updatedAt;
}
// If regressed to in_progress, clear a stale completed_at.
if (nextData.status === "in_progress" && nextData.completed_at && incoming.completed_at === null) {
nextData.completed_at = null;
}
return { ...currentPreferences, onboarding: nextData };
}
/**
* Build the next preferences object for a reset: replace `preferences.onboarding`
* with a fresh default v3 doc (revision 0) and preserve every unrelated
* preference key. Pure — no DB, no network. The revision resets to 0 so the
* next save starts a fresh optimistic-concurrency lineage.
*/
export function resetOnboardingPreferences(
currentPreferences: Record<string, unknown>,
): Record<string, unknown> {
return { ...currentPreferences, onboarding: defaultOnboardingData() };
}
// ────────────────────────────────────────────────────────────────────────────
// Onboarding reset
//
// The onboarding gate reopens when no valid completion ledger row exists for a
// user (getLatestValidOnboardingLedgerEvent returns null). The reset deletes:
// 1. Every completion-type row in EITHER alias form (dotted like
// "onboarding.completed" or underscore like "onboarding_completed") — these
// carry the `onboarding:completed:${userId}` dedupe key.
// 2. Snapshot rows ("onboarding.snapshot.saved" / "onboarding_snapshot_saved")
// ONLY when their payload carries a completion marker (completed_at /
// completedAt at the top level or under onboarding). Intermediate step
// saves — the vast majority of snapshots — are preserved.
// Missions, curator context, and qscore baselines are never touched. Returns
// the count of rows removed for audit output.
// ────────────────────────────────────────────────────────────────────────────
// jsonb predicate matching the same paths completedAtFromOnboardingPayload
// inspects for snapshot payloads. Mirrors the JS validity rule in SQL so the
// reset deletes exactly the snapshots that would otherwise keep the gate shut.
const SNAPSHOT_HAS_COMPLETION = sql<boolean>`${growEvents.payload}->>'completed_at' IS NOT NULL
OR ${growEvents.payload}->>'completedAt' IS NOT NULL
OR ${growEvents.payload}->'onboarding'->>'completed_at' IS NOT NULL
OR ${growEvents.payload}->'onboarding'->>'completedAt' IS NOT NULL`;
export async function resetOnboardingLedger(userId: string): Promise<number> {
const deleted = await db
.delete(growEvents)
.where(
and(
eq(growEvents.userId, userId),
or(
inArray(growEvents.type, [...COMPLETION_EVENT_TYPE_ALIASES]),
and(
inArray(growEvents.type, [...SNAPSHOT_EVENT_TYPE_ALIASES]),
SNAPSHOT_HAS_COMPLETION,
),
),
),
)
.returning({ id: growEvents.id });
return deleted.length;
}

View File

@@ -0,0 +1,75 @@
import { forwardSignalsToQscoreService } from "../services/service-agents.js";
import { DEFAULT_QSCORE_ORG_ID } from "../services/qscore-proxy.js";
export const ONBOARDING_BASELINE_SIGNAL_ID = "onboarding.completed";
/**
* Workbook threshold for a completed onboarding baseline signal
* (Complete=Yes → 0.5). qscore_service owns the aggregate score; this is the
* per-signal evidence weight forwarded to it.
*/
export const ONBOARDING_BASELINE_SIGNAL_SCORE = 0.5;
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);
return parseCompletedAt(onboarding.completed_at ?? onboarding.completedAt);
}
/**
* Forward an onboarding.completed baseline signal to qscore_service when
* onboarding is completed.
*
* qscore_service OWNS all scoring. The backend no longer seeds local qscore
* tables; instead it forwards a single baseline readiness signal and lets
* qscore_service compute the score. Idempotency is delegated to qscore_service's
* latest-signal upsert semantics.
*/
export async function ensureOnboardingBaselineQscore(
userId: string,
preferences: Record<string, unknown> | undefined,
): 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;
await forwardSignalsToQscoreService({
orgId: DEFAULT_QSCORE_ORG_ID,
userId,
profession: "student",
source: "onboarding",
signals: [
{
signalId: ONBOARDING_BASELINE_SIGNAL_ID,
score: ONBOARDING_BASELINE_SIGNAL_SCORE,
present: true,
raw: {
reason: "completed onboarding baseline",
completedAt: completedAt.toISOString(),
},
},
],
});
return true;
}

View File

@@ -1,7 +1,7 @@
import { and, eq, sql } from "drizzle-orm";
import { db } from "../../db/client.js";
import { growQscoreLatest, growQscoreProjectionState, growQscoreSignals, type GrowEventRow } from "../../db/schema.js";
import { asRecord, clampScore, getNumber, type QscoreSignal } from "../envelope.js";
import { type GrowEventRow } from "../../db/schema.js";
import { asRecord, clampScore, getNumber, getString, type QscoreSignal } from "../envelope.js";
import { forwardSignalsToQscoreService } from "../../services/service-agents.js";
import { DEFAULT_QSCORE_ORG_ID } from "../../services/qscore-proxy.js";
function signal(signalId: string, score: number, raw?: Record<string, unknown>, present = true): QscoreSignal {
return { signalId, score: clampScore(score), present, raw };
@@ -15,6 +15,8 @@ function nestedNumber(record: Record<string, unknown>, keys: string[]): number |
return undefined;
}
const RESUME_UPLOAD_BASELINE_SCORE = 35;
function extractResumeSignals(event: GrowEventRow): QscoreSignal[] {
const payload = event.payload ?? {};
const analysis = asRecord(payload.analysis ?? payload.result ?? payload);
@@ -38,15 +40,17 @@ function extractResumeSignals(event: GrowEventRow): QscoreSignal[] {
}
const signals: QscoreSignal[] = [];
if (event.type.includes("uploaded") || event.type.includes("created") || event.type.includes("analysis")) {
signals.push(signal("resume.uploaded", 100, { eventId: event.id }));
if (event.type.includes("uploaded") || event.type.includes("created")) {
// Uploading a resume is only a baseline readiness signal. The actual Q Score
// should rise from parsed resume/interview/roleplay evidence, not jump to 100
// immediately after onboarding.
signals.push(signal("resume.uploaded", RESUME_UPLOAD_BASELINE_SCORE, { eventId: event.id }));
}
const ats = byCategory.get("ATS Compatibility") ?? nestedNumber(analysis, ["ats_score", "ats_compatibility", "atsCompatibility"]);
if (ats !== undefined) signals.push(signal("resume.ats_compatibility", ats, { eventId: event.id }));
const keywords = byDimension.get("Keywords") ?? nestedNumber(analysis, ["keyword_score", "keyword_relevance", "keywords"]);
if (keywords !== undefined) {
signals.push(signal("resume.keyword_relevance", keywords, { eventId: event.id }));
signals.push(signal("resume.technical_keywords", keywords, { eventId: event.id }));
}
const quantification = byDimension.get("Quantification") ?? nestedNumber(analysis, ["quantification_score"]);
if (quantification !== undefined) signals.push(signal("resume.quantified_achievements", quantification, { eventId: event.id }));
@@ -54,14 +58,29 @@ function extractResumeSignals(event: GrowEventRow): QscoreSignal[] {
if (contentQuality !== undefined) signals.push(signal("resume.grammar_clarity", contentQuality, { eventId: event.id }));
const formatting = byCategory.get("Formatting") ?? nestedNumber(analysis, ["formatting", "format_score"]);
if (formatting !== undefined) signals.push(signal("resume.format_structure", formatting, { eventId: event.id }));
const impact = byCategory.get("Impact Demonstration") ?? nestedNumber(analysis, ["impact_score"]);
if (impact !== undefined) {
signals.push(signal("resume.leadership_indicators", impact, { eventId: event.id }));
signals.push(signal("resume.impact_statements", impact, { eventId: event.id }));
}
return signals;
}
function interviewSessionsScore(count: number): number {
if (count <= 0) return 0;
if (count <= 2) return 20;
if (count <= 5) return 35;
return 50;
}
function interviewDiversityScore(count: number): number {
if (count <= 1) return 10;
if (count === 2) return 20;
return 30;
}
function roleplayScenariosScore(count: number): number {
if (count <= 0) return 0;
if (count <= 3) return 20;
if (count <= 6) return 35;
return 50;
}
function extractInterviewSignals(event: GrowEventRow): QscoreSignal[] {
const payload = event.payload ?? {};
const review = asRecord(payload.review ?? payload.result ?? payload);
@@ -69,7 +88,14 @@ function extractInterviewSignals(event: GrowEventRow): QscoreSignal[] {
if (!event.type.includes("review") && !event.type.includes("completed") && status !== "completed") return [];
const signals: QscoreSignal[] = [];
signals.push(signal("interview.completed", 100, { eventId: event.id }));
// A completed interview must always emit at least a single-completion signal.
// When session_count is absent, default to 1 so the event is not silently lost.
const sessionCount = getNumber(payload.session_count ?? payload.sessionCount) ?? 1;
signals.push(signal("interview.sessions_completed", interviewSessionsScore(sessionCount), { eventId: event.id, sessionCount }));
const typeDiversity = getNumber(payload.type_diversity ?? payload.typeDiversity);
if (typeDiversity !== undefined) {
signals.push(signal("interview.type_diversity", interviewDiversityScore(typeDiversity), { eventId: event.id, typeDiversity }));
}
const overall = getNumber(review.overall_score ?? review.overallScore ?? payload.overall_score);
if (overall !== undefined) signals.push(signal("interview.overall_score", overall, { eventId: event.id }));
const rubric = asRecord(review.rubric_scores ?? review.rubricScores);
@@ -92,7 +118,10 @@ function extractRoleplaySignals(event: GrowEventRow): QscoreSignal[] {
if (!event.type.includes("review") && !event.type.includes("completed") && status !== "completed") return [];
const signals: QscoreSignal[] = [];
signals.push(signal("roleplay.completed", 100, { eventId: event.id }));
// A completed roleplay must always emit at least a single-completion signal.
// When scenario_count is absent, default to 1 so the event is not silently lost.
const scenarioCount = getNumber(payload.scenario_count ?? payload.scenarioCount) ?? 1;
signals.push(signal("roleplay.scenarios_completed", roleplayScenariosScore(scenarioCount), { eventId: event.id, scenarioCount }));
const rubric = asRecord(review.rubric_scores ?? review.rubricScores);
const scenario = getNumber(rubric.scenario_adherence ?? review.scenario_adherence_score);
if (scenario !== undefined) signals.push(signal("roleplay.situational_judgment", scenario, { eventId: event.id }));
@@ -102,9 +131,221 @@ function extractRoleplaySignals(event: GrowEventRow): QscoreSignal[] {
if (adaptability !== undefined) signals.push(signal("roleplay.problem_resolution", adaptability, { eventId: event.id }));
const communication = getNumber(rubric.content ?? rubric.communication ?? review.communication_score);
if (communication !== undefined) signals.push(signal("roleplay.communication_effectiveness", communication, { eventId: event.id }));
const historical = asRecord(review.historical_comparison ?? review.historicalComparison);
const delta = getNumber(historical.overall_delta ?? historical.overallDelta);
if (delta !== undefined) signals.push(signal("roleplay.improvement_over_time", 50 + delta * 2.5, { eventId: event.id, delta }));
return signals;
}
function coursesCompletedScore(count: number): number {
if (count <= 0) return 0;
if (count <= 3) return 35;
if (count <= 10) return 60;
return 80;
}
function courseCompletionRateScore(pct: number): number {
if (pct < 50) return 20;
if (pct <= 80) return 40;
return 60;
}
function courseDifficultyScore(difficulty: string): number | undefined {
const d = difficulty.toLowerCase();
if (d.includes("beginner") || d === "easy") return 20;
if (d.includes("inter") || d.includes("medium")) return 35;
if (d.includes("adv")) return 50;
return undefined;
}
function coursePathwayRelevanceScore(label: string): number | undefined {
const l = label.toLowerCase();
if (l.includes("high")) return 40;
if (l.includes("med")) return 25;
if (l.includes("low")) return 10;
return undefined;
}
function extractCourseSignals(event: GrowEventRow): QscoreSignal[] {
const payload = event.payload ?? {};
if (
!event.type.includes("progress_recorded") &&
!event.type.includes("completed") &&
!event.type.includes("started") &&
!event.type.includes("watch")
)
return [];
const watchPctRaw = getNumber(payload.watchPct ?? payload.watch_pct) ?? 0;
const watchPctScaled = watchPctRaw <= 1 ? watchPctRaw * 100 : watchPctRaw;
const completedCount = getNumber(payload.completed_count ?? payload.completedCount ?? payload.courses_completed);
const difficulty = getString(payload.difficulty);
const label = getString(payload.label ?? payload.pathway_label);
const raw = {
eventId: event.id,
courseId: payload.courseId ?? payload.course_id,
lessonId: payload.lessonId ?? payload.lesson_id,
watchPct: watchPctRaw,
};
const signals: QscoreSignal[] = [];
signals.push(signal("courses.started", 40, raw));
signals.push(signal("courses.completion_rate", courseCompletionRateScore(watchPctScaled), { ...raw, completionPct: watchPctScaled }));
if (watchPctScaled >= 80) {
signals.push(signal("courses.completed", coursesCompletedScore(completedCount ?? 1), { ...raw, completedCount: completedCount ?? 1 }));
}
if (difficulty) {
const diffScore = courseDifficultyScore(difficulty);
if (diffScore !== undefined) signals.push(signal("courses.difficulty", diffScore, { ...raw, difficulty }));
}
if (label) {
const relScore = coursePathwayRelevanceScore(label);
if (relScore !== undefined) signals.push(signal("courses.pathway_relevance", relScore, { ...raw, label }));
}
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,
}),
];
}
function countFromPayload(payload: Record<string, unknown>): number | undefined {
const result = asRecord(payload.result);
const request = asRecord(payload.request);
const params = asRecord(request.params);
const direct = getNumber(payload.matchCount ?? payload.matches ?? payload.shortlisted ?? result.matchCount ?? result.matches ?? result.shortlisted);
if (direct !== undefined) return direct;
const opportunities = Array.isArray(result.opportunities) ? result.opportunities : undefined;
if (opportunities) return opportunities.length;
const requestOpportunities = Array.isArray(params.opportunities) ? params.opportunities : undefined;
return requestOpportunities?.length;
}
function jobsViewedScore(count: number): number {
if (count <= 0) return 0;
if (count <= 20) return 15;
if (count <= 50) return 30;
return 40;
}
function applicationsSubmittedScore(count: number): number {
if (count <= 0) return 0;
if (count <= 5) return 20;
if (count <= 15) return 35;
return 50;
}
function matchRateScore(pct: number): number {
if (pct < 50) return 15;
if (pct <= 75) return 35;
return 50;
}
function extractMatchmakingSignals(event: GrowEventRow): QscoreSignal[] {
if (!event.type.startsWith("matchmaking.")) return [];
const payload = event.payload ?? {};
const count = countFromPayload(payload);
const raw = {
eventId: event.id,
type: event.type,
action: payload.action,
opportunityId: payload.opportunityId ?? payload.matchId ?? payload.jobId ?? event.subject?.externalId,
taskId: payload.taskId ?? payload.curatorTaskId ?? event.correlation?.taskId,
matchCount: count,
status: payload.status,
};
const signals: QscoreSignal[] = [];
// Jobs viewed — from feed.viewed and match.viewed events with cumulative counts
if (event.type === "matchmaking.feed.viewed" || event.type === "matchmaking.match.viewed") {
const viewedCount = getNumber(payload.jobs_viewed ?? payload.jobsViewed ?? payload.viewed_count ?? payload.viewedCount ?? count);
signals.push(signal("matching.jobs_viewed", jobsViewedScore(viewedCount ?? 1), { ...raw, viewedCount }));
}
// Applications submitted — from match.applied and application.completed events
if (event.type === "matchmaking.match.applied" || event.type === "matchmaking.application.completed") {
const appliedCount = getNumber(payload.applications_submitted ?? payload.applicationsSubmitted ?? payload.applied_count ?? payload.appliedCount ?? count);
signals.push(signal("matching.applications_submitted", applicationsSubmittedScore(appliedCount ?? 1), { ...raw, appliedCount }));
}
// Application quality — from match quality scores if available
const qualityScore = getNumber(payload.application_quality ?? payload.applicationQuality ?? payload.match_quality ?? payload.matchQuality);
if (qualityScore !== undefined) {
signals.push(signal("matching.application_quality", qualityScore, { ...raw, qualityScore }));
}
// Match rate — from matches.generated with match data
if (event.type === "matchmaking.matches.generated" && count !== undefined) {
const totalCandidates = getNumber(payload.total_candidates ?? payload.totalCandidates) ?? count;
const rate = totalCandidates > 0 ? (count / totalCandidates) * 100 : 0;
signals.push(signal("matching.match_rate", matchRateScore(rate), { ...raw, matchRate: rate }));
}
return signals;
}
function extractSocialSignals(event: GrowEventRow): QscoreSignal[] {
const payload = event.payload ?? {};
const signals: QscoreSignal[] = [];
// social-branding sends pre-computed qscore_signals array — forward as-is
const incoming = Array.isArray(payload.qscore_signals) ? payload.qscore_signals : Array.isArray(payload.qscoreSignals) ? payload.qscoreSignals : undefined;
if (incoming) {
for (const entry of incoming) {
const record = asRecord(entry);
const id = getString(record.signalId ?? record.signal_id ?? record.id);
const score = getNumber(record.score ?? record.value);
if (id !== undefined && score !== undefined) {
signals.push(signal(id, score, { eventId: event.id, source: event.source }));
}
}
return signals;
}
// Fall back to inline fields for backward compatibility
const inline = asRecord(payload.signals);
for (const [key, value] of Object.entries(inline)) {
const score = getNumber(value);
if (score !== undefined) signals.push(signal(key, score, { eventId: event.id, source: event.source }));
}
return signals;
}
@@ -113,77 +354,32 @@ export function extractQscoreSignals(event: GrowEventRow): QscoreSignal[] {
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 (event.type === "mission.interview_to_offer.started") {
return [signal("goals.goals_set", 100, { eventId: event.id })];
}
if (source.includes("course") || event.type.startsWith("course.")) return extractCourseSignals(event);
if (source.includes("matchmaking") || event.type.startsWith("matchmaking.")) return extractMatchmakingSignals(event);
if (source.includes("social") || source.includes("branding") || source.includes("linkedin")) return extractSocialSignals(event);
if (source.includes("qscore") || event.type.startsWith("qscore.")) return extractScoredServiceSignals(event);
const scoredServiceSignals = extractScoredServiceSignals(event);
if (scoredServiceSignals.length) return scoredServiceSignals;
return [];
}
export async function applyQscoreProjection(event: GrowEventRow) {
if (!event.userId) return { signals: [], score: undefined };
const signals = extractQscoreSignals(event);
if (!signals.length) return { signals, score: undefined };
for (const item of signals) {
await db.insert(growQscoreSignals).values({
userId: event.userId,
sourceEventId: event.id,
signalId: item.signalId,
score: item.score,
present: item.present,
source: event.source,
raw: item.raw,
occurredAt: event.occurredAt,
});
await db.insert(growQscoreLatest).values({
userId: event.userId,
signalId: item.signalId,
score: item.score,
present: item.present,
source: event.source,
sourceEventId: event.id,
raw: item.raw,
occurredAt: event.occurredAt,
updatedAt: new Date(),
}).onConflictDoUpdate({
target: [growQscoreLatest.userId, growQscoreLatest.signalId],
set: {
score: item.score,
present: item.present,
source: event.source,
sourceEventId: event.id,
raw: item.raw,
occurredAt: event.occurredAt,
updatedAt: new Date(),
},
});
}
const [aggregate] = await db
.select({ score: sql<number>`round(avg(${growQscoreLatest.score}))::int`, count: sql<number>`count(*)::int` })
.from(growQscoreLatest)
.where(and(eq(growQscoreLatest.userId, event.userId), eq(growQscoreLatest.present, true)));
const score = aggregate?.score ?? 0;
const signalCount = aggregate?.count ?? 0;
await db.insert(growQscoreProjectionState).values({
// qscore_service OWNS all scoring. The backend only extracts signals and
// forwards them; ingest persists signals and marks the user dirty for async
// score computation. It does NOT return a score, so score stays undefined
// here — readers fetch the computed score via the proxy endpoints.
await forwardSignalsToQscoreService({
orgId: event.orgId ?? DEFAULT_QSCORE_ORG_ID,
userId: event.userId,
score,
signalCount,
dimensions: { latestSignalIds: signals.map((s) => s.signalId) },
summary: `Estimated readiness score from ${signalCount} current signal${signalCount === 1 ? "" : "s"}.`,
updatedAt: new Date(),
}).onConflictDoUpdate({
target: growQscoreProjectionState.userId,
set: {
score,
signalCount,
dimensions: { latestSignalIds: signals.map((s) => s.signalId) },
summary: `Estimated readiness score from ${signalCount} current signal${signalCount === 1 ? "" : "s"}.`,
updatedAt: new Date(),
},
profession: "student",
source: event.source,
signals,
});
return { signals, score };
return { signals, score: undefined };
}

View File

@@ -1,6 +1,6 @@
import { eq } from "drizzle-orm";
import { and, eq } from "drizzle-orm";
import { db } from "../../db/client.js";
import { missionServiceSessions, type GrowEventRow } from "../../db/schema.js";
import { growActiveMissions, missionServiceSessions, type GrowEventRow } from "../../db/schema.js";
import { asRecord, getString } from "../envelope.js";
import { normalizeServiceId } from "../record-grow-event.js";
@@ -12,8 +12,16 @@ function extractExternalId(event: GrowEventRow): string | undefined {
correlation.session_id ??
correlation.externalId ??
correlation.external_id ??
correlation.lessonId ??
correlation.courseId ??
payload.session_id ??
payload.sessionId ??
payload.lessonId ??
payload.lesson_id ??
payload.courseId ??
payload.course_id ??
payload.videoId ??
payload.video_id ??
payload.id,
);
}
@@ -21,8 +29,9 @@ function extractExternalId(event: GrowEventRow): string | undefined {
function statusFor(event: GrowEventRow): string {
const payload = event.payload ?? {};
const explicit = getString(payload.status);
if (explicit === "configured" || explicit === "draft" || explicit === "ready") return "active";
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";
@@ -33,22 +42,58 @@ export async function applyServiceSessionProjection(event: GrowEventRow) {
const externalId = extractExternalId(event);
if (!externalId) return null;
const serviceId = normalizeServiceId(event.source);
if (!["interview", "roleplay", "resume"].includes(serviceId)) return null;
if (!["interview", "roleplay", "resume", "course"].includes(serviceId)) return null;
const mission = asRecord(event.mission);
const correlation = asRecord(event.correlation);
const payload = event.payload ?? {};
const missionInstanceId = getString(mission.instanceId ?? mission.instance_id ?? payload.missionInstanceId ?? payload.mission_instance_id);
const missionId = getString(mission.missionId ?? mission.mission_id ?? payload.missionId ?? payload.mission_id);
const stageId = getString(mission.stageId ?? mission.stage_id ?? payload.stageId ?? payload.stage_id);
const [activeMission] = missionInstanceId
? await db
.select({
instanceId: growActiveMissions.instanceId,
missionId: growActiveMissions.missionId,
currentStageId: growActiveMissions.currentStageId,
})
.from(growActiveMissions)
.where(and(eq(growActiveMissions.userId, event.userId), eq(growActiveMissions.instanceId, missionInstanceId)))
.limit(1)
: [];
const linkedMissionInstanceId = activeMission?.instanceId;
const linkedMissionId = missionId ?? activeMission?.missionId;
const linkedStageId = stageId ?? activeMission?.currentStageId ?? undefined;
const metadata = {
lastType: event.type,
subject: event.subject,
payloadStatus: event.payload?.status,
missionInstanceId,
missionId,
stageId,
taskId: getString(correlation.taskId ?? correlation.curatorTaskId ?? correlation.curator_task_id ?? payload.taskId ?? payload.curatorTaskId ?? payload.curator_task_id),
curatorTaskId: getString(correlation.curatorTaskId ?? correlation.curator_task_id ?? correlation.taskId ?? payload.curatorTaskId ?? payload.curator_task_id ?? payload.taskId),
courseId: getString(correlation.courseId ?? payload.courseId ?? payload.course_id),
lessonId: getString(correlation.lessonId ?? payload.lessonId ?? payload.lesson_id),
};
const updateValues = {
...(linkedMissionInstanceId ? { missionInstanceId: linkedMissionInstanceId } : {}),
...(linkedMissionId ? { missionId: linkedMissionId } : {}),
...(linkedStageId ? { stageId: linkedStageId } : {}),
status: statusFor(event),
metadata,
lastEventId: event.id,
lastCheckedAt: event.type.includes("review") ? new Date() : undefined,
updatedAt: new Date(),
};
const [row] = await db
.insert(missionServiceSessions)
.values({
userId: event.userId,
missionInstanceId: getString(mission.instanceId ?? mission.instance_id),
missionId: getString(mission.missionId ?? mission.mission_id),
stageId: getString(mission.stageId ?? mission.stage_id),
missionInstanceId: linkedMissionInstanceId,
missionId: linkedMissionId,
stageId: linkedStageId,
serviceId,
externalId,
status: statusFor(event),
@@ -58,13 +103,7 @@ export async function applyServiceSessionProjection(event: GrowEventRow) {
})
.onConflictDoUpdate({
target: [missionServiceSessions.serviceId, missionServiceSessions.externalId],
set: {
status: statusFor(event),
metadata,
lastEventId: event.id,
lastCheckedAt: event.type.includes("review") ? new Date() : undefined,
updatedAt: new Date(),
},
set: updateValues,
})
.returning();

View File

@@ -30,20 +30,23 @@ export function normalizeServiceId(source: string): string {
if (source.includes("interview")) return "interview";
if (source.includes("roleplay")) return "roleplay";
if (source.includes("resume")) return "resume";
if (source.includes("course")) return "course";
if (source.includes("courses")) return "course";
if (source.includes("qscore")) return "qscore";
return source;
}
export async function recordGrowEvent(input: unknown, overrides: { userId?: string; source?: string } = {}): Promise<GrowEventRow> {
const result = await recordGrowEventWithResult(input, overrides);
return result.event;
}
export async function recordGrowEventWithResult(input: unknown, overrides: { userId?: string; source?: string } = {}): Promise<{ event: GrowEventRow; inserted: boolean }> {
const normalized = normalizeGrowEvent(input, overrides);
const resolvedUserId = await resolveUserId(normalized);
if (resolvedUserId) await ensureUser(resolvedUserId);
const processingStatus = resolvedUserId ? "pending" : "unresolved";
if (normalized.dedupeKey) {
const [existing] = await db.select().from(growEvents).where(eq(growEvents.dedupeKey, normalized.dedupeKey)).limit(1);
if (existing) return existing;
}
const values = {
id: normalized.id,
@@ -62,30 +65,70 @@ export async function recordGrowEvent(input: unknown, overrides: { userId?: stri
processingStatus,
} satisfies typeof growEvents.$inferInsert;
const [inserted] = await db
// Atomic insert-or-dedupe.
//
// onConflictDoNothing() with no target catches BOTH the PK (grow_events.id)
// and the dedupe_key unique index violations atomically. .returning() yields
// a row only on a fresh INSERT — on a conflict the row is suppressed. This is
// the deterministic insert-vs-existing discriminator with no clock heuristics,
// no xmax probe, and no raw SQL.
//
// Critically, DO NOTHING (not DO UPDATE) means an existing row is left
// *untouched*: a terminal (processed/failed) row is never resurrected back to
// *pending by a duplicate ingest. We then SELECT the pre-existing row AS-IS.
//
// NULL dedupeKey never collides (Postgres treats NULLs as distinct under a
// unique index), so events with no dedupe key always insert fresh.
const insertedRows = await db
.insert(growEvents)
.values(values)
.onConflictDoUpdate({
target: growEvents.id,
set: {
userId: values.userId,
orgId: values.orgId,
source: values.source,
type: values.type,
category: values.category,
occurredAt: values.occurredAt,
mission: values.mission,
subject: values.subject,
correlation: values.correlation,
payload: values.payload,
raw: values.raw,
processingStatus: values.processingStatus,
},
})
.onConflictDoNothing()
.returning();
if (!inserted) throw new Error("failed to record grow event");
return inserted;
const inserted = insertedRows[0];
if (inserted) {
return { event: inserted, inserted: true };
}
// Conflict: the row already existed. onConflictDoNothing() (no target)
// suppresses on EITHER the PK (id) or the dedupe_key unique index. We must
// find the conflicting row by whichever constraint fired — try dedupeKey
// first (the common case), then fall back to id (PK collision). Both can
// point to the same row when normalize.ts sets dedupeKey = id.
let existing: GrowEventRow | undefined;
if (normalized.dedupeKey) {
[existing] = await db
.select()
.from(growEvents)
.where(eq(growEvents.dedupeKey, normalized.dedupeKey))
.limit(1);
}
if (!existing && normalized.id) {
[existing] = await db
.select()
.from(growEvents)
.where(eq(growEvents.id, normalized.id))
.limit(1);
}
if (!existing) {
// Should not happen: onConflictDoNothing suppressed the insert yet we
// cannot find the conflicting row on either constraint. Treat as a hard
// failure rather than silently dropping the event.
throw new Error("failed to record grow event: conflict with unresolvable row");
}
return { event: existing, inserted: false };
}
/**
* Routing gate: only newly inserted, user-resolved, pending events should be
* enqueued to the user-event actor. Replays / dedupe hits / unresolved events
* must not enqueue (they would saturate the actor queue on duplicate ingest).
*/
export function shouldRouteGrowEvent(event: Pick<GrowEventRow, "userId" | "processingStatus">, inserted: boolean): boolean {
if (!inserted) return false;
if (!event.userId) return false;
return event.processingStatus === "pending";
}
export async function markGrowEventProcessing(eventId: string) {

View File

@@ -1,8 +1,16 @@
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,
recordGrowEventWithResult,
shouldRouteGrowEvent,
} from "./record-grow-event.js";
import { routeGrowEventToUserActor } from "./route-to-user-actor.js";
import { applyQscoreProjection } from "./projectors/qscore-projector.js";
import { ensureOnboardingSideEffectsForEvent } from "./onboarding-ledger.js";
// This file has two Redis ingestion modes:
// 1. Canonical GrowEvent stream: grow.events.raw — future service event bus.
@@ -23,8 +31,8 @@ type RedisClientLike = {
};
type ServiceRedisSpec = {
serviceId: "interview" | "roleplay" | "resume";
agentName: "interview-service" | "roleplay-service" | "resume-builder";
serviceId: "interview" | "roleplay" | "resume" | "course";
agentName: "interview-service" | "roleplay-service" | "resume-builder" | "course-service";
redisUrl: string;
};
@@ -64,6 +72,15 @@ function getString(value: unknown): string | undefined {
return typeof value === "string" && value.trim() ? value.trim() : undefined;
}
function getNumber(value: unknown): number | undefined {
if (typeof value === "number" && Number.isFinite(value)) return value;
if (typeof value === "string" && value.trim()) {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : undefined;
}
return undefined;
}
function fieldsToEvent(fields: Record<string, string>, stream: string) {
const payload = fields.payload ? parseFieldValue(fields.payload) : parseFieldValue(fields.data ?? "{}");
const correlation = fields.correlation ? parseFieldValue(fields.correlation) : undefined;
@@ -91,6 +108,7 @@ function serviceSpecs(): ServiceRedisSpec[] {
{ serviceId: "interview", agentName: "interview-service", redisUrl: config.interviewRedisUrl },
{ serviceId: "roleplay", agentName: "roleplay-service", redisUrl: config.roleplayRedisUrl },
{ serviceId: "resume", agentName: "resume-builder", redisUrl: config.resumeRedisUrl },
{ serviceId: "course", agentName: "course-service", redisUrl: config.coursesRedisUrl },
];
return specs.filter((spec) => Boolean(spec.redisUrl));
}
@@ -100,26 +118,34 @@ 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 (serviceId === "course") {
if (effective === "swipe_recorded" || action === "record_swipe") return "course.progress_recorded";
if (effective === "profile_created" || effective === "profile_recalibrated") return "course.started";
if (effective === "course_state_loaded") return "course.page_state_loaded";
if (effective === "feed_loaded") return "course.feed_loaded";
return `course.${effective.replaceAll("_", ".")}`;
}
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("_", ".")}`;
@@ -145,11 +171,59 @@ function extractResumeId(message: Record<string, unknown>, ctx?: LegacyTaskConte
);
}
function extractCourseIds(ctx?: LegacyTaskContext) {
const params = ctx?.params ?? {};
const courseId = getString(params.courseId ?? params.course_id ?? params.anchor_id ?? params.anchorId);
const lessonId = getString(params.lessonId ?? params.lesson_id ?? params.video_id ?? params.videoId);
return { courseId, lessonId };
}
function meaningfulCourseProgress(data: Record<string, unknown>, ctx?: LegacyTaskContext) {
if (data.error) return false;
const params = ctx?.params ?? {};
const watchS = getNumber(params.watch_s ?? params.watchS) ?? 0;
const watchPct = getNumber(data.watch_pct ?? data.watchPct) ?? 0;
const label = getString(data.label);
return watchS >= 3 || watchPct >= 0.5 || label === "pos" || label === "strong_pos";
}
async function recordAndRoute(input: unknown) {
const event = await recordGrowEvent(input);
await routeGrowEventToUserActor(event).catch((err) => {
log.warn({ err, eventId: event.id, userId: event.userId }, "failed to route grow event to user actor");
});
const { event, inserted } = await recordGrowEventWithResult(input);
// Route only newly inserted, user-resolved, pending events to the actor.
// Dedupe hits (inserted=false) are skipped so duplicate ingests cannot
// saturate the actor queue.
const routed = shouldRouteGrowEvent(event, inserted);
if (routed) {
await routeGrowEventToUserActor(event).catch((err) => {
log.warn({ err, eventId: event.id, userId: event.userId }, "failed to route grow event to user actor");
});
}
// In-process projection/onboarding only for freshly inserted events;
// dedupe hits have already been processed (or are being processed elsewhere).
if (!inserted) return event;
if (!event.userId) 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");
}
// Always mark "processed" after successful synchronous projections, regardless
// of route outcome. The actor will still run mission reducers on "processed"
// events (isProcessableStatus allows "processed") — projections are idempotent
// and the in-memory processedEventIds guard prevents double-processing.
await markGrowEventProcessed(event.id);
} catch (err) {
await markGrowEventFailed(event.id, err);
throw err;
}
return event;
}
@@ -165,6 +239,12 @@ async function handleLegacyResponseMessage(spec: ServiceRedisSpec, channel: stri
const eventType = actionToEventType(spec.serviceId, ctx?.action, message);
const sessionId = extractSessionId(message, ctx);
const resumeId = extractResumeId(message, ctx);
const { courseId, lessonId } = extractCourseIds(ctx);
if (eventType === "course.progress_recorded" && !meaningfulCourseProgress(data, ctx)) return;
const curatorTaskId = getString(ctx?.params?.curatorTaskId ?? ctx?.params?.curator_task_id ?? ctx?.params?.taskId);
const missionInstanceId = getString(ctx?.params?.missionInstanceId ?? ctx?.params?.mission_instance_id);
const missionId = getString(ctx?.params?.missionId ?? ctx?.params?.mission_id);
const stageId = getString(ctx?.params?.stageId ?? ctx?.params?.stage_id);
await recordAndRoute({
id: randomUUID(),
@@ -173,18 +253,36 @@ async function handleLegacyResponseMessage(spec: ServiceRedisSpec, channel: stri
category: type === "agent_error" ? "system" : "service",
userId: ctx?.userId,
occurredAt: new Date().toISOString(),
mission: {
instanceId: missionInstanceId,
missionId,
stageId,
},
correlation: {
taskId,
taskId: curatorTaskId ?? taskId,
curatorTaskId,
serviceTaskId: taskId,
action: ctx?.action,
sessionId,
resumeId,
externalId: sessionId ?? resumeId,
courseId,
lessonId,
externalId: sessionId ?? resumeId ?? lessonId ?? courseId,
},
payload: {
action: ctx?.action,
params: ctx?.params,
message,
data,
taskId: curatorTaskId,
curatorTaskId,
serviceId: spec.agentName,
sessionId,
courseId,
lessonId,
missionInstanceId,
missionId,
stageId,
},
raw: { channel, message },
dedupeKey: `legacy-a2a:${spec.agentName}:${taskId}:${eventType}:${JSON.stringify(message).slice(0, 512)}`,
@@ -311,14 +409,33 @@ async function startCanonicalGrowEventStream(createClient: (opts: { url: string
}
export async function startGrowEventsRedisConsumer() {
const createClient = await loadRedisCreateClient();
await startCanonicalGrowEventStream(createClient);
const specs = serviceSpecs();
if (!specs.length) {
log.info("legacy service Redis observers disabled (INTERVIEW_REDIS_URL/ROLEPLAY_REDIS_URL/RESUME_REDIS_URL not set)");
// Both Redis ingestion paths are default-off. When neither flag is set, we
// return immediately WITHOUT calling loadRedisCreateClient() — so the
// `redis` module is never imported and no client is constructed. The REST
// production path (POST /events/ingest/service) handles all event ingestion
// without Redis.
if (!config.growEventsRedisEnabled && !config.legacyServiceRedisEnabled) {
log.info("grow events Redis consumers disabled (GROW_EVENTS_REDIS_ENABLED and LEGACY_SERVICE_REDIS_ENABLED both off)");
return;
}
await Promise.all(specs.map((spec) => startLegacyServiceObserver(spec, createClient)));
// At least one path is opted in — now it is safe to load the Redis module.
const createClient = await loadRedisCreateClient();
if (config.growEventsRedisEnabled) {
if (!config.growEventsRedisUrl) {
log.warn("GROW_EVENTS_REDIS_ENABLED=true but GROW_EVENTS_REDIS_URL not set — canonical consumer skipped");
} else {
await startCanonicalGrowEventStream(createClient);
}
}
if (config.legacyServiceRedisEnabled) {
const specs = serviceSpecs();
if (!specs.length) {
log.info("legacy service Redis observers enabled but no service URLs configured (INTERVIEW_REDIS_URL/ROLEPLAY_REDIS_URL/RESUME_REDIS_URL not set)");
} else {
await Promise.all(specs.map((spec) => startLegacyServiceObserver(spec, createClient)));
}
}
}

View File

@@ -3,13 +3,46 @@ import { config } from "../config.js";
import type { Registry } from "../actors/registry.js";
import type { GrowEventRow } from "../db/schema.js";
export type GrowEventActorRouteResult =
| { routed: true }
| { routed: false; reason: "unresolved_user" | "actor_route_timeout" };
export interface GrowEventActorRouteOptions {
timeoutMs?: number;
enqueue?: (event: { userId: string; eventId: string }) => Promise<unknown>;
}
const DEFAULT_ACTOR_ROUTE_TIMEOUT_MS = 2_000;
const ACTOR_ROUTE_TIMEOUT = Symbol("actor_route_timeout");
let _client: Client<Registry> | null = null;
function getClient(): Client<Registry> {
return (_client ??= createClient<Registry>(config.rivetClientEndpoint));
}
export async function routeGrowEventToUserActor(event: Pick<GrowEventRow, "id" | "userId">) {
if (!event.userId) return { routed: false, reason: "unresolved_user" } as const;
await getClient().userEventActor.getOrCreate([event.userId]).enqueueEvent({ userId: event.userId, eventId: event.id });
return { routed: true } as const;
export async function routeGrowEventToUserActor(
event: Pick<GrowEventRow, "id" | "userId">,
options: GrowEventActorRouteOptions = {},
): Promise<GrowEventActorRouteResult> {
if (!event.userId) return { routed: false, reason: "unresolved_user" };
const actorEvent = { userId: event.userId, eventId: event.id };
const enqueue = options.enqueue ?? ((input) =>
getClient().userEventActor.getOrCreate([input.userId]).enqueueEvent(input));
const timeoutMs = options.timeoutMs ?? DEFAULT_ACTOR_ROUTE_TIMEOUT_MS;
let timer: NodeJS.Timeout | undefined;
try {
const result = await Promise.race([
enqueue(actorEvent),
new Promise<typeof ACTOR_ROUTE_TIMEOUT>((resolve) => {
timer = setTimeout(() => resolve(ACTOR_ROUTE_TIMEOUT), timeoutMs);
}),
]);
return result === ACTOR_ROUTE_TIMEOUT
? { routed: false, reason: "actor_route_timeout" }
: { routed: true };
} finally {
if (timer) clearTimeout(timer);
}
}

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

@@ -1,4 +1,4 @@
import { asc, desc, eq, and } from "drizzle-orm";
import { asc, desc, eq, and, sql } from "drizzle-orm";
import { db } from "../db/client.js";
import { growActiveMissions, growConversationMessages, growConversations, missionCoachRuns, missionSuggestions } from "../db/schema.js";
import type { GrowActiveMission, MissionSnapshot } from "../actors/missions/types.js";
@@ -42,6 +42,118 @@ export async function createConversationPg(userId: string, title = "Talk to Me")
return toConversation(row);
}
export async function ensureCuratorTaskConversationPg(input: {
userId: string;
curatorTaskId: string;
subtaskIndex?: number;
subtask?: string;
missionInstanceId?: string;
missionId?: string;
stageId?: string;
title?: string;
}): Promise<GrowConversation> {
const curatorTaskKey = `${input.curatorTaskId}:${input.subtaskIndex ?? "task"}`;
const [existing] = await db
.select()
.from(growConversations)
.where(and(
eq(growConversations.userId, input.userId),
sql`${growConversations.metadata}->>'curatorTaskKey' = ${curatorTaskKey}`,
))
.orderBy(desc(growConversations.updatedAt))
.limit(1);
const metadata = {
source: "curator-v1",
curatorTaskId: input.curatorTaskId,
curatorTaskKey,
...(Number.isInteger(input.subtaskIndex) ? { subtaskIndex: input.subtaskIndex } : {}),
...(input.subtask ? { subtask: input.subtask } : {}),
...(input.missionInstanceId ? { missionInstanceId: input.missionInstanceId } : {}),
...(input.missionId ? { missionId: input.missionId } : {}),
...(input.stageId ? { stageId: input.stageId } : {}),
};
if (existing) {
const [row] = await db.update(growConversations).set({
title: input.title?.trim() || existing.title,
metadata,
updatedAt: new Date(),
}).where(and(eq(growConversations.userId, input.userId), eq(growConversations.id, existing.id))).returning();
if (!row) throw new Error("Failed to update curator task conversation");
return toConversation(row);
}
const now = new Date();
const [row] = await db.insert(growConversations).values({
id: buildId("conversation"),
userId: input.userId,
title: input.title?.trim() || input.subtask?.trim() || "V1 Curator chat",
metadata,
createdAt: now,
updatedAt: now,
}).returning();
if (!row) throw new Error("Failed to create curator task conversation");
return toConversation(row);
}
export async function ensureMissionConversationPg(input: {
userId: string;
missionInstanceId: string;
missionId: string;
stageId?: string;
title?: string;
source?: string;
}): Promise<GrowConversation> {
const [existing] = await db
.select()
.from(growConversations)
.where(and(
eq(growConversations.userId, input.userId),
sql`${growConversations.metadata}->>'missionInstanceId' = ${input.missionInstanceId}`,
))
.orderBy(desc(growConversations.updatedAt))
.limit(1);
const metadata = {
missionInstanceId: input.missionInstanceId,
missionId: input.missionId,
...(input.stageId ? { stageId: input.stageId } : {}),
source: input.source ?? "mission",
};
if (existing) {
const [row] = await db.update(growConversations).set({
title: input.title?.trim() || existing.title,
metadata,
updatedAt: new Date(),
}).where(and(eq(growConversations.userId, input.userId), eq(growConversations.id, existing.id))).returning();
if (!row) throw new Error("Failed to update mission conversation");
return toConversation(row);
}
const now = new Date();
const [row] = await db.insert(growConversations).values({
id: buildId("conversation"),
userId: input.userId,
title: input.title?.trim() || "Mission chat",
metadata,
createdAt: now,
updatedAt: now,
}).returning();
if (!row) throw new Error("Failed to create mission conversation");
return toConversation(row);
}
export async function getConversationMetadataPg(userId: string, conversationId: string) {
const [row] = await db
.select({ metadata: growConversations.metadata })
.from(growConversations)
.where(and(eq(growConversations.userId, userId), eq(growConversations.id, conversationId)))
.limit(1);
return row?.metadata ?? {};
}
export async function getConversationPg(userId: string, conversationId: string): Promise<GrowConversation | null> {
const [row] = await db.select().from(growConversations).where(and(eq(growConversations.userId, userId), eq(growConversations.id, conversationId))).limit(1);
return row ? toConversation(row) : null;
@@ -162,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

@@ -1,91 +0,0 @@
import { Output, generateText } from "ai";
import { z } from "zod";
import { getConversationModel } from "../actors/conversation/agent.js";
import { config } from "../config.js";
import { log } from "../log.js";
import { ALLOWED_NOTIFICATION_HREFS, MODULE_IDS, type HomeModuleId, type HomeNotification, type HomeUrgency } from "./types.js";
const notificationSchema = z.object({
moduleId: z.enum(MODULE_IDS as [HomeModuleId, ...HomeModuleId[]]),
title: z.string().min(4).max(72),
subtitle: z.string().min(4).max(110),
tag: z.string().min(2).max(14),
urgency: z.enum(["now", "today", "soon", "calm"]),
href: z.string().min(1),
source: z.enum(["resume", "interview", "roleplay", "qscore", "mission", "social", "pathways", "rewards", "system"]),
reason: z.string().max(160).optional(),
});
const feedSchema = z.object({
notifications: z.array(notificationSchema).min(6).max(24),
});
export type AgentHomeNotification = z.infer<typeof notificationSchema>;
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.
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
- /rewards for locked/coming-soon rewards
- /suggestions for broad onboarding/profile suggestions
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 (ALLOWED_NOTIFICATION_HREFS.has(href)) return href;
if (href.startsWith("/agents/resume")) return "/agents/resume";
if (href.startsWith("/agents/interview")) return "/agents/interview";
if (href.startsWith("/agents/roleplay")) return "/agents/roleplay";
if (href.startsWith("/agents/qscore")) return "/agents/qscore";
if (href.startsWith("/missions")) return "/missions";
if (href.startsWith("/social")) return "/social";
if (href.startsWith("/pathways")) return "/pathways";
if (href.startsWith("/rewards")) return "/rewards";
if (href.startsWith("/productivity")) return "/productivity";
return moduleId === "productivity" ? "/productivity" : `/${moduleId}`;
}
function stableId(prefix: string, index: number) {
return `${prefix}-${index + 1}`;
}
export async function refineHomeNotificationsWithAgent(input: {
userId: string;
context: Record<string, unknown>;
seeds: Array<Omit<HomeNotification, "id" | "createdAt"> & { moduleId: HomeModuleId }>;
}): Promise<Array<AgentHomeNotification & { id: string; createdAt: string }>> {
if (!config.llmApiKey) return [];
try {
const result = await generateText({
model: getConversationModel(),
output: Output.object({ schema: feedSchema }),
system: SYSTEM,
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 now = new Date().toISOString();
return result.output.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 [];
}
}

View File

@@ -1,22 +1,27 @@
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,
growHomeNotifications,
growQscoreLatest,
growQscoreProjectionState,
missionArtifacts,
missionServiceSessions,
missionSuggestions,
qscoreSnapshots,
users,
type GrowHomeNotificationRow,
type NewGrowHomeNotification,
} from "../db/schema.js";
import { interviewService, resumeService, roleplayService } from "../services/product-service-clients.js";
import { refineHomeNotificationsWithAgent } from "./home-feed-agent.js";
import { buildServiceLink } from "../services/service-registry.js";
import { DEFAULT_QSCORE_ORG_ID, getQscoreFromService } from "../services/qscore-proxy.js";
import { ensureOnboardingBaselineQscoreFromLedger } from "../events/onboarding-ledger.js";
import { listAvailableMissionDefinitions } from "../missions/registry.js";
import { listServiceCapabilities } from "../workflows/service-capabilities.js";
import { buildCuratorSprint } from "../v1/curator/curator-store.js";
import {
ALLOWED_NOTIFICATION_HREFS,
isAllowedNotificationHref,
MODULE_IDS,
MODULE_META,
type HomeFeedResponse,
@@ -29,18 +34,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",
mission: "/missions",
social: "/social",
pathways: "/pathways",
resume: buildServiceLink("resume-service", "workspace") ?? "/agents/resume",
interview: buildServiceLink("interview-service", "discovery") ?? "/agents/interview",
roleplay: buildServiceLink("roleplay-service", "discovery") ?? "/agents/roleplay",
mission: "/missions/active",
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 };
@@ -50,10 +54,13 @@ type HomeContext = {
qscore: { score: number; signalCount: number; summary: string | null; dimensions: Record<string, unknown> | null } | undefined;
qscoreSignals: Array<{ signalId: string; score: number; source: string | null; updatedAt: Date }>;
activeMissions: Array<{ instanceId: string; missionId: string; title: string; status: string; progressPercent: number; currentStageId: string | null; updatedAt: Date }>;
missionSuggestions: Array<{ id: string; missionInstanceId: string; missionId: string; stageId: string | null; role: string; type: string; title: string; body: string; reason: string | null; priority: number; urgency: string; ctaLabel: string; ctaHref: string; updatedAt: Date }>;
sessions: Array<{ serviceId: string; externalId: string; status: string; updatedAt: Date; metadata: Record<string, unknown> | null }>;
artifacts: Array<{ serviceId: string | null; type: string; title: string; status: string; summary: string | null; createdAt: Date }>;
events: Array<{ source: string; type: string; occurredAt: Date; payload: Record<string, unknown> }>;
serviceStates: Record<string, unknown>;
userProfile?: Record<string, unknown>;
preferences: Record<string, unknown>;
};
function isRecord(value: unknown): value is Record<string, unknown> {
@@ -64,13 +71,70 @@ function numberFrom(value: unknown): number | undefined {
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
}
function arrayOfStrings(value: unknown): string[] {
return Array.isArray(value) ? value.filter((item): item is string => typeof item === "string" && item.trim().length > 0) : [];
}
function recordOf(value: unknown): Record<string, unknown> {
return isRecord(value) ? value : {};
}
function profileFromPreferences(preferences: Record<string, unknown>) {
const onboarding = recordOf(preferences.onboarding);
const interview = recordOf(preferences.interview_preferences);
const resume = recordOf(preferences.resume_preferences);
const mission = recordOf(preferences.mission_preferences);
const targetRoles = arrayOfStrings(preferences.target_roles);
const targetCompanies = arrayOfStrings(preferences.target_companies);
const focusAreas = arrayOfStrings(interview.focus_areas);
return {
targetRole: targetRoles[0] ?? (typeof resume.target_title === "string" ? resume.target_title : "Senior Data Scientist"),
targetCompany: targetCompanies[0] ?? "target company",
industry: typeof preferences.industry === "string" ? preferences.industry : "AI / SaaS",
focusAreas,
weakSpots: arrayOfStrings(interview.weak_spots),
jobDescription: typeof interview.job_description === "string" ? interview.job_description : undefined,
activeGoal: typeof mission.active_goal === "string" ? mission.active_goal : typeof onboarding.goal === "string" ? onboarding.goal : undefined,
onboardingComplete: Boolean(onboarding.completed_at),
};
}
function serviceHref(service: "resume" | "interview" | "roleplay", ctx: HomeContext, mission?: { instanceId?: string; missionId?: string; stageId?: string | null }) {
const profile = profileFromPreferences(ctx.preferences);
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 {
const value = role.toLowerCase();
if (value.includes("resume")) return "resume";
if (value.includes("roleplay")) return "roleplay";
if (value.includes("interview")) return "interview";
if (value.includes("match") || value.includes("pathway") || value.includes("job")) return "pathways";
return "mission";
}
function sanitizeUrgency(value: string): HomeUrgency {
if (value === "now" || value === "today" || value === "soon" || value === "calm") return value;
return "calm";
}
function sanitizeHref(href: string | undefined, fallback: string) {
if (href && ALLOWED_NOTIFICATION_HREFS.has(href)) return href;
if (href && isAllowedNotificationHref(href)) return href;
return fallback;
}
@@ -97,28 +161,64 @@ function hasAnyRealActivity(ctx: HomeContext) {
ctx.activeMissions.length ||
ctx.sessions.length ||
ctx.artifacts.length ||
ctx.events.length,
ctx.events.length ||
ctx.missionSuggestions.length ||
profileFromPreferences(ctx.preferences).onboardingComplete,
);
}
function buildDayOneSeeds(): SeedNotification[] {
const seeds: SeedNotification[] = [];
pushSeed(seeds, { moduleId: "suggestions", title: "Start with your Q Score", subtitle: "A quick readiness scan calibrates resume, interview, and roleplay tips.", tag: "Start", urgency: "now", href: SERVICE_HREFS.qscore, source: "qscore", priority: 90 });
pushSeed(seeds, { moduleId: "suggestions", title: "Add your target role", subtitle: "One role goal makes every recommendation sharper.", tag: "Profile", urgency: "today", href: SERVICE_HREFS.suggestions, source: "system", priority: 80 });
pushSeed(seeds, { moduleId: "missions", title: "Explore Interview-to-Offer", subtitle: "A guided mission connects resume fit, mock practice, and readiness scoring.", tag: "Browse", urgency: "today", href: SERVICE_HREFS.mission, source: "mission", priority: 80 });
pushSeed(seeds, { moduleId: "missions", title: "No approvals pending yet", subtitle: "Start a mission and this tile will track missing steps and progress.", tag: "Quiet", urgency: "calm", href: SERVICE_HREFS.mission, source: "mission", priority: 55 });
pushSeed(seeds, { moduleId: "social", title: "Connect LinkedIn when ready", subtitle: "Social branding recommendations unlock after your profile is available.", tag: "Setup", urgency: "soon", href: SERVICE_HREFS.social, source: "social", priority: 60 });
pushSeed(seeds, { moduleId: "social", title: "Build proof before posting", subtitle: "Resume and mock interview artifacts can become stronger featured pins.", tag: "Proof", urgency: "calm", href: SERVICE_HREFS.social, source: "social", priority: 50 });
pushSeed(seeds, { moduleId: "pathways", title: "Pathways are warming up", subtitle: "Complete resume + interview activity to unlock better route recommendations.", tag: "Soon", urgency: "calm", href: SERVICE_HREFS.pathways, source: "pathways", priority: 40 });
pushSeed(seeds, { moduleId: "productivity", title: "Open Resume Builder", subtitle: "Upload or create a resume to generate ATS and content recommendations.", tag: "Resume", urgency: "now", href: SERVICE_HREFS.resume, source: "resume", priority: 85 });
pushSeed(seeds, { moduleId: "productivity", title: "Try a 10-minute mock interview", subtitle: "The interview service creates a role-aware live practice session.", tag: "Mock", urgency: "soon", href: SERVICE_HREFS.interview, source: "interview", priority: 70 });
pushSeed(seeds, { moduleId: "productivity", title: "Roleplay is available for pressure practice", subtitle: "Use it for recruiter screens, salary asks, or manager conversations.", tag: "Roleplay", urgency: "calm", href: SERVICE_HREFS.roleplay, source: "roleplay", priority: 55 });
pushSeed(seeds, { moduleId: "rewards", title: "Rewards unlock after activity", subtitle: "Finish readiness actions to start earning demo streaks and perks.", tag: "Locked", urgency: "calm", href: SERVICE_HREFS.rewards, source: "rewards", priority: 35 });
const missions = listAvailableMissionDefinitions();
for (const [index, mission] of missions.slice(0, 3).entries()) {
const firstServiceModule = mission.modules.find((module) => module.execution === "service" && module.service);
pushSeed(seeds, {
moduleId: "missions",
title: mission.shortTitle || mission.title,
subtitle: mission.promise,
tag: mission.urgency === "high" ? "High" : mission.estimatedDuration,
urgency: mission.urgency === "high" ? "today" : "soon",
href: `/missions/available?missionId=${encodeURIComponent(mission.missionId)}`,
source: "mission",
reason: firstServiceModule ? `Registry workflow using ${firstServiceModule.role}.` : "Registry workflow.",
priority: 92 - index * 4,
});
}
const services = listServiceCapabilities().filter((service) => service.enabled);
const serviceCards = [
{ 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: "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()) {
const service = services.find((item) => item.id === card.id);
if (!service) continue;
pushSeed(seeds, {
moduleId: card.moduleId,
title: service.name,
subtitle: service.operations.slice(0, 3).join(", ") || "Registered GrowQR service capability.",
tag: service.featureId?.replaceAll("-", " ").slice(0, 14) || "Service",
urgency: card.urgency,
href: card.href,
source: card.source,
reason: `From service registry: ${service.promptModulePath ?? service.id}.`,
priority: 84 - index * 3,
});
}
pushSeed(seeds, { moduleId: "suggestions", title: "Set your target outcome", subtitle: "One role or career goal sharpens every registry mission and service handoff.", tag: "Profile", urgency: "today", href: SERVICE_HREFS.suggestions, source: "system", priority: 80 });
pushSeed(seeds, { moduleId: "rewards", title: "Rewards unlock from mission progress", subtitle: "Complete registry-backed mission stages to earn streak and coin progress.", tag: "Locked", urgency: "calm", href: SERVICE_HREFS.rewards, source: "rewards", priority: 35 });
return seeds;
}
function buildDynamicSeeds(ctx: HomeContext): SeedNotification[] {
const seeds = buildDayOneSeeds().filter((seed) => seed.moduleId === "pathways" || seed.moduleId === "rewards");
const profile = profileFromPreferences(ctx.preferences);
const qscore = ctx.qscore?.score ?? Math.round(ctx.qscoreSignals.reduce((sum, s) => sum + s.score, 0) / Math.max(ctx.qscoreSignals.length, 1));
const ats = latestScore(ctx.qscoreSignals, "resume.ats_compatibility");
const interviewOverall = latestScore(ctx.qscoreSignals, "interview.overall_score");
@@ -130,15 +230,63 @@ function buildDynamicSeeds(ctx: HomeContext): SeedNotification[] {
const roleplayReview = serviceEvent(ctx, "roleplay.", "review");
const resumeAnalysis = serviceEvent(ctx, "resume.", "analysis");
if (ctx.qscore || ctx.qscoreSignals.length) {
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);
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.` : `Current estimate is ${qscore || 64}. Resume + mock practice are the fastest signals.`,
tag: "Q Score",
title: suggestion.title,
subtitle: suggestion.body,
tag: suggestion.ctaLabel.replace(/\s+/g, " ").slice(0, 14),
urgency: sanitizeUrgency(suggestion.urgency),
href,
source,
reason: suggestion.reason ?? undefined,
priority: Math.max(100, suggestion.priority + 10),
});
pushSeed(seeds, {
moduleId: suggestion.role.toLowerCase().includes("resume") || suggestion.role.toLowerCase().includes("interview") || suggestion.role.toLowerCase().includes("roleplay") ? "productivity" : "missions",
title: `${suggestion.role}: ${suggestion.title}`,
subtitle: mission ? `${mission.title} · ${suggestion.body}` : suggestion.body,
tag: suggestion.urgency === "now" ? "Now" : suggestion.urgency === "today" ? "Today" : "Next",
urgency: sanitizeUrgency(suggestion.urgency),
href,
source,
reason: suggestion.reason ?? undefined,
priority: suggestion.priority,
});
}
if (profile.onboardingComplete) {
pushSeed(seeds, {
moduleId: "suggestions",
title: `${profile.targetRole} plan is calibrated`,
subtitle: profile.activeGoal ?? `Today's recommendations are tuned for ${profile.targetRole}${profile.targetCompany !== "target company" ? ` at ${profile.targetCompany}` : ""}.`,
tag: "Profile",
urgency: "today",
href: "/suggestions",
source: "system",
priority: 91,
});
}
if (ctx.qscore || ctx.qscoreSignals.length) {
pushSeed(seeds, {
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: SERVICE_HREFS.qscore,
source: "qscore",
href: SERVICE_HREFS.pathways,
source: "pathways",
priority: 95,
});
}
@@ -147,23 +295,23 @@ function buildDynamicSeeds(ctx: HomeContext): SeedNotification[] {
pushSeed(seeds, {
moduleId: "suggestions",
title: ats >= 80 ? "ATS is demo-ready" : "Resume ATS needs one pass",
subtitle: ats >= 80 ? `ATS ${Math.round(ats)} — review role-specific keywords before applying.` : `ATS ${Math.round(ats)} — add JD keywords and measurable bullets.`,
subtitle: ats >= 80 ? `ATS ${Math.round(ats)} — review ${profile.targetRole} keywords before applying.` : `ATS ${Math.round(ats)} — add JD keywords and measurable data-science bullets.`,
tag: ats >= 80 ? "Ready" : "Fix",
urgency: ats >= 80 ? "today" : "now",
href: SERVICE_HREFS.resume,
href: serviceHref("resume", ctx),
source: "resume",
priority: 92,
});
}
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}%`,
subtitle: mission.currentStageId ? `Current stage: ${mission.currentStageId.replaceAll("-", " ")}` : "Next action is ready on the mission dashboard.",
tag: mission.status === "paused" ? "Paused" : "Active",
urgency: mission.status === "paused" ? "soon" : "today",
href: SERVICE_HREFS.mission,
href: `/missions/active?missionInstanceId=${encodeURIComponent(mission.instanceId)}`,
source: "mission",
priority: 90 - mission.progressPercent,
});
@@ -183,80 +331,96 @@ 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 mission 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",
title: ats !== undefined ? `Resume ATS ${Math.round(ats)}` : "Resume analysis is ready",
subtitle: ats !== undefined && ats >= 80 ? "Use this version for role-fit scan or final polish." : "Open Resume Builder for recommendations and bullet fixes.",
subtitle: ats !== undefined && ats >= 80 ? `Use this version for ${profile.targetRole} role-fit scan or final polish.` : "Open Resume Builder for recommendations and bullet fixes.",
tag: "Resume",
urgency: ats !== undefined && ats < 75 ? "now" : "today",
href: SERVICE_HREFS.resume,
href: serviceHref("resume", ctx),
source: "resume",
priority: 90,
});
}
const firstMission = ctx.activeMissions[0];
if (interviewReview || interviewOverall !== undefined || interviewSession) {
pushSeed(seeds, {
moduleId: "productivity",
title: interviewOverall !== undefined ? `Mock interview score ${Math.round(interviewOverall)}` : "Mock interview review is tracking",
subtitle: interviewReview?.type.includes("processing") ? "Review is still processing; check back from the interview page." : "Open interview practice for review, next drill, or a new session.",
subtitle: interviewReview?.type.includes("processing") ? "Review is still processing; check back from the interview page." : `Open ${profile.targetRole} interview practice for review, next drill, or a new session.`,
tag: interviewReview?.type.includes("processing") ? "Wait" : "Mock",
urgency: interviewReview?.type.includes("processing") ? "soon" : "today",
href: SERVICE_HREFS.interview,
href: serviceHref("interview", ctx, { instanceId: firstMission?.instanceId, missionId: firstMission?.missionId, stageId: firstMission?.currentStageId }),
source: "interview",
priority: 86,
});
} else {
pushSeed(seeds, { moduleId: "productivity", title: "Schedule a mock interview", subtitle: "Generate a behavioral or role-related session from your target role.", tag: "Mock", urgency: "soon", href: SERVICE_HREFS.interview, source: "interview", priority: 72 });
pushSeed(seeds, { moduleId: "productivity", title: `Schedule a ${profile.targetRole} mock`, subtitle: "Generate a behavioral or role-related session from your target role.", tag: "Mock", urgency: "soon", href: serviceHref("interview", ctx, { instanceId: firstMission?.instanceId, missionId: firstMission?.missionId, stageId: firstMission?.currentStageId }), source: "interview", priority: 72 });
}
if (roleplayReview || roleplayComms !== undefined || roleplaySession) {
pushSeed(seeds, {
moduleId: "productivity",
title: roleplayComms !== undefined ? `Roleplay communication ${Math.round(roleplayComms)}` : "Roleplay scenario is ready",
subtitle: "Practice recruiter, manager, salary, or stakeholder conversations.",
subtitle: `Practice recruiter, manager, salary, or stakeholder conversations for ${profile.targetRole}.`,
tag: "Roleplay",
urgency: "soon",
href: SERVICE_HREFS.roleplay,
href: serviceHref("roleplay", ctx, { instanceId: firstMission?.instanceId, missionId: firstMission?.missionId, stageId: firstMission?.currentStageId }),
source: "roleplay",
priority: 78,
});
}
if (!ctx.activeMissions.length) {
pushSeed(seeds, { moduleId: "missions", title: "Start Interview-to-Offer", subtitle: "Bundle resume fit, mock practice, and Q Score deltas into one journey.", tag: "Begin", urgency: "today", href: SERVICE_HREFS.mission, 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;
}
async function collectContext(userId: string): Promise<HomeContext> {
async function collectContext(userId: string, input: { userProfile?: Record<string, unknown>; preferences?: Record<string, unknown> } = {}): Promise<HomeContext> {
const [user] = await db.select({ id: users.id, email: users.email, displayName: users.displayName }).from(users).where(eq(users.id, userId)).limit(1);
const [qscore] = await db.select().from(growQscoreProjectionState).where(eq(growQscoreProjectionState.userId, userId)).limit(1);
const qscoreSignals = await db
.select({ signalId: growQscoreLatest.signalId, score: growQscoreLatest.score, source: growQscoreLatest.source, updatedAt: growQscoreLatest.updatedAt })
.from(growQscoreLatest)
.where(eq(growQscoreLatest.userId, userId))
.orderBy(desc(growQscoreLatest.updatedAt))
.limit(30);
const qscoreResult = await getQscoreFromService(userId, DEFAULT_QSCORE_ORG_ID);
const breakdown = qscoreResult?.breakdown ?? {};
const breakdownSignals = Array.isArray(breakdown.signals) ? breakdown.signals : [];
const qscoreSignals = breakdownSignals.map((item) => {
const s = isRecord(item) ? item : {};
return {
signalId: typeof s.signalId === "string" ? s.signalId : typeof s.signal_id === "string" ? s.signal_id : "",
score: typeof s.score === "number" ? s.score : 0,
source: typeof s.source === "string" ? s.source : null,
updatedAt: new Date(typeof s.updatedAt === "string" ? s.updatedAt : (qscoreResult?.calculated_at ?? new Date().toISOString())),
};
});
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
.select({
id: missionSuggestions.id,
missionInstanceId: missionSuggestions.missionInstanceId,
missionId: missionSuggestions.missionId,
stageId: missionSuggestions.stageId,
role: missionSuggestions.role,
type: missionSuggestions.type,
title: missionSuggestions.title,
body: missionSuggestions.body,
reason: missionSuggestions.reason,
priority: missionSuggestions.priority,
urgency: missionSuggestions.urgency,
ctaLabel: missionSuggestions.ctaLabel,
ctaHref: missionSuggestions.ctaHref,
updatedAt: missionSuggestions.updatedAt,
})
.from(missionSuggestions)
.where(and(eq(missionSuggestions.userId, userId), eq(missionSuggestions.status, "active")))
.orderBy(desc(missionSuggestions.priority), desc(missionSuggestions.updatedAt))
.limit(12);
const sessions = await db
.select({ serviceId: missionServiceSessions.serviceId, externalId: missionServiceSessions.externalId, status: missionServiceSessions.status, updatedAt: missionServiceSessions.updatedAt, metadata: missionServiceSessions.metadata })
.from(missionServiceSessions)
@@ -286,20 +450,23 @@ async function collectContext(userId: string): Promise<HomeContext> {
return {
user,
qscore: qscore
qscore: qscoreResult
? {
score: qscore.score,
signalCount: qscore.signalCount,
summary: qscore.summary,
dimensions: isRecord(qscore.dimensions) ? qscore.dimensions : null,
score: qscoreResult.q_score,
signalCount: typeof breakdown.signalCount === "number" ? breakdown.signalCount : qscoreSignals.length,
summary: typeof breakdown.summary === "string" ? breakdown.summary : null,
dimensions: isRecord(breakdown.dimensions) ? breakdown.dimensions : isRecord(qscoreResult.quotients) ? qscoreResult.quotients : null,
}
: undefined,
qscoreSignals,
activeMissions,
missionSuggestions: suggestions,
sessions: sessions.map((s) => ({ ...s, metadata: isRecord(s.metadata) ? s.metadata : null })),
artifacts,
events: events.map((e) => ({ ...e, payload: isRecord(e.payload) ? e.payload : {} })),
serviceStates: { resume: resumeState, interview: interviewState, roleplay: roleplayState },
userProfile: input.userProfile,
preferences: input.preferences ?? {},
};
}
@@ -327,6 +494,20 @@ async function readPersistedNotifications(userId: string) {
.limit(60);
}
function hasLegacyMockSeed(rows: GrowHomeNotificationRow[]) {
const legacyTitles = new Set([
"Complete your QX self-check",
"Create your interview room",
"Browse 1 career pathway",
"Explore Interview-to-Offer",
"Pathways are warming up",
"Open Resume Builder",
"Try a 10-minute mock interview",
"Rewards unlock after activity",
]);
return rows.some((row) => legacyTitles.has(row.title));
}
async function replaceGeneratedNotifications(userId: string, notifications: Array<SeedNotification>, generatedBy: "deterministic" | "agent") {
await db
.delete(growHomeNotifications)
@@ -364,17 +545,8 @@ function ensureCoverage(seeds: SeedNotification[], fallback: SeedNotification[])
}
function moduleCount(moduleId: HomeModuleId, notifications: HomeNotification[], ctx: HomeContext, mode: HomeFeedResponse["mode"]) {
if (mode === "demo") {
if (moduleId === "missions") return "1 active";
if (moduleId === "productivity") return "4 updates";
if (moduleId === "social") return "3 ready";
if (moduleId === "pathways") return "Soon";
if (moduleId === "rewards") return "Demo";
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;
@@ -396,10 +568,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,
};
});
}
@@ -430,15 +603,17 @@ async function buildIdentity(ctx: HomeContext) {
};
}
export async function getHomeFeed(userId: string, opts: { refresh?: boolean } = {}): Promise<HomeFeedResponse> {
const ctx = await collectContext(userId);
export async function getHomeFeed(userId: string, opts: { refresh?: boolean; userProfile?: Record<string, unknown>; preferences?: Record<string, unknown> } = {}): Promise<HomeFeedResponse> {
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;
const hasDemo = persisted.some((row) => row.generatedBy === "demo");
const hasLegacyMock = hasLegacyMockSeed(persisted);
const fresh = newest > Date.now() - FRESH_MS;
if (persisted.length && (hasDemo || (!opts.refresh && fresh))) {
const mode = hasDemo ? "demo" : hasAnyRealActivity(ctx) ? "dynamic" : "day1";
if (persisted.length && !hasLegacyMock && !opts.refresh && fresh) {
const mode = hasAnyRealActivity(ctx) ? "dynamic" : "day1";
return {
generatedAt: new Date().toISOString(),
mode,
@@ -449,37 +624,7 @@ export async function getHomeFeed(userId: string, opts: { refresh?: boolean } =
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,
routeRules: SERVICE_HREFS,
},
seeds: deterministic,
});
const generatedBy = agentNotifications.length ? "agent" : "deterministic";
const generatedSeeds: SeedNotification[] = agentNotifications.length
? agentNotifications.map((n, index) => ({
moduleId: n.moduleId,
title: n.title,
subtitle: n.subtitle,
tag: n.tag,
urgency: n.urgency,
href: n.href,
source: n.source,
reason: n.reason,
priority: 100 - index,
}))
: deterministic;
await replaceGeneratedNotifications(userId, ensureCoverage(generatedSeeds, dayOneSeeds), generatedBy);
await replaceGeneratedNotifications(userId, ensureCoverage(deterministic, dayOneSeeds), "deterministic");
const rows = await readPersistedNotifications(userId);
const mode = hasAnyRealActivity(ctx) ? "dynamic" : "day1";

View File

@@ -1,165 +0,0 @@
import "dotenv/config";
import { and, eq, inArray } from "drizzle-orm";
import { db } from "../db/client.js";
import { growActiveMissions, growHomeNotifications, growQscoreLatest, growQscoreProjectionState, missionArtifacts, missionServiceSessions, users, type NewGrowHomeNotification } from "../db/schema.js";
import type { HomeModuleId, HomeSource, HomeUrgency } from "./types.js";
type DemoNotification = {
moduleId: HomeModuleId;
title: string;
subtitle: string;
tag: string;
urgency: HomeUrgency;
href: string;
source: HomeSource;
priority: number;
reason?: string;
};
const demoNotifications: DemoNotification[] = [
{ moduleId: "suggestions", title: "Approve resume v6", subtitle: "ATS 78 → 86 · two PM bullets are ready to ship.", tag: "Urgent", urgency: "now", href: "/agents/resume", source: "resume", priority: 120 },
{ moduleId: "suggestions", title: "Mock follow-up at 6:30 PM", subtitle: "Behavioral drill based on yesterday's review gaps.", tag: "Today", urgency: "today", href: "/agents/interview", source: "interview", priority: 116 },
{ moduleId: "suggestions", title: "Practice recruiter pushback", subtitle: "Roleplay can tighten your compensation and notice-period answer.", tag: "Soon", urgency: "soon", href: "/agents/roleplay", source: "roleplay", priority: 110 },
{ moduleId: "missions", title: "Interview-to-Offer — 72%", subtitle: "Resume fit done · mock review ready · final report locked.", tag: "Active", urgency: "today", href: "/missions", source: "mission", priority: 115 },
{ 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: "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: "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 },
{ moduleId: "productivity", title: "Roleplay recruiter screen", subtitle: "Scenario ready · handle salary range and start-date objections.", tag: "Roleplay", urgency: "soon", href: "/agents/roleplay", source: "roleplay", priority: 105 },
{ moduleId: "productivity", title: "Q Score now 86", subtitle: "Signals: resume ATS, mock review, and roleplay completion.", tag: "Q Score", urgency: "today", href: "/agents/qscore", source: "qscore", priority: 102 },
{ moduleId: "rewards", title: "Mentor pass preview", subtitle: "Demo reward only · would unlock after 3 completed readiness actions.", tag: "Demo", urgency: "calm", href: "/rewards", source: "rewards", priority: 60 },
{ moduleId: "rewards", title: "Resume Pro credit queued", subtitle: "Tabled for v1; shown as a consistent notification tile.", tag: "Soon", urgency: "calm", href: "/rewards", source: "rewards", priority: 58 },
];
export async function seedDemoHome(userId: string) {
await db.insert(users).values({ id: userId, email: `${userId}@demo.local`, displayName: "Demo User" }).onConflictDoNothing();
await db
.delete(growHomeNotifications)
.where(and(eq(growHomeNotifications.userId, userId), inArray(growHomeNotifications.generatedBy, ["demo", "agent", "deterministic"])));
const expiresAt = new Date(Date.now() + 14 * 24 * 60 * 60 * 1000);
const rows: NewGrowHomeNotification[] = demoNotifications.map((n) => ({
userId,
moduleId: n.moduleId,
title: n.title,
subtitle: n.subtitle,
tag: n.tag,
urgency: n.urgency,
href: n.href,
source: n.source,
priority: n.priority,
generatedBy: "demo",
reason: n.reason ?? "Seeded for multi-day dashboard demo.",
status: "active",
expiresAt,
updatedAt: new Date(),
}));
await db.insert(growHomeNotifications).values(rows);
await db.insert(growQscoreProjectionState).values({
userId,
score: 86,
signalCount: 14,
dimensions: { strengths: ["resume.ats_compatibility", "interview.overall_score"], growth: ["interview.response_clarity", "roleplay.problem_resolution"] },
summary: "Demo readiness score from resume, interview, and roleplay signals.",
updatedAt: new Date(),
}).onConflictDoUpdate({
target: growQscoreProjectionState.userId,
set: {
score: 86,
signalCount: 14,
dimensions: { strengths: ["resume.ats_compatibility", "interview.overall_score"], growth: ["interview.response_clarity", "roleplay.problem_resolution"] },
summary: "Demo readiness score from resume, interview, and roleplay signals.",
updatedAt: new Date(),
},
});
const signalRows = [
["resume.uploaded", 100, "resume-service"],
["resume.ats_compatibility", 86, "resume-service"],
["resume.keyword_relevance", 84, "resume-service"],
["interview.completed", 100, "interview-service"],
["interview.overall_score", 82, "interview-service"],
["interview.response_clarity", 74, "interview-service"],
["roleplay.completed", 100, "roleplay-service"],
["roleplay.communication_effectiveness", 79, "roleplay-service"],
] as const;
for (const [signalId, score, source] of signalRows) {
await db.insert(growQscoreLatest).values({
userId,
signalId,
score,
present: true,
source,
raw: { demo: true },
occurredAt: new Date(),
updatedAt: new Date(),
}).onConflictDoUpdate({
target: [growQscoreLatest.userId, growQscoreLatest.signalId],
set: { score, present: true, source, raw: { demo: true }, occurredAt: new Date(), updatedAt: new Date() },
});
}
await db.insert(growActiveMissions).values({
instanceId: `demo-interview-to-offer-${userId}`,
userId,
missionId: "interview-to-offer",
workflowId: "interview-to-offer",
actorType: "interviewToOfferMissionActor",
title: "Interview-to-Offer Accelerator",
shortTitle: "Interview-to-Offer",
status: "active",
progressPercent: 72,
currentStageId: "mock-interview-review",
goal: "Land a PM internship offer",
mission: { demo: true, missionId: "interview-to-offer" },
snapshot: { demo: true, progressPercent: 72 },
updatedAt: new Date(),
}).onConflictDoUpdate({
target: growActiveMissions.instanceId,
set: { status: "active", progressPercent: 72, currentStageId: "mock-interview-review", snapshot: { demo: true, progressPercent: 72 }, updatedAt: new Date() },
});
await db.insert(missionServiceSessions).values([
{ userId, missionInstanceId: `demo-interview-to-offer-${userId}`, missionId: "interview-to-offer", stageId: "resume-fit-scan", serviceId: "resume-service", externalId: `demo-resume-${userId}`, status: "completed", metadata: { demo: true, ats: 86 }, updatedAt: new Date() },
{ userId, missionInstanceId: `demo-interview-to-offer-${userId}`, missionId: "interview-to-offer", stageId: "mock-interview", serviceId: "interview-service", externalId: `demo-interview-${userId}`, status: "review_ready", metadata: { demo: true, overall: 82 }, updatedAt: new Date() },
{ userId, missionInstanceId: `demo-interview-to-offer-${userId}`, missionId: "interview-to-offer", stageId: "roleplay-recovery", serviceId: "roleplay-service", externalId: `demo-roleplay-${userId}`, status: "configured", metadata: { demo: true }, updatedAt: new Date() },
]).onConflictDoNothing();
await db.insert(missionArtifacts).values([
{ userId, missionInstanceId: `demo-interview-to-offer-${userId}`, missionId: "interview-to-offer", stageId: "resume-fit-scan", serviceId: "resume-service", externalId: `demo-resume-${userId}`, type: "resume_fit_scan", title: "Resume Fit Scan", status: "ready", summary: "ATS 86 with stronger PM keywords.", metadata: { demo: true }, updatedAt: new Date() },
{ userId, missionInstanceId: `demo-interview-to-offer-${userId}`, missionId: "interview-to-offer", stageId: "mock-interview", serviceId: "interview-service", externalId: `demo-interview-${userId}`, type: "mock_interview_review", title: "Mock Interview Review", status: "ready", summary: "Overall 82; improve story brevity and tradeoff framing.", metadata: { demo: true }, updatedAt: new Date() },
]).onConflictDoNothing();
return { inserted: rows.length, userId };
}
const entry = process.argv[1] ?? "";
if (entry.endsWith("seed-demo-home.ts") || entry.endsWith("seed-demo-home.js")) {
const userId = process.env.DEMO_USER_ID;
if (!userId) {
console.error("Set DEMO_USER_ID to seed demo home notifications.");
process.exit(1);
}
seedDemoHome(userId)
.then((result) => {
console.log(JSON.stringify(result, null, 2));
process.exit(0);
})
.catch((err) => {
console.error(err);
process.exit(1);
});
}

View File

@@ -34,31 +34,63 @@ export type HomeIdentity = {
export type HomeFeedResponse = {
generatedAt: string;
mode: "day1" | "dynamic" | "demo";
mode: "day1" | "dynamic";
identity: HomeIdentity;
modules: HomeModule[];
};
export const MODULE_META: Record<HomeModuleId, Omit<HomeModule, "count" | "notifications">> = {
suggestions: { id: "suggestions", label: "Suggestions", href: "/suggestions", accent: "orange" },
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" },
productivity: { id: "productivity", label: "Productivity", href: "/productivity", accent: "orange" },
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" },
};
export const MODULE_IDS: HomeModuleId[] = ["suggestions", "missions", "social", "pathways", "productivity", "rewards"];
export const MODULE_IDS: HomeModuleId[] = [
"suggestions",
"missions",
"social",
"pathways",
"productivity",
"rewards",
];
export const ALLOWED_NOTIFICATION_HREFS = new Set([
"/suggestions",
"/missions",
"/social",
"/pathways",
"/productivity",
"/missions/active",
"/missions/available",
"/agents/social-branding",
"/agents/matchmaking",
"/agents",
"/rewards",
"/agents/resume",
"/agents/interview",
"/agents/interview",
"/agents/roleplay",
"/agents/roleplay",
"/agents/qscore",
]);
export const ALLOWED_NOTIFICATION_HREF_PREFIXES = [
"/missions/",
"/missions/active",
"/missions/available",
"/agents/resume",
"/agents/interview",
"/agents/interview",
"/agents/roleplay",
"/agents/roleplay",
"/agents/qscore",
] as const;
export function isAllowedNotificationHref(href: string) {
if (ALLOWED_NOTIFICATION_HREFS.has(href)) return true;
return ALLOWED_NOTIFICATION_HREF_PREFIXES.some((prefix) =>
prefix.endsWith("/")
? href.startsWith(prefix)
: href === prefix || href.startsWith(`${prefix}?`),
);
}

View File

@@ -18,14 +18,21 @@ import { growRoutes } from "./routes/grow.js";
import { missionRoutes } from "./routes/missions.js";
import { eventRoutes } from "./routes/events.js";
import { homeRoutes } from "./routes/home.js";
import { dailyMissionRoutes } from "./routes/daily-mission.js";
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).
@@ -41,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();
@@ -86,7 +94,11 @@ async function main() {
app.route("/grow", growRoutes());
app.route("/missions", missionRoutes());
app.route("/events", eventRoutes());
app.route("/analytics", analyticsRoutes());
app.route("/v1", v1Routes());
app.route("/logs", logRoutes());
app.route("/home", homeRoutes());
app.route("/daily-mission", dailyMissionRoutes());
app.route("/conversations", conversationRoutes());
app.route("/opencode", opencodeRoutes());
app.route("/git", gitRoutes());

View File

@@ -164,7 +164,7 @@ export async function loadPromptsFromDisk(): Promise<void> {
} catch (err) {
log.error({ err, path: SYSTEM_PROMPT_FILE }, "failed to load system prompt — using fallback");
// Fallback: assemble from modules without a template file.
const fallback = `You are the Grow Agent — a unified AI orchestrator for the GrowQR platform.\n\n## Sub-Agent Capabilities\n\n${modules.map((m) => `- **${m.name}**: ${m.description}`).join("\n")}`;
const fallback = `You are Grow — a unified AI career assistant for the GrowQR platform.\n\n## Specialist Capabilities\n\n${modules.map((m) => `- **${m.name}**: ${m.description}`).join("\n")}`;
cachedSystemPrompt = fallback;
}
}

View File

@@ -0,0 +1,80 @@
import type { InferSelectModel } from "drizzle-orm";
import type { missionActions } from "../db/schema.js";
export type MissionActionMode = "autonomous" | "approval_required" | "user_input_required" | "suggestion";
export type MissionActionStatus =
| "queued"
| "running"
| "waiting_approval"
| "waiting_user_input"
| "done"
| "failed"
| "dismissed"
| "snoozed";
export type MissionActionUrgency = "now" | "today" | "soon" | "calm";
export type MissionActionRow = InferSelectModel<typeof missionActions>;
export type MissionActionDto = {
id: string;
userId: string;
missionInstanceId: string;
missionId: string;
stageId?: string;
agentId: string;
agentName: string;
baseAgent?: string;
serviceId?: string;
toolName?: string;
mode: MissionActionMode;
status: MissionActionStatus;
title: string;
body: string;
prompt?: string;
payload: Record<string, unknown>;
result?: Record<string, unknown>;
error?: string;
sourceEventId?: string;
idempotencyKey?: string;
priority: number;
urgency: MissionActionUrgency;
dueAt?: string;
createdAt: string;
updatedAt: string;
resolvedAt?: string;
};
export type NewMissionActionInput = {
userId: string;
missionInstanceId: string;
missionId: string;
stageId?: string;
agentId: string;
agentName: string;
baseAgent?: string;
serviceId?: string;
toolName?: string;
mode: MissionActionMode;
status?: MissionActionStatus;
title: string;
body: string;
prompt?: string;
payload?: Record<string, unknown>;
result?: Record<string, unknown>;
error?: string;
sourceEventId?: string;
idempotencyKey?: string;
priority?: number;
urgency?: MissionActionUrgency;
dueAt?: Date | string;
};
export function defaultMissionActionStatus(mode: MissionActionMode): MissionActionStatus {
if (mode === "approval_required") return "waiting_approval";
if (mode === "user_input_required") return "waiting_user_input";
return "queued";
}
export function isOpenMissionActionStatus(status: MissionActionStatus) {
return status === "queued" || status === "running" || status === "waiting_approval" || status === "waiting_user_input" || status === "failed";
}

196
src/missions/actions.ts Normal file
View File

@@ -0,0 +1,196 @@
import { and, desc, eq, inArray } from "drizzle-orm";
import { db } from "../db/client.js";
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 } 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"];
function toDate(value?: Date | string) {
if (!value) return undefined;
return value instanceof Date ? value : new Date(value);
}
export function actionToDto(row: MissionActionRow): MissionActionDto {
return {
id: row.id,
userId: row.userId,
missionInstanceId: row.missionInstanceId,
missionId: row.missionId,
stageId: row.stageId ?? undefined,
agentId: row.agentId,
agentName: row.agentName,
baseAgent: row.baseAgent ?? undefined,
serviceId: row.serviceId ?? undefined,
toolName: row.toolName ?? undefined,
mode: row.mode,
status: row.status,
title: row.title,
body: row.body,
prompt: row.prompt ?? undefined,
payload: row.payload ?? {},
result: row.result ?? undefined,
error: row.error ?? undefined,
sourceEventId: row.sourceEventId ?? undefined,
idempotencyKey: row.idempotencyKey ?? undefined,
priority: row.priority,
urgency: row.urgency,
dueAt: row.dueAt?.toISOString(),
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
resolvedAt: row.resolvedAt?.toISOString(),
};
}
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 missionHref = missionDetailHref(action.missionInstanceId);
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 };
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;
const category = getService(action.serviceId)?.category;
if (category === "practice") return "practice" as const;
if (category === "document") return "artifact" as const;
return "action" as const;
}
async function refreshSuggestionForAction(row: MissionActionRow) {
const active = OPEN_STATUSES.includes(row.status);
const { ctaLabel, ctaHref } = ctaForAction(row);
const status = active ? "active" : row.status === "done" ? "done" : "dismissed";
await db.insert(missionSuggestions).values({
id: `suggestion:${row.id}`,
userId: row.userId,
missionInstanceId: row.missionInstanceId,
missionId: row.missionId,
stageId: row.stageId,
role: row.agentName,
type: suggestionTypeForAction(row),
title: row.title,
body: row.body,
reason: row.prompt,
priority: row.priority,
urgency: row.urgency,
status,
ctaLabel,
ctaHref,
sourceRefs: { actionId: row.id, sourceEventId: row.sourceEventId, toolName: row.toolName },
generatedBy: "agent",
updatedAt: new Date(),
}).onConflictDoUpdate({
target: missionSuggestions.id,
set: {
stageId: row.stageId,
role: row.agentName,
type: suggestionTypeForAction(row),
title: row.title,
body: row.body,
reason: row.prompt,
priority: row.priority,
urgency: row.urgency,
status,
ctaLabel,
ctaHref,
sourceRefs: { actionId: row.id, sourceEventId: row.sourceEventId, toolName: row.toolName },
generatedBy: "agent",
updatedAt: new Date(),
},
});
}
export async function createMissionAction(input: NewMissionActionInput) {
const [row] = await db.insert(missionActions).values({
...input,
status: input.status ?? defaultMissionActionStatus(input.mode),
priority: input.priority ?? 0,
urgency: input.urgency ?? "calm",
payload: input.payload ?? {},
result: input.result,
dueAt: toDate(input.dueAt),
updatedAt: new Date(),
}).onConflictDoNothing().returning();
if (!row) return null;
await refreshSuggestionForAction(row);
return actionToDto(row);
}
export async function createMissionActionsFromPatches(input: { userId: string; mission: GrowActiveMission; eventId: string; patches: MissionActionPatch[] }) {
const created: MissionActionDto[] = [];
for (const patch of input.patches) {
const action = await createMissionAction({
userId: input.userId,
missionInstanceId: input.mission.instanceId,
missionId: input.mission.missionId,
stageId: patch.stageId,
agentId: patch.agentId,
agentName: patch.agentName,
baseAgent: patch.baseAgent,
serviceId: patch.serviceId,
toolName: patch.toolName,
mode: patch.mode,
status: patch.status,
title: patch.title,
body: patch.body,
prompt: patch.prompt,
payload: patch.payload ?? {},
sourceEventId: patch.sourceEventId ?? input.eventId,
idempotencyKey: patch.idempotencyKey,
priority: patch.priority,
urgency: patch.urgency,
dueAt: patch.dueAt,
});
if (action) created.push(action);
}
return created;
}
export async function listMissionActions(userId: string, opts: { missionInstanceId?: string; openOnly?: boolean } = {}) {
const conditions = [eq(missionActions.userId, userId)];
if (opts.missionInstanceId) conditions.push(eq(missionActions.missionInstanceId, opts.missionInstanceId));
if (opts.openOnly ?? true) conditions.push(inArray(missionActions.status, OPEN_STATUSES));
const rows = await db.select().from(missionActions).where(and(...conditions)).orderBy(desc(missionActions.priority), desc(missionActions.updatedAt));
return rows.map(actionToDto);
}
export async function getMissionAction(userId: string, actionId: string) {
const [row] = await db.select().from(missionActions).where(and(eq(missionActions.userId, userId), eq(missionActions.id, actionId))).limit(1);
return row ?? null;
}
export async function updateMissionActionStatus(userId: string, actionId: string, patch: { status: MissionActionStatus; result?: Record<string, unknown>; error?: string; payload?: Record<string, unknown> }) {
const resolvedAt = DONE_STATUSES.includes(patch.status) ? new Date() : undefined;
const [row] = await db.update(missionActions).set({
status: patch.status,
result: patch.result,
error: patch.error,
payload: patch.payload,
resolvedAt,
updatedAt: new Date(),
}).where(and(eq(missionActions.userId, userId), eq(missionActions.id, actionId))).returning();
if (!row) return null;
await refreshSuggestionForAction(row);
return actionToDto(row);
}

View File

@@ -0,0 +1,129 @@
import type { MissionReducer, MissionReduction, MissionStagePatch } from "../reducer-types.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",
accepts(ctx) {
return ctx.activeMission.missionId === "career-transition" &&
(missionExplicitlyMatches(ctx.event.mission, "career-transition") || isRelevantServiceEvent(ctx.event.source, ctx.event.type));
},
reduce(ctx): MissionReduction {
const { event, activeMission } = ctx;
const type = event.type;
const payload = event.payload ?? {};
const stagePatches: MissionStagePatch[] = [];
const artifacts: MissionReduction["artifacts"] = [];
const actions: MissionReduction["actions"] = [];
let eventMessage = ctx.insight.summary;
if (!activeMission.goal && (type === "mission.started" || type.startsWith("mission."))) {
actions.push(actionForAgent("career-transition", "planner", {
stageId: "clarify-target",
mode: "user_input_required",
title: "Choose the target role for your transition",
body: "Career transition needs a clear adjacent role before resume repositioning or practice will be useful.",
prompt: "What role are you exploring next, and what role/background are you moving from?",
payload: { fields: ["current_role", "target_role", "constraints"] },
idempotencyKey: `${activeMission.instanceId}:clarify-target-role`,
priority: 100,
urgency: "now",
}));
}
if (isResumeEvent(event.source, type) && (type.includes("analysis") || type.includes("parsed") || type.includes("analyzed"))) {
const signals = extractResumeSignals(payload);
stagePatches.push({ stageId: "resume", status: "done", progressPercent: 100, outputSummary: "Transferable skills mapped from resume evidence." });
stagePatches.push({ stageId: "interview", status: "ready", progressPercent: 0, outputSummary: "Validate the transition story in an adjacent-role mock interview." });
artifacts.push({ type: "transferable_skills_map", title: "Transferable skills map", stageId: "resume", summary: signals[0] ?? "Resume proof was mapped into transition evidence.", metadata: { sourceEventId: event.id, signals } });
actions.push(actionForAgent("career-transition", "resume", {
stageId: "resume",
serviceId: "resume-service",
toolName: "resume.create_version_prompt_draft",
mode: "approval_required",
title: "Draft a repositioned resume for the target role?",
body: "Approve a Resume Agent draft that reframes your transferable skills for the career switch.",
payload: { signals, href: serviceHref("resume", activeMission.instanceId, activeMission.missionId, "resume") },
sourceEventId: event.id,
idempotencyKey: `${activeMission.instanceId}:resume-transition-draft:${event.id}`,
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.";
}
if (isInterviewEvent(event.source, type) && type.includes("configured")) {
stagePatches.push({ stageId: "interview", status: "in_progress", progressPercent: 40, outputSummary: "Adjacent-role mock interview configured." });
eventMessage = "Adjacent-role interview practice started.";
}
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." });
artifacts.push({ type: "transition_interview_diagnosis", title: "Adjacent-role credibility diagnosis", stageId: "interview", summary: weakAreas.length ? `Needs work: ${weakAreas.join(", ")}` : "Interview review completed for transition credibility.", metadata: { sourceEventId: event.id, weakAreas } });
actions.push(actionForAgent("career-transition", "roleplay", {
stageId: "roleplay",
serviceId: "roleplay-service",
toolName: "roleplay.configure_practice",
mode: "suggestion",
title: "Practice your 'why I am switching' pitch",
body: "Turn the adjacent-role feedback into a confident transition narrative before real conversations.",
payload: { weakAreas, href: serviceHref("roleplay", activeMission.instanceId, activeMission.missionId, "roleplay") },
sourceEventId: event.id,
idempotencyKey: `${activeMission.instanceId}:transition-pitch-roleplay:${event.id}`,
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) && 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.";
}
if (ctx.qscoreSignals.length) stagePatches.push({ stageId: "qscore", status: "in_progress", progressPercent: Math.min(90, 45 + ctx.qscoreSignals.length * 10), outputSummary: "Transition readiness signals updated." });
return { stagePatches, artifacts, actions, eventMessage };
},
};

View File

@@ -1,7 +1,17 @@
import type { MissionReducer } from "./reducer-types.js";
import { interviewToOfferReducer } from "./interview-to-offer/reducer.js";
import { careerTransitionReducer } from "./career-transition/reducer.js";
import { salaryNegotiationReducer } from "./salary-negotiation-war-room/reducer.js";
import { promotionReadinessReducer } from "./promotion-readiness/reducer.js";
import { personalBrandOpportunityReducer } from "./personal-brand-opportunity-engine/reducer.js";
export const missionEventReducers: MissionReducer[] = [interviewToOfferReducer];
export const missionEventReducers: MissionReducer[] = [
interviewToOfferReducer,
careerTransitionReducer,
salaryNegotiationReducer,
promotionReadinessReducer,
personalBrandOpportunityReducer,
];
export function reducersForMission(missionId: string) {
return missionEventReducers.filter((reducer) => reducer.missionId === missionId);

View File

@@ -1,99 +1,229 @@
import { asRecord, getNumber, getString } from "../../events/envelope.js";
import type { MissionArtifactPatch, MissionReducer, MissionReduction, MissionStagePatch } from "../reducer-types.js";
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,
isRelevantServiceEvent,
isResumeEvent,
isRoleplayEvent,
missionExplicitlyMatches,
serviceHref,
} from "../reducer-helpers.js";
function eventMatchesSource(source: string, type: string) {
return (
source.includes("resume") ||
source.includes("interview") ||
source.includes("qscore") ||
type.startsWith("resume.") ||
type.startsWith("interview.") ||
type.startsWith("qscore.") ||
type.startsWith("mission.interview_to_offer")
);
function acceptsMission(ctx: Parameters<MissionReducer["accepts"]>[0]) {
return ctx.activeMission.missionId === "interview-to-offer" &&
(missionExplicitlyMatches(ctx.event.mission, "interview-to-offer") || isRelevantServiceEvent(ctx.event.source, ctx.event.type));
}
function reviewSummary(payload: Record<string, unknown>) {
const review = asRecord(payload.review ?? payload.result ?? payload);
const overall = getNumber(review.overall_score ?? review.overallScore);
const summary = getString(review.summary ?? review.feedback_summary ?? review.overall_feedback);
if (overall !== undefined && summary) return `Mock interview review completed with score ${overall}. ${summary}`;
if (overall !== undefined) return `Mock interview review completed with score ${overall}.`;
const score = extractOverallScore(payload);
const summary = getString(payload.summary ?? payload.feedback_summary ?? payload.overall_feedback);
if (score !== undefined && summary) return `Mock interview review completed with score ${score}. ${summary}`;
if (score !== undefined) return `Mock interview review completed with score ${score}.`;
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(ctx) {
if (ctx.activeMission.missionId !== "interview-to-offer") return false;
const mission = asRecord(ctx.event.mission);
const explicitMissionId = getString(mission.missionId ?? mission.mission_id);
return explicitMissionId === "interview-to-offer" || eventMatchesSource(ctx.event.source.toLowerCase(), ctx.event.type);
},
accepts: acceptsMission,
reduce(ctx): MissionReduction {
const type = ctx.event.type;
const payload = ctx.event.payload ?? {};
const { event, activeMission } = ctx;
const type = event.type;
const payload = event.payload ?? {};
const stagePatches: MissionStagePatch[] = [...ctx.insight.missionStageHints.map((hint) => ({
stageId: hint.stageId,
status: hint.status,
progressPercent: hint.progressPercent,
outputSummary: hint.reason,
}))];
const artifacts: MissionArtifactPatch[] = [];
const artifacts: MissionReduction["artifacts"] = [];
const actions: MissionReduction["actions"] = [];
let eventMessage = ctx.insight.summary;
if (type.startsWith("resume.") && (type.includes("analysis_completed") || type.includes("analysis.complete") || type.includes("analyzed"))) {
stagePatches.push({ stageId: "resume", status: "done", progressPercent: 100, outputSummary: "Resume fit scan completed." });
stagePatches.push({ stageId: "interview-plan", status: "ready", progressPercent: 0 });
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({
type: "resume_fit_scan",
title: "Resume Fit Scan",
type: "resume_talking_points",
title: "Resume-based talking points",
stageId: "resume",
summary: getString(payload.summary) ?? "Resume analysis completed and readiness signals were updated.",
metadata: { sourceEventId: ctx.event.id, payload },
summary: signals[0] ?? "Resume analysis completed and role-fit talking points are ready.",
metadata: { sourceEventId: event.id, signals, payload },
});
eventMessage = "Resume fit scan completed for Interview-to-Offer.";
actions.push(actionForAgent("interview-to-offer", "interview", {
stageId: "interview",
serviceId: "interview-service",
toolName: "interview.configure_practice",
mode: "suggestion",
title: "Start the first mock interview",
body: "Your resume proof is ready. Run a targeted mock interview for the scheduled role now.",
payload: { href: serviceHref("interview", activeMission.instanceId, activeMission.missionId, "interview") },
sourceEventId: event.id,
idempotencyKey: `${activeMission.instanceId}:resume-analysis:start-interview:${event.id}`,
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.";
}
if (type.startsWith("interview.") && (type.includes("configured") || type.includes("created"))) {
if (isInterviewEvent(event.source, type) && (type.includes("configured") || type.includes("created"))) {
stagePatches.push({ stageId: "interview", status: "in_progress", progressPercent: 35, outputSummary: "Mock interview session configured." });
eventMessage = "Mock interview session configured.";
}
if (type.startsWith("interview.") && (type.includes("session_completed") || type.includes("session.completed"))) {
if (isInterviewEvent(event.source, type) && (type.includes("session_completed") || type.includes("session.completed"))) {
stagePatches.push({ stageId: "interview", status: "in_progress", progressPercent: 75, outputSummary: "Interview completed; review is being prepared." });
eventMessage = "Mock interview completed; waiting for review.";
}
if (type.startsWith("interview.") && (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." });
artifacts.push({
type: "mock_interview_review",
title: "Mock Interview Review",
title: "Weakness diagnosis",
stageId: "interview",
summary: reviewSummary(payload),
metadata: { sourceEventId: ctx.event.id, payload },
metadata: { sourceEventId: event.id, weakAreas, payload },
});
eventMessage = "Mock interview review completed and mission readiness was updated.";
actions.push(actionForAgent("interview-to-offer", "resume", {
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: 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: 108,
urgency: "now",
}));
if (weakAreas.some((area) => /communication|story|clarity|confidence|concise/i.test(area))) {
actions.push(actionForAgent("interview-to-offer", "roleplay", {
stageId: "roleplay",
serviceId: "roleplay-service",
toolName: "roleplay.configure_practice",
mode: "suggestion",
title: "Run a communication recovery drill",
body: "Practice the exact communication weakness from the mock interview before the real interview.",
payload: { weakAreas, href: serviceHref("roleplay", activeMission.instanceId, activeMission.missionId, "roleplay") },
sourceEventId: event.id,
idempotencyKey: `${activeMission.instanceId}:interview-review:roleplay:${event.id}`,
priority: 94,
urgency: "today",
}));
}
eventMessage = "Interview review completed; resume and roleplay next actions were created.";
}
if (ctx.qscoreSignals.length > 0) {
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.";
}
if (ctx.qscoreSignals.length > 0 || (isQscoreEvent(event.source, type) && type.includes("updated"))) {
stagePatches.push({
stageId: "qscore",
status: "in_progress",
progressPercent: Math.max(40, Math.min(90, ctx.qscoreSignals.length * 15)),
outputSummary: `${ctx.qscoreSignals.length} readiness signal${ctx.qscoreSignals.length === 1 ? "" : "s"} updated.`,
status: type.startsWith("qscore.") ? "done" : "in_progress",
progressPercent: type.startsWith("qscore.") ? 100 : Math.max(40, Math.min(90, ctx.qscoreSignals.length * 15)),
outputSummary: `${ctx.qscoreSignals.length || 1} readiness signal${ctx.qscoreSignals.length === 1 ? "" : "s"} updated.`,
});
}
if (type.startsWith("qscore.") && (type.includes("snapshot") || type.includes("computed") || type.includes("updated"))) {
stagePatches.push({ stageId: "qscore", status: "done", progressPercent: 100, outputSummary: "Readiness Q Score updated." });
eventMessage = "Readiness Q Score updated.";
}
return { stagePatches, artifacts, eventMessage };
return { stagePatches, artifacts, actions, eventMessage };
},
};

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

@@ -0,0 +1,124 @@
import type { MissionReducer, MissionReduction, MissionStagePatch } from "../reducer-types.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",
accepts(ctx) {
return ctx.activeMission.missionId === "personal-brand-opportunity-engine" &&
(missionExplicitlyMatches(ctx.event.mission, "personal-brand-opportunity-engine") || isRelevantServiceEvent(ctx.event.source, ctx.event.type));
},
reduce(ctx): MissionReduction {
const { event, activeMission } = ctx;
const type = event.type;
const payload = event.payload ?? {};
const stagePatches: MissionStagePatch[] = [];
const artifacts: MissionReduction["artifacts"] = [];
const actions: MissionReduction["actions"] = [];
let eventMessage = ctx.insight.summary;
if (type === "mission.started" || type.startsWith("mission.")) {
actions.push(actionForAgent("personal-brand-opportunity-engine", "planner", {
stageId: "positioning",
mode: "user_input_required",
title: "Pick the audience you want to be known by",
body: "Personal brand only works when the target audience and credibility theme are explicit.",
prompt: "Who should notice you, and what do you want to be known for?",
payload: { fields: ["target_audience", "positioning_theme", "proof_points"] },
idempotencyKey: `${activeMission.instanceId}:brand-positioning`,
priority: 96,
urgency: "today",
}));
}
if (isResumeEvent(event.source, type) && (type.includes("analysis") || type.includes("parsed") || type.includes("analyzed"))) {
const signals = extractResumeSignals(payload);
stagePatches.push({ stageId: "resume", status: "done", progressPercent: 100, outputSummary: "Proof points extracted for positioning." });
stagePatches.push({ stageId: "roleplay", status: "ready", progressPercent: 0, outputSummary: "Practice networking pitch next." });
artifacts.push({ type: "positioning_statement", title: "Positioning statement draft", stageId: "resume", summary: signals[0] ?? "Strongest proof points were extracted from resume evidence.", metadata: { sourceEventId: event.id, signals } });
actions.push(actionForAgent("personal-brand-opportunity-engine", "resume", {
stageId: "resume",
serviceId: "resume-service",
toolName: "resume.create_version_prompt_draft",
mode: "approval_required",
title: "Draft your profile positioning statement?",
body: "Approve a draft that turns your strongest resume proof into a clear LinkedIn/profile positioning statement.",
payload: { signals, href: serviceHref("resume", activeMission.instanceId, activeMission.missionId, "resume") },
sourceEventId: event.id,
idempotencyKey: `${activeMission.instanceId}:brand-positioning-draft:${event.id}`,
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) && 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",
title: "Turn this pitch into weekly content pillars",
body: "Use the networking practice feedback to draft 3 credibility themes for weekly posts.",
payload: { weakAreas, href: missionDetailHref(activeMission.instanceId) },
sourceEventId: event.id,
idempotencyKey: `${activeMission.instanceId}:content-pillars:${event.id}`,
priority: 82,
urgency: "soon",
}));
actions.push(passive.action);
eventMessage = "Networking pitch review created brand content next steps.";
}
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.";
}
if (ctx.qscoreSignals.length) stagePatches.push({ stageId: "qscore", status: "in_progress", progressPercent: Math.min(90, 45 + ctx.qscoreSignals.length * 10), outputSummary: "Brand growth/readiness signals updated." });
return { stagePatches, artifacts, actions, eventMessage };
},
};

View File

@@ -0,0 +1,126 @@
import type { MissionReducer, MissionReduction, MissionStagePatch } from "../reducer-types.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",
accepts(ctx) {
return ctx.activeMission.missionId === "promotion-readiness" &&
(missionExplicitlyMatches(ctx.event.mission, "promotion-readiness") || isRelevantServiceEvent(ctx.event.source, ctx.event.type));
},
reduce(ctx): MissionReduction {
const { event, activeMission } = ctx;
const type = event.type;
const payload = event.payload ?? {};
const stagePatches: MissionStagePatch[] = [];
const artifacts: MissionReduction["artifacts"] = [];
const actions: MissionReduction["actions"] = [];
let eventMessage = ctx.insight.summary;
if (type === "mission.started" || type.startsWith("mission.")) {
actions.push(actionForAgent("promotion-readiness", "planner", {
stageId: "promotion-context",
mode: "user_input_required",
title: "Clarify your promotion target",
body: "Promotion readiness needs the desired level, timeline, manager context, and stakeholder map.",
prompt: "What role/level are you targeting, by when, and what does your manager care about most?",
payload: { fields: ["current_role", "desired_role", "timeline", "manager_context", "stakeholders"] },
idempotencyKey: `${activeMission.instanceId}:promotion-context`,
priority: 100,
urgency: "today",
}));
}
if (isResumeEvent(event.source, type) && (type.includes("analysis") || type.includes("parsed") || type.includes("analyzed"))) {
const signals = extractResumeSignals(payload);
stagePatches.push({ stageId: "resume", status: "done", progressPercent: 100, outputSummary: "Promotion evidence extracted from achievement history." });
stagePatches.push({ stageId: "roleplay", status: "ready", progressPercent: 0, outputSummary: "Manager conversation practice is ready." });
artifacts.push({ type: "promotion_evidence_packet", title: "Promotion evidence packet", stageId: "resume", summary: signals[0] ?? "Achievement evidence extracted for promotion case.", metadata: { sourceEventId: event.id, signals } });
actions.push(actionForAgent("promotion-readiness", "roleplay", {
stageId: "roleplay",
serviceId: "roleplay-service",
toolName: "roleplay.configure_practice",
mode: "suggestion",
title: "Practice the manager promotion conversation",
body: "Use your achievement evidence in a realistic manager conversation drill.",
payload: { signals, href: serviceHref("roleplay", activeMission.instanceId, activeMission.missionId, "roleplay") },
sourceEventId: event.id,
idempotencyKey: `${activeMission.instanceId}:promotion-manager-roleplay:${event.id}`,
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) && 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",
toolName: "interview.configure_practice",
mode: "suggestion",
title: "Practice leadership stories",
body: "Run a leadership-style mock interview to tighten the promotion case.",
payload: { weakAreas, href: serviceHref("interview", activeMission.instanceId, activeMission.missionId, "interview") },
sourceEventId: event.id,
idempotencyKey: `${activeMission.instanceId}:promotion-leadership-interview:${event.id}`,
priority: 86,
urgency: "soon",
}));
actions.push(passive.action);
eventMessage = "Manager conversation review updated promotion readiness.";
}
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.";
}
if (ctx.qscoreSignals.length) stagePatches.push({ stageId: "qscore", status: "in_progress", progressPercent: Math.min(90, 45 + ctx.qscoreSignals.length * 10), outputSummary: "Promotion readiness signals updated." });
return { stagePatches, artifacts, actions, eventMessage };
},
};

View File

@@ -0,0 +1,341 @@
import { asRecord, getNumber, getString } from "../events/envelope.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();
return value.includes("resume") || type.startsWith("resume.");
}
export function isInterviewEvent(source: string, type: string) {
const value = source.toLowerCase();
return value.includes("interview") || type.startsWith("interview.");
}
export function isRoleplayEvent(source: string, type: string) {
const value = source.toLowerCase();
return value.includes("roleplay") || type.startsWith("roleplay.");
}
export function isQscoreEvent(source: string, type: string) {
const value = source.toLowerCase();
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);
}
export function extractOverallScore(payload: Record<string, unknown>) {
const review = reviewRecord(payload);
return getNumber(review.overall_score ?? review.overallScore ?? review.score ?? payload.overall_score);
}
export function extractWeakAreas(payload: Record<string, unknown>): string[] {
const review = reviewRecord(payload);
const candidates = [
review.weak_areas,
review.weakAreas,
review.improvement_areas,
review.improvementAreas,
review.recommendations,
review.gaps,
];
const areas: string[] = [];
for (const candidate of candidates) {
if (Array.isArray(candidate)) {
for (const item of candidate) {
if (typeof item === "string" && item.trim()) areas.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.area ?? row.name ?? row.label ?? row.summary);
if (text) areas.push(text);
}
}
} else if (typeof candidate === "string" && candidate.trim()) {
areas.push(...candidate.split(/[;,]/).map((part) => part.trim()).filter(Boolean));
}
}
const summary = getString(review.summary ?? review.feedback_summary ?? review.overall_feedback);
if (!areas.length && summary) {
const lower = summary.toLowerCase();
if (lower.includes("communication") || lower.includes("clarity") || lower.includes("story")) areas.push("communication clarity");
if (lower.includes("technical") || lower.includes("role")) areas.push("role-fit depth");
if (lower.includes("confidence") || lower.includes("concise")) areas.push("confidence and concision");
}
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[] = [];
const summary = getString(analysis.summary ?? analysis.overall_feedback ?? payload.summary);
if (summary) signals.push(summary);
for (const key of ["strengths", "gaps", "recommendations", "missing_keywords", "keyword_gaps"]) {
const value = analysis[key];
if (Array.isArray(value)) {
for (const item of value.slice(0, 4)) if (typeof item === "string") signals.push(item);
}
}
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);
return explicit === missionId;
}
export function isRelevantServiceEvent(source: string, type: string) {
return isResumeEvent(source, type) || isInterviewEvent(source, type) || isRoleplayEvent(source, type) || isQscoreEvent(source, type);
}
const AGENT_NAMES: Record<string, Record<string, { agentId: string; baseAgent: string; agentName: string }>> = {
"interview-to-offer": {
planner: { agentId: "planner", baseAgent: "mission-planner", agentName: "Offer Strategist" },
resume: { agentId: "resume", baseAgent: "resume-strategist", agentName: "Resume Fit Agent" },
interview: { agentId: "interview", baseAgent: "interview-coach", agentName: "Mock Interviewer" },
roleplay: { agentId: "roleplay", baseAgent: "roleplay-coach", agentName: "Communication Coach" },
qscore: { agentId: "qscore", baseAgent: "qscore-analyst", agentName: "Readiness Analyst" },
},
"career-transition": {
planner: { agentId: "planner", baseAgent: "mission-planner", agentName: "Transition Strategist" },
resume: { agentId: "resume", baseAgent: "resume-strategist", agentName: "Transferable Skills Agent" },
interview: { agentId: "interview", baseAgent: "interview-coach", agentName: "Adjacent Role Interviewer" },
roleplay: { agentId: "roleplay", baseAgent: "roleplay-coach", agentName: "Transition Pitch Coach" },
qscore: { agentId: "qscore", baseAgent: "qscore-analyst", agentName: "Transition Readiness Analyst" },
},
"salary-negotiation-war-room": {
planner: { agentId: "planner", baseAgent: "mission-planner", agentName: "Negotiation Strategist" },
resume: { agentId: "resume", baseAgent: "resume-strategist", agentName: "Value Evidence Agent" },
interview: { agentId: "interview", baseAgent: "interview-coach", agentName: "Confidence Coach" },
roleplay: { agentId: "roleplay", baseAgent: "roleplay-coach", agentName: "Negotiation Drill Coach" },
qscore: { agentId: "qscore", baseAgent: "qscore-analyst", agentName: "Confidence Analyst" },
},
"promotion-readiness": {
planner: { agentId: "planner", baseAgent: "mission-planner", agentName: "Promotion Strategist" },
resume: { agentId: "resume", baseAgent: "resume-strategist", agentName: "Achievement Evidence Agent" },
interview: { agentId: "interview", baseAgent: "interview-coach", agentName: "Leadership Interview Coach" },
roleplay: { agentId: "roleplay", baseAgent: "roleplay-coach", agentName: "Manager Conversation Coach" },
qscore: { agentId: "qscore", baseAgent: "qscore-analyst", agentName: "Leadership Readiness Analyst" },
},
"personal-brand-opportunity-engine": {
planner: { agentId: "planner", baseAgent: "mission-planner", agentName: "Brand Strategist" },
resume: { agentId: "resume", baseAgent: "resume-strategist", agentName: "Proof Point Agent" },
interview: { agentId: "interview", baseAgent: "interview-coach", agentName: "Credibility Coach" },
roleplay: { agentId: "roleplay", baseAgent: "roleplay-coach", agentName: "Networking Pitch Coach" },
qscore: { agentId: "qscore", baseAgent: "qscore-analyst", agentName: "Visibility Analyst" },
},
};
export function actionForAgent(missionId: string, agent: "planner" | "resume" | "interview" | "roleplay" | "qscore", patch: Omit<MissionActionPatch, "agentId" | "agentName" | "baseAgent">): MissionActionPatch {
const fallback = AGENT_NAMES["interview-to-offer"]?.[agent] ?? { agentId: agent, baseAgent: agent, agentName: agent };
const spec = AGENT_NAMES[missionId]?.[agent] ?? fallback;
return { ...spec, ...patch };
}
export function serviceHref(service: "resume" | "interview" | "roleplay" | "qscore", missionInstanceId: string, missionId: string, stageId?: string) {
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

@@ -2,6 +2,7 @@ import type { GrowEventRow } from "../db/schema.js";
import type { ProjectionInsight } from "../events/projectors/projection-agent.js";
import type { QscoreSignal } from "../events/envelope.js";
import type { GrowActiveMission, MissionStageStatus } from "../actors/missions/types.js";
import type { MissionActionMode, MissionActionStatus, MissionActionUrgency } from "./action-types.js";
export type MissionReducerContext = {
userId: string;
@@ -27,9 +28,30 @@ export type MissionStagePatch = {
outputSummary?: string;
};
export type MissionActionPatch = {
stageId?: string;
agentId: string;
agentName: string;
baseAgent?: string;
serviceId?: string;
toolName?: string;
mode: MissionActionMode;
status?: MissionActionStatus;
title: string;
body: string;
prompt?: string;
payload?: Record<string, unknown>;
sourceEventId?: string;
idempotencyKey?: string;
priority?: number;
urgency?: MissionActionUrgency;
dueAt?: string;
};
export type MissionReduction = {
stagePatches: MissionStagePatch[];
artifacts: MissionArtifactPatch[];
actions: MissionActionPatch[];
eventMessage?: string;
};

View File

@@ -0,0 +1,128 @@
import type { MissionReducer, MissionReduction, MissionStagePatch } from "../reducer-types.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",
accepts(ctx) {
return ctx.activeMission.missionId === "salary-negotiation-war-room" &&
(missionExplicitlyMatches(ctx.event.mission, "salary-negotiation-war-room") || isRelevantServiceEvent(ctx.event.source, ctx.event.type));
},
reduce(ctx): MissionReduction {
const { event, activeMission } = ctx;
const type = event.type;
const payload = event.payload ?? {};
const stagePatches: MissionStagePatch[] = [];
const artifacts: MissionReduction["artifacts"] = [];
const actions: MissionReduction["actions"] = [];
let eventMessage = ctx.insight.summary;
if (type === "mission.started" || type.startsWith("mission.")) {
actions.push(actionForAgent("salary-negotiation-war-room", "planner", {
stageId: "offer-context",
mode: "user_input_required",
title: "Add your offer and negotiation constraints",
body: "The war room needs your current offer, target range, deadline, and leverage before scripts or drills are accurate.",
prompt: "What is the current offer/raise, your target, deadline, and any competing leverage?",
payload: { fields: ["current_offer", "target_range", "deadline", "competing_offers", "constraints"] },
idempotencyKey: `${activeMission.instanceId}:offer-context`,
priority: 100,
urgency: "now",
}));
}
if (isResumeEvent(event.source, type) && (type.includes("analysis") || type.includes("parsed") || type.includes("analyzed"))) {
const signals = extractResumeSignals(payload);
stagePatches.push({ stageId: "resume", status: "done", progressPercent: 100, outputSummary: "Value evidence extracted from resume proof." });
stagePatches.push({ stageId: "roleplay", status: "ready", progressPercent: 0, outputSummary: "Use value evidence in negotiation roleplay." });
artifacts.push({ type: "value_evidence_map", title: "Value evidence map", stageId: "resume", summary: signals[0] ?? "Impact proof extracted for negotiation leverage.", metadata: { sourceEventId: event.id, signals } });
actions.push(actionForAgent("salary-negotiation-war-room", "roleplay", {
stageId: "roleplay",
serviceId: "roleplay-service",
toolName: "roleplay.configure_practice",
mode: "suggestion",
title: "Run the first counteroffer drill",
body: "Practice anchoring your number and defending value with the evidence now extracted.",
payload: { signals, href: serviceHref("roleplay", activeMission.instanceId, activeMission.missionId, "roleplay") },
sourceEventId: event.id,
idempotencyKey: `${activeMission.instanceId}:value-evidence-roleplay:${event.id}`,
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.";
}
if (isRoleplayEvent(event.source, type) && type.includes("configured")) {
stagePatches.push({ stageId: "roleplay", status: "in_progress", progressPercent: 45, outputSummary: "Negotiation drill configured." });
eventMessage = "Negotiation drill started.";
}
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",
toolName: "roleplay.configure_practice",
mode: "approval_required",
title: "Run one more objection-handling drill?",
body: weakAreas.length ? `Recommended focus: ${weakAreas.slice(0, 3).join(", ")}. Approve another drill before the live negotiation.` : "Approve one more objection-handling drill before the live negotiation.",
payload: { weakAreas, href: serviceHref("roleplay", activeMission.instanceId, activeMission.missionId, "roleplay") },
sourceEventId: event.id,
idempotencyKey: `${activeMission.instanceId}:negotiation-followup:${event.id}`,
priority: 94,
urgency: "today",
}));
actions.push(passive.action);
eventMessage = "Negotiation drill review created the next objection-handling action.";
}
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.";
}
if (ctx.qscoreSignals.length) stagePatches.push({ stageId: "qscore", status: "in_progress", progressPercent: Math.min(90, 45 + ctx.qscoreSignals.length * 10), outputSummary: "Negotiation confidence signals updated." });
return { stagePatches, artifacts, actions, eventMessage };
},
};

View File

@@ -1,4 +1,5 @@
import type { MissionSnapshot, MissionStage } from "../actors/missions/types.js";
import { missionDetailHref } from "./reducer-helpers.js";
export type MissionSuggestionType = "action" | "practice" | "review" | "artifact" | "blocked" | "insight";
export type MissionSuggestionUrgency = "now" | "today" | "soon" | "calm";
@@ -92,18 +93,18 @@ function ctaFor(stage: MissionStage, snapshot: MissionSnapshot, context?: Missio
if (role === "Interview") {
params.set("mode", "mission");
params.set("prompt", `${stage.title} for ${profile.targetRole}`);
return { label: stage.status === "in_progress" ? "Continue mock" : "Start mock", href: `/agents/interview/setup?${params.toString()}` };
return { label: stage.status === "in_progress" ? "Continue mock" : "Start mock", href: `/agents/interview?${params.toString()}` };
}
if (role === "Roleplay") {
params.set("scenario", `${stage.title} for ${profile.targetRole}`);
return { label: stage.status === "in_progress" ? "Continue roleplay" : "Start roleplay", href: `/agents/roleplay/setup?${params.toString()}` };
return { label: stage.status === "in_progress" ? "Continue roleplay" : "Start roleplay", href: `/agents/roleplay?${params.toString()}` };
}
if (role === "Resume") {
params.set("focus", `${stage.title}: ${profile.targetRole}`);
return { label: "Open resume", href: `/agents/resume?${params.toString()}` };
}
if (role === "Q Score") return { label: "View Q Score", href: `/agents/qscore?${params.toString()}` };
return { label: "Continue", href: `/missions/active?${params.toString()}` };
return { label: "Continue", href: `${missionDetailHref(snapshot.instanceId)}?${params.toString()}` };
}
function suggestionId(snapshot: MissionSnapshot, stage: MissionStage, suffix: string) {

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);
}

52
src/routes/analytics.ts Normal file
View File

@@ -0,0 +1,52 @@
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> {
return (_client ??= createClient<Registry>(config.rivetClientEndpoint));
}
export function analyticsRoutes() {
const app = new Hono<AuthContext>();
app.use("*", requireUser);
app.get("/platform", async (c) => {
return c.json(await getClient().analyticsActor.getOrCreate(["platform"]).getPlatform());
});
app.get("/user/qscore", async (c) => {
const userId = c.get("userId");
return c.json(await getClient().analyticsActor.getOrCreate(["user", userId]).getUserQscore({ userId }));
});
app.get("/user/activity", async (c) => {
const userId = c.get("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

@@ -52,7 +52,7 @@ function buildTools() {
type: "function" as const,
function: {
name: "start_interview_session",
description: "Create a real interview practice session via the Interview Agent / interview-service microservice. Call this when the user asks to start or launch an interview.",
description: "Create a real mock interview session via the interview-service microservice. Call this when the user asks to start or launch interview practice.",
parameters: {
type: "object",
properties: {
@@ -66,7 +66,7 @@ function buildTools() {
type: "function" as const,
function: {
name: "start_roleplay_session",
description: "Create a real roleplay session via Roleplay Agent / roleplay-service. Call when user asks for roleplay or negotiation practice.",
description: "Create a real mock roleplay session via roleplay-service. Call when the user asks for roleplay or negotiation practice.",
parameters: {
type: "object",
properties: {
@@ -80,7 +80,7 @@ function buildTools() {
type: "function" as const,
function: {
name: "analyze_resume",
description: "Analyze user's resume using the Resume Agent. Returns completeness, skills, and gaps.",
description: "Analyze the user's resume using Resume Building. Returns completeness, skills, and gaps.",
parameters: {
type: "object",
properties: {
@@ -94,7 +94,7 @@ function buildTools() {
type: "function" as const,
function: {
name: "compute_qscore",
description: "Compute user's readiness Q-Score via Q Score Agent / qscore-service.",
description: "Compute the user's readiness Q Score via qscore-service.",
parameters: {
type: "object",
properties: {},
@@ -174,14 +174,14 @@ export function chatRoutes() {
switch (toolCall.name) {
case "start_interview_session": {
toolResult = await runServiceAgentProbe(
{ id: "interview", name: "Interview Agent", role: "Interview Agent", kind: "microservice", description: "Interview practice", service: "interview-service" },
{ id: "interview", name: "Mock Interview", role: "Interview practice", kind: "microservice", description: "Interview practice", service: "interview-service" },
{ userId, goal: String(toolCall.arguments.target_role ?? "general preparation") },
);
if (toolResult.status === "ok" && toolResult.detail) {
const detail = toolResult.detail as Record<string, unknown>;
sessions.push({
moduleId: "interview",
moduleName: "Interview Agent",
moduleName: "Mock Interview",
status: "done",
sessionId: detail.session_id as string,
sessionUrl: typeof detail.ui_session_url === "string"
@@ -194,14 +194,14 @@ export function chatRoutes() {
}
case "start_roleplay_session": {
toolResult = await runServiceAgentProbe(
{ id: "roleplay", name: "Roleplay Agent", role: "Roleplay Agent", kind: "microservice", description: "Roleplay practice", service: "roleplay-service" },
{ id: "roleplay", name: "Mock Roleplay", role: "Roleplay practice", kind: "microservice", description: "Roleplay practice", service: "roleplay-service" },
{ userId, goal: String(toolCall.arguments.goal ?? "general practice") },
);
if (toolResult.status === "ok" && toolResult.detail) {
const detail = toolResult.detail as Record<string, unknown>;
sessions.push({
moduleId: "roleplay",
moduleName: "Roleplay Agent",
moduleName: "Mock Roleplay",
status: "done",
sessionId: detail.session_id as string,
sessionUrl: typeof detail.ui_session_url === "string"

View File

@@ -1,7 +1,7 @@
import { Hono } from "hono";
import { z } from "zod";
import { createClient, type Client } from "rivetkit/client";
import { convertToModelMessages, generateText, stepCountIs, streamText, tool, type UIMessage } from "ai";
import { convertToModelMessages, createUIMessageStream, createUIMessageStreamResponse, generateText, stepCountIs, streamText, tool, type UIMessage } from "ai";
import { config } from "../config.js";
import { requireUser, type AuthContext } from "../auth/clerk.js";
import type { Registry } from "../actors/registry.js";
@@ -9,7 +9,8 @@ import { getConversationModel } from "../actors/conversation/agent.js";
import { getMissionDefinition, isActorBackedMission, listMissionDefinitions } from "../missions/registry.js";
import type { GrowActiveMission, MissionActorType, MissionSnapshot } from "../actors/missions/types.js";
import { getSubAgentModules } from "../lib/prompt-loader.js";
import { addMessagePg, createConversationPg, ensureConversation, getConversationPg, listActiveMissionsPg, listConversationsPg, listMessagesPg, resetConversationPg, touchConversationPg, upsertActiveMissionPg } from "../grow/persistence.js";
import { addMessagePg, createConversationPg, ensureConversation, ensureMissionConversationPg, getActiveMissionPg, getConversationMetadataPg, getConversationPg, listActiveMissionsPg, listConversationsPg, listMessagesPg, resetConversationPg, touchConversationPg, upsertActiveMissionPg } from "../grow/persistence.js";
import { getMissionAction, listMissionActions, updateMissionActionStatus } from "../missions/actions.js";
let _client: Client<Registry> | null = null;
function getClient(): Client<Registry> {
@@ -101,9 +102,16 @@ function missionActorFor(userId: string, instanceId: string, actorType: MissionA
}
const createConversationSchema = z.object({ title: z.string().optional() });
const createMissionConversationSchema = z.object({
missionInstanceId: z.string().min(1),
stageId: z.string().optional(),
title: z.string().optional(),
source: z.string().optional(),
});
const streamSchema = z.object({
messages: z.array(z.custom<UIMessage>()),
conversationId: z.string().optional(),
growContext: z.any().optional(),
});
const createMissionInstanceId = (missionId: string) =>
@@ -147,15 +155,25 @@ function textFromMessage(message: UIMessage | undefined) {
.trim();
}
function forcedToolForPrompt(text: string) {
function forcedToolForPrompt(text: string, hasGrowContext = false) {
const lower = text.toLowerCase();
const asksForTodayTasks =
/\b(show|list|give|get|what|which|today|daily|streak|task|tasks|mission|missions|report|progress)\b/.test(lower) &&
/\b(today|daily|streak|task|tasks|mission|missions|day|report|progress)\b/.test(lower);
// Keep high-value dashboard UI predictable: when the user explicitly asks to
// see/discover/find workflows, always produce workflow cards instead of a
// text-only answer.
if (hasGrowContext && /\b(report|result|outcome|kpi|summary)\b/.test(lower)) {
return "showCareerSprintReport" as const;
}
if (hasGrowContext && asksForTodayTasks) {
return "showCareerSprintTasks" as const;
}
// The legacy workflow catalog should only show when the user explicitly asks
// for broader programs/workflows, not when they ask for streak missions.
if (
/\b(discover|find|show|list|recommend|suggest|compare)\b/.test(lower) &&
/\b(workflow|workflows|mission|missions|plan|plans|program|programs)\b/.test(lower)
/\b(workflow|workflows|program|programs)\b/.test(lower)
) {
return "discoverWorkflows" as const;
}
@@ -163,7 +181,48 @@ function forcedToolForPrompt(text: string) {
return undefined;
}
function buildSystemPrompt() {
function buildGrowContextPrompt(growContext: any) {
if (!growContext || typeof growContext !== "object") return "";
const activeDay = growContext.activeDay;
const tasks = Array.isArray(activeDay?.tasks) ? activeDay.tasks : [];
const taskLines = tasks
.map((task: any, index: number) => ` ${index + 1}. ${task.title ?? "Task"}${task.serviceName ?? task.serviceId ?? "service"} — status: ${task.status ?? "pending"} — route: ${task.route ?? "none"}`)
.join("\n");
return `\n\nCurrent CareerSprint context from the dashboard/streak UI:
- localDate: ${growContext.localDate ?? "unknown"}
- activeDayIndex: ${growContext.activeDayIndex ?? "unknown"}
- streak: ${growContext.streak ?? 0} day(s)
- progress: ${growContext.completedCount ?? 0}/${growContext.totalCount ?? 0} tasks (${growContext.overallProgressPercent ?? 0}%)
- reportEligible: ${growContext.reportEligible ? "yes" : "no"}
- activeDayTitle: ${activeDay?.title ?? "today"}
- activeDayCompleted: ${activeDay?.completedCount ?? 0}/${activeDay?.totalCount ?? tasks.length}
- activeDayTasks:
${taskLines || " none"}
When the user asks for missions, tasks, today, streak status, missed work, or report readiness, use this CareerSprint context instead of the old workflow catalog. If reportEligible is true and the user asks about a report, ask whether they want to open the report. If activeDayIndex is 8 or higher, explain recovery/missed tasks only when there are incomplete tasks.`;
}
async function buildMissionContextPrompt(userId: string, conversationId: string) {
const metadata = await getConversationMetadataPg(userId, conversationId);
const missionInstanceId = typeof metadata.missionInstanceId === "string" ? metadata.missionInstanceId : undefined;
if (!missionInstanceId) return "";
const active = await getActiveMissionPg(userId, missionInstanceId);
if (!active) return "";
const actions = await listMissionActions(userId, { missionInstanceId });
return `\n\nCurrent mission context:
- missionInstanceId: ${active.mission.instanceId}
- missionId: ${active.mission.missionId}
- title: ${active.mission.title}
- status: ${active.mission.status}
- progress: ${active.mission.progressPercent}%
- currentStageId: ${active.mission.currentStageId ?? "none"}
- goal: ${active.mission.goal ?? "none"}
- openActions: ${actions.length}
Use this mission context when answering. If a service is needed, prepare a handoff/action instead of completing the service directly.`;
}
function buildSystemPrompt(missionContext = "") {
return `You are Grow — a friendly, normal career buddy inside GrowQR. Talk like a real person, not a coach or a robot.
Personality & Tone:
@@ -180,9 +239,10 @@ How to help:
- Don't call tools for general chitchat, emotional support, simple explanations, or when a normal text answer would do.
- If you don't know something, say so. If a tool fails, just say it failed and move on.
- When the user asks about interview prep, roleplay, resume, or Q Score — just answer normally. Only route to a specialist tool if they explicitly say something like "connect me to the interview specialist" or "let me talk to the roleplay agent".
- When the user asks to see missions or plans, call discoverWorkflows or showMissions. When they ask about memory, use the memory tools. Otherwise, just chat.
- When the user asks to see todays missions, streak tasks, daily tasks, missed tasks, task progress, or report readiness, call showCareerSprintTasks if CareerSprint context is available.
- Use discoverWorkflows only for broad workflow/program discovery. When they ask about memory, use the memory tools. Otherwise, just chat.
- Only start a mission if the user clearly says yes to starting one. Don't push.
- When you write memory, a quick "Saved." is enough. No need to over-confirm.`
- When you write memory, a quick "Saved." is enough. No need to over-confirm.${missionContext}`
}
function safeAgentRegistry() {
@@ -237,8 +297,107 @@ When helping:
Return compact, bullet-heavy markdown.`,
};
function buildConversationTools(userId: string) {
function careerSprintTasksPayload(growContext: any, dayIndex?: number) {
const days = Array.isArray(growContext?.days) ? growContext.days : [];
const requestedDay = typeof dayIndex === "number" ? dayIndex : Number(growContext?.activeDayIndex ?? 1);
const clampedDay = Math.max(1, Math.min(7, requestedDay || 1));
const activeDay = days.find((day: any) => Number(day?.dayIndex) === clampedDay) ?? growContext?.activeDay ?? days[0] ?? null;
const tasks = Array.isArray(activeDay?.tasks) ? activeDay.tasks : [];
const missedTasks = days.flatMap((day: any) => (Array.isArray(day?.tasks) ? day.tasks : []).filter((task: any) => task?.status !== "completed"));
return {
kind: "career-sprint-tasks",
localDate: growContext?.localDate,
activeDayIndex: growContext?.activeDayIndex,
requestedDayIndex: clampedDay,
streak: growContext?.streak ?? 0,
completedCount: growContext?.completedCount ?? 0,
totalCount: growContext?.totalCount ?? 0,
overallProgressPercent: growContext?.overallProgressPercent ?? 0,
reportEligible: Boolean(growContext?.reportEligible),
isRecoveryDay: Number(growContext?.activeDayIndex ?? 1) > 7,
missedCount: missedTasks.length,
missedTasks,
day: activeDay,
tasks,
};
}
function careerSprintReportPayload(growContext: any) {
const days = Array.isArray(growContext?.days) ? growContext.days : [];
const completedCount = Number(growContext?.completedCount ?? 0);
const totalCount = Number(growContext?.totalCount ?? 0);
const reportEligible = Boolean(growContext?.reportEligible);
const missedTasks = days.flatMap((day: any) => (Array.isArray(day?.tasks) ? day.tasks : []).filter((task: any) => task?.status !== "completed"));
const params = new URLSearchParams();
if (typeof growContext?.demoIcpId === "string" && growContext.demoIcpId) params.set("icpId", growContext.demoIcpId);
if (Number.isFinite(Number(growContext?.activeDayIndex))) params.set("dayIndex", String(growContext.activeDayIndex));
const href = `/agents/careersprint-report${params.size ? `?${params.toString()}` : ""}`;
return {
kind: "career-sprint-report-status",
localDate: growContext?.localDate,
activeDayIndex: growContext?.activeDayIndex,
streak: growContext?.streak ?? 0,
completedCount,
totalCount,
overallProgressPercent: growContext?.overallProgressPercent ?? 0,
reportEligible,
href,
missedCount: missedTasks.length,
missedTasks: missedTasks.slice(0, 8),
};
}
function deterministicCareerSprintResponse(options: {
originalMessages: UIMessage[];
toolName: "showCareerSprintTasks" | "showCareerSprintReport";
input: Record<string, unknown>;
output: unknown;
onFinish: (responseMessage: UIMessage) => Promise<void>;
}) {
const messageId = `assistant-${Date.now()}-${Math.random().toString(16).slice(2)}`;
const toolCallId = `call-${Date.now()}-${Math.random().toString(16).slice(2)}`;
const stream = createUIMessageStream<UIMessage>({
originalMessages: options.originalMessages,
execute: ({ writer }) => {
writer.write({ type: "start", messageId } as any);
writer.write({ type: "start-step" } as any);
writer.write({
type: "tool-input-available",
toolCallId,
toolName: options.toolName,
input: options.input,
} as any);
writer.write({
type: "tool-output-available",
toolCallId,
output: options.output,
} as any);
writer.write({ type: "finish-step" } as any);
writer.write({ type: "finish", finishReason: "stop" } as any);
},
onFinish: async ({ responseMessage }) => {
await options.onFinish(responseMessage as UIMessage);
},
});
return createUIMessageStreamResponse({ stream });
}
function buildConversationTools(userId: string, growContext?: any) {
return {
showCareerSprintTasks: tool({
description: "Return the current CareerSprint streak tasks from the dashboard context. Use for today missions, daily tasks, streak status, missed tasks, completed tasks, Day 8/recovery, and report readiness.",
inputSchema: z.object({
dayIndex: z.number().optional().describe("Optional requested CareerSprint day number. Defaults to active day."),
}),
execute: async ({ dayIndex }) => careerSprintTasksPayload(growContext, dayIndex),
}),
showCareerSprintReport: tool({
description: "Return CareerSprint report readiness, KPI completion status, and the report link. Use when the user asks for report, result, outcome, KPI, or 7-day summary.",
inputSchema: z.object({}),
execute: async () => careerSprintReportPayload(growContext),
}),
discoverWorkflows: tool({
description: "Return sellable GrowQR missions as rich UI-ready discovery cards. Use whenever the user asks to see, find, discover, compare, choose, recommend, or understand missions/workflows.",
inputSchema: z.object({
@@ -337,33 +496,36 @@ function buildConversationTools(userId: string) {
inputSchema: z.object({
focus: z.string().optional(),
}),
execute: async ({ focus }) => ({
kind: "missions",
focus,
missions: [
{
id: "mission-share-goal",
title: "Tell Grow what you are aiming for",
description: "Share target role, company, deadline, and biggest blocker so the agent can set up your first mission.",
status: "suggested",
rewardLabel: "+10 readiness",
},
{
id: "mission-pick-workflow",
title: "Pick a mission to activate",
description: "Start Interview-to-Offer if you have an interview coming up, or Career Transition if you are exploring a pivot.",
status: "suggested",
rewardLabel: "Unlock plan",
},
{
id: "mission-save-memory",
title: "Save one durable career fact",
description: "Let Grow remember a role target, resume link, deadline, or interview date.",
status: "suggested",
rewardLabel: "Better recommendations",
},
],
}),
execute: async ({ focus }) => {
if (growContext && typeof growContext === "object") return careerSprintTasksPayload(growContext);
return {
kind: "missions",
focus,
missions: [
{
id: "mission-share-goal",
title: "Tell Grow what you are aiming for",
description: "Share target role, company, deadline, and biggest blocker so the agent can set up your first mission.",
status: "suggested",
rewardLabel: "+10 readiness",
},
{
id: "mission-pick-workflow",
title: "Pick a mission to activate",
description: "Start Interview-to-Offer if you have an interview coming up, or Career Transition if you are exploring a pivot.",
status: "suggested",
rewardLabel: "Unlock plan",
},
{
id: "mission-save-memory",
title: "Save one durable career fact",
description: "Let Grow remember a role target, resume link, deadline, or interview date.",
status: "suggested",
rewardLabel: "Better recommendations",
},
],
};
},
}),
startWorkflow: tool({
@@ -443,6 +605,49 @@ function buildConversationTools(userId: string) {
},
}),
listMissionActions: tool({
description: "List open mission queue actions/approvals/questions for the user. Use for: what should I do today, what are my agents doing, why is my mission blocked, show approvals.",
inputSchema: z.object({ missionInstanceId: z.string().optional() }),
execute: async ({ missionInstanceId }) => ({
kind: "mission-actions",
actions: await listMissionActions(userId, { missionInstanceId }),
}),
}),
approveMissionAction: tool({
description: "Approve a mission action by id. Only use when the user explicitly asks to approve a specific action.",
inputSchema: z.object({ actionId: z.string() }),
execute: async ({ actionId }) => {
const action = await getMissionAction(userId, actionId);
if (!action) return { kind: "mission-action-approved", actionId, error: "Action not found" };
return { kind: "mission-action-approved", action: await updateMissionActionStatus(userId, actionId, { status: "queued", result: { approvedAt: new Date().toISOString() } }) };
},
}),
rejectMissionAction: tool({
description: "Reject/dismiss a mission action by id. Only use when the user explicitly asks to reject or dismiss a specific action.",
inputSchema: z.object({ actionId: z.string() }),
execute: async ({ actionId }) => ({
kind: "mission-action-rejected",
action: await updateMissionActionStatus(userId, actionId, { status: "dismissed", result: { rejectedAt: new Date().toISOString() } }),
}),
}),
explainMissionProgress: tool({
description: "Explain active mission progress using snapshots and open actions.",
inputSchema: z.object({ missionInstanceId: z.string().optional() }),
execute: async ({ missionInstanceId }) => {
const persisted = await listActiveMissionsPg(userId);
const selected = missionInstanceId ? persisted.filter((item) => item.mission.instanceId === missionInstanceId) : persisted;
return {
kind: "mission-progress",
missions: selected.map((item) => item.mission),
snapshots: selected.map((item) => item.snapshot).filter(Boolean),
actions: await listMissionActions(userId, { missionInstanceId }),
};
},
}),
listMemory: tool({
description: "List memory files for this user by path prefix.",
inputSchema: z.object({ prefix: z.string().optional() }),
@@ -526,6 +731,28 @@ export function conversationRoutes() {
return c.json({ conversation }, 201);
});
app.post("/mission", async (c) => {
const userId = c.get("userId");
const body = createMissionConversationSchema.parse(await c.req.json().catch(() => ({})));
const active = await getActiveMissionPg(userId, body.missionInstanceId);
if (!active) return c.json({ error: "mission_not_found" }, 404);
const conversation = await ensureMissionConversationPg({
userId,
missionInstanceId: active.mission.instanceId,
missionId: active.mission.missionId,
stageId: body.stageId,
title: body.title ?? active.mission.shortTitle,
source: body.source ?? "mission",
});
setupGrow(userId).then((grow) => grow.touchConversation({ conversationId: conversation.id, title: conversation.title })).catch((err) => console.warn("growActor mission conversation mirror failed", err));
return c.json({
conversation,
mission: active.mission,
snapshot: active.snapshot,
messages: await listMessagesPg(userId, conversation.id),
}, 201);
});
app.get("/:conversationId", async (c) => {
const userId = c.get("userId");
const conversationId = c.req.param("conversationId");
@@ -565,12 +792,37 @@ export function conversationRoutes() {
withActorRecovery("conversationActor", () => conversation.addMessage({ role: "user", content: latestUserText, sender: "User" })).catch((err) => console.warn("conversationActor user-message mirror failed", err));
}
const visualTool = forcedToolForPrompt(latestUserText);
const visualTool = forcedToolForPrompt(latestUserText, Boolean(body.growContext));
if (visualTool === "showCareerSprintTasks" || visualTool === "showCareerSprintReport") {
const output = visualTool === "showCareerSprintTasks"
? careerSprintTasksPayload(body.growContext)
: careerSprintReportPayload(body.growContext);
return deterministicCareerSprintResponse({
originalMessages: body.messages,
toolName: visualTool,
input: {},
output,
onFinish: async (responseMessage) => {
const assistantText = textFromMessage(responseMessage);
await addMessagePg(userId, {
id: responseMessage.id,
conversationId,
role: "assistant",
content: assistantText || JSON.stringify(responseMessage.parts ?? []),
sender: "GrowQR",
});
await touchConversationPg(userId, conversationId, latestUserText.slice(0, 64) || undefined);
await growPromise;
},
});
}
const missionContext = `${await buildMissionContextPrompt(userId, conversationId)}${buildGrowContextPrompt(body.growContext)}`;
const result = streamText({
model: getConversationModel(),
system: buildSystemPrompt(),
system: buildSystemPrompt(missionContext),
messages: await convertToModelMessages(body.messages),
tools: buildConversationTools(userId),
tools: buildConversationTools(userId, body.growContext),
toolChoice: visualTool ? { type: "tool", toolName: visualTool } : "auto",
stopWhen: stepCountIs(5),
});
@@ -593,8 +845,7 @@ export function conversationRoutes() {
sender: "GrowQR",
})).catch((err) => console.warn("conversationActor assistant-message mirror failed", err));
await touchConversationPg(userId, conversationId, latestUserText.slice(0, 64) || undefined);
const grow = await growPromise;
if (grow) await grow.touchConversation({ conversationId, title: latestUserText.slice(0, 64) || undefined }).catch((err) => console.warn("growActor touch mirror failed", err));
await growPromise;
},
});
});

299
src/routes/daily-mission.ts Normal file
View File

@@ -0,0 +1,299 @@
import { Hono } from "hono";
import { z } from "zod";
import { createClient, type Client } from "rivetkit/client";
import { requireUser, type AuthContext } from "../auth/clerk.js";
import { config } from "../config.js";
import { log } from "../log.js";
import {
dailyMissionMessageSchema,
dailyMissionTaskSchema,
type DailyMissionResult,
runDailyMissionAgent,
streamDailyMissionAgent,
} from "../agents/daily-mission.js";
import type { Registry } from "../actors/registry.js";
import type { MissionActorType, MissionSnapshot } from "../actors/missions/types.js";
import { addMessagePg, ensureMissionConversationPg, getActiveMissionPg, listMessagesPg, upsertActiveMissionPg } from "../grow/persistence.js";
const chatSchema = z.object({
task: dailyMissionTaskSchema,
messages: z.array(dailyMissionMessageSchema).min(1).max(24),
missionInstanceId: z.string().optional(),
missionId: z.string().optional(),
stageId: z.string().optional(),
conversationId: z.string().optional(),
});
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 buildId(prefix: string) {
return `${prefix}-${Date.now()}-${Math.random().toString(16).slice(2)}`;
}
function latestUserText(messages: z.infer<typeof dailyMissionMessageSchema>[]) {
return [...messages].reverse().find((message) => message.role === "user")?.content.trim();
}
const encoder = new TextEncoder();
function sse(event: string, payload: Record<string, unknown>) {
return encoder.encode(`event: ${event}\ndata: ${JSON.stringify(payload)}\n\n`);
}
function sanitizeAssistantText(text: string) {
return text
.replace(/[\u2013\u2014]/g, ". ")
.replace(/[\u2018\u2019]/g, "'")
.replace(/[\u201C\u201D]/g, '"')
.replace(/\u2026/g, "...")
.replace(/^\s*(Perfect|Great|Absolutely|Sure)[.!,:;-]*\s*/i, "")
.replace(/\s+\./g, ".")
.replace(/\.{2,}/g, ".")
.replace(/\s{2,}/g, " ");
}
function sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function visibleTextChunks(text: string) {
const words = text.match(/\S+\s*/g) ?? [text];
const chunks: string[] = [];
let current = "";
for (const word of words) {
current += word;
if (current.length >= 12 || /[.!?]\s*$/.test(current)) {
chunks.push(current);
current = "";
}
}
if (current) chunks.push(current);
return chunks;
}
async function enqueueVisibleText(controller: ReadableStreamDefaultController<Uint8Array>, text: string) {
for (const chunk of visibleTextChunks(sanitizeAssistantText(text))) {
controller.enqueue(sse("delta", { text: chunk }));
await sleep(28);
}
}
async function applyDailyMissionResult(input: {
userId: string;
body: z.infer<typeof chatSchema>;
result: DailyMissionResult;
}) {
const { userId, body, result } = input;
let conversationId = body.conversationId;
let missionInstanceId = body.missionInstanceId;
let responseStageId = body.stageId ?? body.task.questId;
let snapshot: MissionSnapshot | null | undefined;
if (missionInstanceId) {
const active = await getActiveMissionPg(userId, missionInstanceId);
if (active) {
const requestedStageId = body.stageId ?? body.task.questId;
const stageId = active.snapshot?.stages.some((stage) => stage.id === requestedStageId)
? requestedStageId
: active.mission.currentStageId;
responseStageId = stageId ?? responseStageId;
const conversation = await ensureMissionConversationPg({
userId,
missionInstanceId: active.mission.instanceId,
missionId: active.mission.missionId,
stageId,
title: body.task.questTitle,
source: "daily-mission",
});
log.info({
userId,
agent: "conversation-actor",
conversationId: conversation.id,
missionInstanceId: active.mission.instanceId,
missionId: active.mission.missionId,
stageId,
service: body.task.service,
}, "conversation actor linked to daily mission");
conversationId = conversation.id;
missionInstanceId = active.mission.instanceId;
const conversationActor = getClient().conversationActor.getOrCreate([userId, conversation.id]);
const latestUser = latestUserText(body.messages);
if (latestUser) {
const userMessage = {
id: buildId("user"),
conversationId: conversation.id,
role: "user" as const,
sender: "User",
content: latestUser,
};
await addMessagePg(userId, userMessage);
conversationActor.addMessage(userMessage).catch(() => undefined);
}
const assistantMessage = {
id: buildId("assistant"),
conversationId: conversation.id,
role: "assistant" as const,
sender: "Daily Mission",
content: result.reply,
};
await addMessagePg(userId, assistantMessage);
conversationActor.addMessage(assistantMessage).catch(() => undefined);
if (result.completed && active.mission.actorType && stageId) {
snapshot = await missionActorFor(userId, active.mission.instanceId, active.mission.actorType).updateStage({
stageId,
status: "done",
progressPercent: 100,
outputSummary: result.updateSummary,
}).catch(() => active.snapshot ?? null);
log.info({
userId,
agent: active.mission.actorType,
missionInstanceId: active.mission.instanceId,
missionId: active.mission.missionId,
stageId,
completed: result.completed,
progressPercent: snapshot?.progressPercent,
currentStageId: snapshot?.currentStageId,
}, "mission actor stage update requested from daily mission");
if (snapshot) {
await upsertActiveMissionPg(userId, {
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: active.mission.actorType,
createdAt: new Date(snapshot.createdAt).getTime(),
updatedAt: new Date(snapshot.updatedAt).getTime(),
}, snapshot);
}
} else {
snapshot = active.snapshot;
}
}
}
return {
agent: "daily-mission",
message: result.reply,
completed: result.completed,
updateSummary: result.updateSummary,
actionLabel: result.actionLabel,
actionRoute: result.actionRoute,
conversationId,
missionInstanceId,
stageId: responseStageId,
snapshot,
messages: conversationId ? await listMessagesPg(userId, conversationId) : undefined,
};
}
export function dailyMissionRoutes() {
const app = new Hono<AuthContext>();
app.use("*", requireUser);
app.post("/chat", async (c) => {
const userId = c.get("userId");
const body = chatSchema.parse(await c.req.json());
log.info({
userId,
agent: "daily-mission",
missionInstanceId: body.missionInstanceId,
missionId: body.missionId,
stageId: body.stageId,
service: body.task.service,
route: body.task.route,
questTitle: body.task.questTitle,
subtask: body.task.subtask,
}, "daily mission actor request");
const result = await runDailyMissionAgent({ userId, ...body });
return c.json(await applyDailyMissionResult({ userId, body, result }));
});
app.post("/chat/stream", async (c) => {
const userId = c.get("userId");
const body = chatSchema.parse(await c.req.json());
log.info({
userId,
agent: "daily-mission",
missionInstanceId: body.missionInstanceId,
missionId: body.missionId,
stageId: body.stageId,
service: body.task.service,
route: body.task.route,
questTitle: body.task.questTitle,
subtask: body.task.subtask,
streaming: true,
}, "daily mission actor stream request");
const stream = new ReadableStream<Uint8Array>({
async start(controller) {
try {
const streamed = await streamDailyMissionAgent({ userId, ...body });
let reply = "";
if (streamed.kind === "static") {
reply = streamed.result.reply;
await enqueueVisibleText(controller, reply);
const finalPayload = await applyDailyMissionResult({ userId, body, result: streamed.result });
controller.enqueue(sse("final", finalPayload));
controller.close();
return;
}
for await (const delta of streamed.textStream) {
const cleanDelta = sanitizeAssistantText(delta);
reply += cleanDelta;
controller.enqueue(sse("delta", { text: cleanDelta }));
}
let result = streamed.finalize(reply);
result = {
...result,
reply: sanitizeAssistantText(result.reply),
updateSummary: result.updateSummary ? sanitizeAssistantText(result.updateSummary) : result.updateSummary,
};
if (!result.reply.trim()) {
throw new Error("daily_mission_empty_model_reply");
}
const finalPayload = await applyDailyMissionResult({ userId, body, result });
controller.enqueue(sse("final", finalPayload));
controller.close();
} catch (error) {
controller.enqueue(sse("error", { error: error instanceof Error ? error.message : String(error) }));
controller.close();
}
},
});
return new Response(stream, {
headers: {
"content-type": "text/event-stream; charset=utf-8",
"cache-control": "no-cache, no-transform",
connection: "keep-alive",
},
});
});
return app;
}

View File

@@ -4,8 +4,17 @@ 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,
recordGrowEventWithResult,
shouldRouteGrowEvent,
} from "../events/record-grow-event.js";
import { routeGrowEventToUserActor } from "../events/route-to-user-actor.js";
import { applyQscoreProjection } from "../events/projectors/qscore-projector.js";
import { applyServiceSessionProjection } from "../events/projectors/service-session-projector.js";
import { ensureOnboardingSideEffectsForEvent } from "../events/onboarding-ledger.js";
function serviceAuthorized(auth: string | undefined) {
const token = (auth ?? "").replace(/^Bearer\s+/i, "").trim();
@@ -15,12 +24,61 @@ function serviceAuthorized(auth: string | undefined) {
}
async function ingest(body: unknown, userId?: string, source?: string) {
const event = await recordGrowEvent(body, { userId, source });
const route = await routeGrowEventToUserActor(event).catch((err) => ({
routed: false as const,
reason: err instanceof Error ? err.message : String(err),
}));
return { event, route };
const { event, inserted } = await recordGrowEventWithResult(body, { userId, source });
// Route only newly inserted, user-resolved, pending events to the actor.
// Dedupe hits (inserted=false) never re-enqueue, preventing actor saturation.
const routed = shouldRouteGrowEvent(event, inserted);
const route = routed
? await routeGrowEventToUserActor(event).catch((err) => ({
routed: false as const,
reason: err instanceof Error ? err.message : String(err),
}))
: { routed: false as const, reason: "dedupe_or_unresolved" as const };
// Dedupe hit: event was already processed (or is unresolved). Return early
// with idempotent semantics — no in-process re-projection.
if (!inserted) {
return {
event,
processingStatus: event.processingStatus,
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 {
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");
}
// Always mark "processed" after successful synchronous projections, regardless
// of route outcome. The actor will still run mission reducers on "processed"
// events (isProcessableStatus allows "processed") — projections are idempotent
// and the in-memory processedEventIds guard prevents double-processing.
// This avoids orphaning the event if the route call failed.
await markGrowEventProcessed(event.id);
return { event, processingStatus: "processed" as const, route, qscore, onboarding };
} catch (err) {
await markGrowEventFailed(event.id, err);
throw err;
}
}
export function eventRoutes() {
@@ -30,8 +88,8 @@ export function eventRoutes() {
app.post("/ingest", requireUser, async (c) => {
const userId = c.get("userId");
const body = await c.req.json().catch(() => ({}));
const { event, route } = await ingest(body, userId);
return c.json({ eventId: event.id, processingStatus: event.processingStatus, route }, 202);
const { event, processingStatus, route, qscore, onboarding } = await ingest(body, userId);
return c.json({ eventId: event.id, processingStatus, route, qscore, onboarding }, 202);
});
// Service-to-service ingress. Services may include userId directly, or we resolve it from session correlation.
@@ -41,8 +99,8 @@ export function eventRoutes() {
}
const body = await c.req.json().catch(() => ({}));
const source = c.req.header("x-growqr-source") ?? undefined;
const { event, route } = await ingest(body, undefined, source);
return c.json({ eventId: event.id, processingStatus: event.processingStatus, route }, 202);
const { event, processingStatus, route, qscore, onboarding } = await ingest(body, undefined, source);
return c.json({ eventId: event.id, processingStatus, route, qscore, onboarding }, 202);
});
app.get("/", requireUser, async (c) => {

View File

@@ -1,12 +1,17 @@
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 { seedDemoHome } from "../home/seed-demo-home.js";
function canSeedDemo(userId: string) {
return config.nodeEnv !== "production" || config.adminUserIds.includes(userId);
}
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";
export function homeRoutes() {
const app = new Hono<AuthContext>();
@@ -14,7 +19,12 @@ export function homeRoutes() {
app.get("/feed", async (c) => {
const refresh = c.req.query("refresh") === "1" || c.req.query("refresh") === "true";
return c.json(await getHomeFeed(c.get("userId"), { refresh }));
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(userId, { refresh, ...profile }));
});
app.post("/notifications/:id/dismiss", async (c) => {
@@ -22,13 +32,33 @@ export function homeRoutes() {
return c.json({ ok: true });
});
app.get("/debug/counts", async (c) => c.json(await getHomeFeedDebugCounts(c.get("userId"))));
app.post("/demo/seed", async (c) => {
const userId = c.get("userId");
if (!canSeedDemo(userId)) return c.json({ error: "forbidden" }, 403);
return c.json(await seedDemoHome(userId));
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"))));
return app;
}

116
src/routes/logs.ts Normal file
View File

@@ -0,0 +1,116 @@
import Docker from "dockerode";
import { PassThrough } from "node:stream";
import { Hono } from "hono";
import { requireUser, type AuthContext } from "../auth/clerk.js";
const LOG_CONTAINERS = {
backend: "growqr-backend",
dashboard: "growqr-dashboard",
actors: "growqr-rivet",
interview: "interview-service-api-1",
roleplay: "roleplay-service-api-1",
social: "growqr_social_api",
courses: "courses_service-api-1",
assessment: "assessment-service-api-1",
matchmaking: "matchmaking-service-api-1",
} as const;
type LogService = keyof typeof LOG_CONTAINERS;
const encoder = new TextEncoder();
function parseServices(value: string | undefined): LogService[] {
const requested = (value ?? "backend,dashboard,actors,interview,roleplay")
.split(",")
.map((item) => item.trim())
.filter(Boolean);
const services = requested.filter((item): item is LogService => item in LOG_CONTAINERS);
return services.length ? services : ["backend", "dashboard", "actors", "interview", "roleplay"];
}
function sse(event: string, payload: Record<string, unknown>) {
return encoder.encode(`event: ${event}\ndata: ${JSON.stringify(payload)}\n\n`);
}
function splitLines(chunk: Buffer | string) {
return chunk
.toString("utf8")
.split(/\r?\n/)
.map((line) => line.trimEnd())
.filter(Boolean);
}
export function logRoutes() {
const app = new Hono<AuthContext>();
app.use("*", requireUser);
app.get("/stream", (c) => {
const services = parseServices(c.req.query("services"));
const tail = Math.max(10, Math.min(500, Number(c.req.query("tail") ?? 120)));
const docker = new Docker({ socketPath: "/var/run/docker.sock" });
const openStreams: Array<{ destroy: () => void }> = [];
let closed = false;
let heartbeat: NodeJS.Timeout | undefined;
const body = new ReadableStream<Uint8Array>({
async start(controller) {
const send = (event: string, payload: Record<string, unknown>) => {
if (!closed) controller.enqueue(sse(event, payload));
};
send("ready", { services, tail, at: new Date().toISOString() });
for (const service of services) {
const containerName = LOG_CONTAINERS[service];
try {
const stream = await docker.getContainer(containerName).logs({
follow: true,
stdout: true,
stderr: true,
timestamps: true,
tail,
});
const stdout = new PassThrough();
const stderr = new PassThrough();
docker.modem.demuxStream(stream, stdout, stderr);
openStreams.push(stream as unknown as { destroy: () => void }, stdout, stderr);
stdout.on("data", (chunk) => {
for (const line of splitLines(chunk)) send("log", { service, stream: "stdout", line });
});
stderr.on("data", (chunk) => {
for (const line of splitLines(chunk)) send("log", { service, stream: "stderr", line });
});
stream.on("end", () => send("status", { service, status: "ended" }));
stream.on("error", (error) => send("error", { service, error: error instanceof Error ? error.message : String(error) }));
} catch (error) {
send("error", { service, container: containerName, error: error instanceof Error ? error.message : String(error) });
}
}
heartbeat = setInterval(() => send("ping", { at: new Date().toISOString() }), 20_000);
c.req.raw.signal.addEventListener("abort", () => {
closed = true;
if (heartbeat) clearInterval(heartbeat);
for (const stream of openStreams) stream.destroy();
controller.close();
});
},
cancel() {
closed = true;
if (heartbeat) clearInterval(heartbeat);
for (const stream of openStreams) stream.destroy();
},
});
return new Response(body, {
headers: {
"content-type": "text/event-stream; charset=utf-8",
"cache-control": "no-cache, no-transform",
connection: "keep-alive",
},
});
});
return app;
}

View File

@@ -9,6 +9,12 @@ import { isActorBackedMission } from "../missions/registry.js";
import { getPersistedMissionDefinition, listPersistedMissionDefinitions } from "../missions/postgres-registry.js";
import { completeMissionCoachRunPg, createMissionCoachRunPg, getActiveMissionPg, listActiveMissionsPg, listMissionSuggestionsPg, replaceMissionSuggestionsPg, upsertActiveMissionPg } from "../grow/persistence.js";
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> {
@@ -55,6 +61,21 @@ const addArtifactSchema = z.object({
metadata: z.record(z.unknown()).optional(),
});
const answerActionSchema = z.object({
input: z.record(z.unknown()).optional(),
answer: z.string().optional(),
});
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)}`;
@@ -92,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);
@@ -126,10 +135,15 @@ export function missionRoutes() {
await Promise.all(persisted.map(async (item) => {
suggestionsByMission[item.mission.instanceId] = await listMissionSuggestionsPg(userId, item.mission.instanceId);
}));
const actionsByMission: Record<string, unknown[]> = {};
await Promise.all(persisted.map(async (item) => {
actionsByMission[item.mission.instanceId] = await listMissionActions(userId, { missionInstanceId: item.mission.instanceId });
}));
return c.json({
missions: persisted.map((item) => item.mission),
snapshots: persisted.map((item) => item.snapshot).filter((snapshot): snapshot is MissionSnapshot => Boolean(snapshot)),
suggestionsByMission,
actionsByMission,
});
});
@@ -138,7 +152,24 @@ export function missionRoutes() {
const active = await getActiveMissionPg(userId, c.req.param("instanceId"));
if (!active) return c.json({ error: "mission_not_found" }, 404);
const suggestions = await listMissionSuggestionsPg(userId, active.mission.instanceId);
return c.json({ mission: active.mission, snapshot: active.snapshot, suggestions });
const actions = await listMissionActions(userId, { missionInstanceId: active.mission.instanceId });
return c.json({ mission: active.mission, snapshot: active.snapshot, suggestions, actions });
});
app.get("/active/:instanceId/actions", async (c) => {
const userId = c.get("userId");
const active = await getActiveMissionPg(userId, c.req.param("instanceId"));
if (!active) return c.json({ error: "mission_not_found" }, 404);
return c.json({ actions: await listMissionActions(userId, { missionInstanceId: active.mission.instanceId }) });
});
app.post("/active/:instanceId/scrum/run", async (c) => {
const userId = c.get("userId");
const active = await getActiveMissionPg(userId, c.req.param("instanceId"));
if (!active?.mission.actorType) return c.json({ error: "mission_not_found" }, 404);
const result = await missionActorFor(userId, active.mission.instanceId, active.mission.actorType).runDailyScrum({ trigger: "manual" });
if (result.snapshot) await upsertActiveMissionPg(userId, activeMissionFromSnapshot(result.snapshot), result.snapshot);
return c.json({ summary: result.summary, snapshot: result.snapshot });
});
app.post("/active/:instanceId/coach/run", async (c) => {
@@ -148,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,
@@ -190,6 +221,88 @@ export function missionRoutes() {
return c.json({ coachRunId: run.id, summary, suggestions });
});
app.post("/actions/:actionId/approve", async (c) => {
const userId = c.get("userId");
const action = await getMissionAction(userId, c.req.param("actionId"));
if (!action) return c.json({ error: "action_not_found" }, 404);
const updated = await updateMissionActionStatus(userId, action.id, {
status: "queued",
result: { approvedAt: new Date().toISOString() },
payload: { ...(action.payload ?? {}), approved: true },
});
const active = await getActiveMissionPg(userId, action.missionInstanceId);
if (active?.mission.actorType) {
await missionActorFor(userId, active.mission.instanceId, active.mission.actorType).resolveHitl({ actionId: action.id, resolution: "approved" }).catch(() => undefined);
}
return c.json({ action: updated });
});
app.post("/actions/:actionId/reject", async (c) => {
const userId = c.get("userId");
const action = await updateMissionActionStatus(userId, c.req.param("actionId"), { status: "dismissed", result: { rejectedAt: new Date().toISOString() } });
if (!action) return c.json({ error: "action_not_found" }, 404);
return c.json({ action });
});
app.post("/actions/:actionId/run", async (c) => {
const userId = c.get("userId");
const existing = await getMissionAction(userId, c.req.param("actionId"));
if (!existing) return c.json({ error: "action_not_found" }, 404);
const active = await getActiveMissionPg(userId, existing.missionInstanceId);
if (active?.mission.actorType) {
await missionActorFor(userId, active.mission.instanceId, active.mission.actorType).runAction({ actionId: existing.id }).catch(() => undefined);
}
const href = typeof existing.payload?.href === "string" ? existing.payload.href : missionDetailHref(existing.missionInstanceId);
const action = await updateMissionActionStatus(userId, existing.id, {
status: "done",
result: {
ranAt: new Date().toISOString(),
message: existing.toolName?.startsWith("resume.")
? "Resume Agent prepared the draft brief. Open Resume Builder to apply it."
: "Action marked complete. Continue from the linked GrowQR surface.",
href,
},
});
return c.json({ action });
});
app.post("/actions/:actionId/answer", async (c) => {
const userId = c.get("userId");
const existing = await getMissionAction(userId, c.req.param("actionId"));
if (!existing) return c.json({ error: "action_not_found" }, 404);
const body = answerActionSchema.parse(await c.req.json().catch(() => ({})));
const input = body.input ?? (body.answer ? { answer: body.answer } : {});
const action = await updateMissionActionStatus(userId, existing.id, {
status: "done",
result: { answeredAt: new Date().toISOString(), input },
payload: { ...(existing.payload ?? {}), userInput: input },
});
const active = await getActiveMissionPg(userId, existing.missionInstanceId);
if (active?.mission.actorType) {
await missionActorFor(userId, active.mission.instanceId, active.mission.actorType).resolveHitl({ actionId: existing.id, resolution: "answered", input }).catch(() => undefined);
}
return c.json({ action });
});
app.post("/actions/:actionId/snooze", async (c) => {
const userId = c.get("userId");
const body = snoozeActionSchema.parse(await c.req.json().catch(() => ({})));
const action = await updateMissionActionStatus(userId, c.req.param("actionId"), { status: "snoozed", result: { snoozedAt: new Date().toISOString(), until: body.until } });
if (!action) return c.json({ error: "action_not_found" }, 404);
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");
@@ -215,6 +328,16 @@ export function missionRoutes() {
await upsertActiveMissionPg(userId, activeMission, snapshot);
const grow = growFor(userId);
grow.setup({ userId }).then(() => grow.registerActiveMission(activeMission)).catch((err) => console.warn("growActor mission mirror failed", err));
recordGrowEvent({
source: "growqr-backend:missions",
type: "mission.started",
category: "mission",
userId,
occurredAt: new Date().toISOString(),
mission: { instanceId, missionId, stageId: snapshot.currentStageId },
payload: { goal: body.goal, input: body.input ?? {}, title: snapshot.title },
dedupeKey: `mission-started:${instanceId}`,
}).then((event) => routeGrowEventToUserActor(event)).catch((err) => console.warn("mission start event routing failed", err));
return c.json({ mission: activeMission, snapshot }, 201);
});

File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More