Compare commits
201 Commits
prm-52
...
feat/caree
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fcbb93ca6a | ||
|
|
461b893fdc | ||
|
|
712a4e57f3 | ||
| d857b60111 | |||
|
|
171495f1e1 | ||
|
|
7caf05f4f5 | ||
|
|
15eb7e033c | ||
|
|
63c9c18494 | ||
|
|
d4332f9f8b | ||
|
|
e8c6665ca0 | ||
|
|
d6f583f238 | ||
|
|
d2353273d5 | ||
|
|
13ca12588b | ||
| 6d2379ce61 | |||
|
|
27a65dd218 | ||
|
|
29c3db33d4 | ||
|
|
4e7ed3c280 | ||
|
|
453d9b5191 | ||
|
|
bbeaae5684 | ||
|
|
d4090f5607 | ||
|
|
1d37456084 | ||
|
|
340557dd56 | ||
|
|
d8b11edcb6 | ||
|
|
34920f6a09 | ||
|
|
7b35d1e0c5 | ||
|
|
41f9f0dfec | ||
|
|
8d43bea58e | ||
|
|
c1d2acd96d | ||
|
|
a2292dd155 | ||
|
|
4985605638 | ||
|
|
626684ea7e | ||
|
|
5eaf4ae1ea | ||
|
|
a56172c7ac | ||
|
|
eafca3aafb | ||
|
|
8c0f8e1d57 | ||
|
|
4c59151e37 | ||
|
|
a3c4a91cbe | ||
|
|
4fa4a8bbd1 | ||
|
|
d71b7ede08 | ||
|
|
1f778fe3ea | ||
|
|
fc06317832 | ||
|
|
a1e05b0337 | ||
|
|
7a0c762fde | ||
|
|
70c3634178 | ||
|
|
26e0e54488 | ||
| 38939c2fd0 | |||
|
|
54b546bba3 | ||
|
|
3a7a6c46a8 | ||
|
|
25d92f67be | ||
|
|
51c9eb0167 | ||
|
|
59482d4747 | ||
|
|
6b3c6d0b0c | ||
|
|
301dba8c50 | ||
|
|
e444d1be15 | ||
|
|
fdf4f6468d | ||
|
|
8617be7e7c | ||
|
|
8cb5413766 | ||
|
|
2410a4800b | ||
|
|
f848f45429 | ||
|
|
9cb1027f4c | ||
|
|
d2e3f49519 | ||
|
|
80884fc0b6 | ||
|
|
5f5622b3a0 | ||
|
|
b93cc81d93 | ||
|
|
982942fa6e | ||
|
|
fdc320b995 | ||
|
|
4b652358d3 | ||
|
|
c7c6f1b8cc | ||
|
|
e7546142b6 | ||
|
|
78860ecf5e | ||
|
|
e50be827ca | ||
|
|
c54e12972c | ||
|
|
ad1318424b | ||
|
|
62208d385f | ||
|
|
a1de355ea0 | ||
|
|
411ad591bf | ||
|
|
63619f5047 | ||
|
|
b2283476d1 | ||
|
|
09d3da0d7a | ||
|
|
c715171aa2 | ||
|
|
f4964e5030 | ||
|
|
5f5b08bfda | ||
|
|
3f2dabd23d | ||
|
|
ac7bdca912 | ||
|
|
d3a4e63e4a | ||
|
|
722d5825bf | ||
|
|
389a9fb014 | ||
|
|
689bfb6c52 | ||
|
|
c3e7b00250 | ||
|
|
7bcd664afb | ||
|
|
64c245535f | ||
|
|
237c65f328 | ||
|
|
ee964a5d13 | ||
|
|
f61742b38f | ||
|
|
da065d1077 | ||
|
|
38c147b331 | ||
|
|
8314ffbe75 | ||
|
|
ee304b0016 | ||
|
|
0c9ddd3b25 | ||
|
|
098056458e | ||
|
|
6eeaf50423 | ||
|
|
4b0bf4379f | ||
|
|
6d996882bf | ||
|
|
82859d8d08 | ||
|
|
7b4f6f7d94 | ||
| 790db95bda | |||
|
|
7142c8f7e8 | ||
|
|
9062ce9f22 | ||
|
|
d252077135 | ||
|
|
5606f987c5 | ||
|
|
4353e8fe33 | ||
|
|
f826b7b9a3 | ||
|
|
6432734f74 | ||
|
|
0394dfbca0 | ||
|
|
837a36276a | ||
|
|
a43b7dd9dd | ||
|
|
4ec3f57210 | ||
|
|
9df22e01e7 | ||
|
|
35052a0ced | ||
| e1bb3b2b11 | |||
|
|
ac66abec28 | ||
|
|
7d74f81913 | ||
|
|
3bba99e874 | ||
|
|
697040e9d3 | ||
|
|
39ba59ab25 | ||
|
|
10478fb035 | ||
| a6b0cf3a00 | |||
|
|
dfc45fbea2 | ||
| 210b577462 | |||
|
|
60332956a0 | ||
|
|
1b90e5db39 | ||
|
|
52589fc76d | ||
| 685f2dcd24 | |||
|
|
3329eeb2fd | ||
|
|
760103f838 | ||
|
|
592bbf0f57 | ||
|
|
57b31d58cc | ||
|
|
e13dfe7d46 | ||
|
|
b895d6be79 | ||
|
|
91600e4e8c | ||
|
|
eaba7f95e3 | ||
|
|
a442f1f53a | ||
| e88bc02012 | |||
|
|
13e82e0a52 | ||
|
|
750a6ab03b | ||
| 1ecd964104 | |||
|
|
97ed70a921 | ||
|
|
0bfc18305b | ||
|
|
a83a27eb50 | ||
|
|
2de70d3b8c | ||
| b379d5b9fc | |||
| 71f18fde9d | |||
| dfdde7fa4d | |||
|
|
dbc984ed7f | ||
|
|
4092025693 | ||
|
|
29ed0a15cd | ||
|
|
7bad0a46c2 | ||
|
|
f888a6fc0d | ||
|
|
1cbd3e1a84 | ||
|
|
bff336baa7 | ||
|
|
cad24ea089 | ||
|
|
459832a2a3 | ||
|
|
610975561f | ||
|
|
a3a84faae7 | ||
|
|
d493ce8f33 | ||
|
|
fe62662cb6 | ||
|
|
6a77bb5d2e | ||
|
|
c48c28fdb3 | ||
| 17a888bd67 | |||
|
|
1be3ab1961 | ||
|
|
bd582fc6c4 | ||
|
|
2c5cf1bcf8 | ||
|
|
292e375a37 | ||
|
|
9a6518a5d8 | ||
|
|
c66360cb7e | ||
|
|
abeefc221b | ||
|
|
20c18583db | ||
|
|
27c9f58b80 | ||
|
|
c73b1a1788 | ||
|
|
447b5ca726 | ||
|
|
e8b4634dd1 | ||
|
|
a41e8be1e1 | ||
|
|
38e68d8273 | ||
|
|
1d887bc153 | ||
|
|
c46b9b11f6 | ||
|
|
fe449fdc50 | ||
| 9b6f887c3f | |||
|
|
89e1be4b12 | ||
|
|
2ccc0ea48d | ||
|
|
3fecfdc403 | ||
|
|
37fa8f13f4 | ||
|
|
9bb2c0de3f | ||
|
|
368410e9d8 | ||
|
|
4b23dd0905 | ||
|
|
60b1df6892 | ||
|
|
ed7233d6e2 | ||
|
|
4a20816ba0 | ||
|
|
036aff1d1d | ||
| 72b3f03dad | |||
|
|
41b0c69326 | ||
| 92ab414048 |
@@ -23,6 +23,7 @@ MAX_AGENT_TOKENS=4096
|
||||
# Shared secret for actor → backend service calls (rotate in prod)
|
||||
SERVICE_TOKEN=dev-service-token-REPLACE_ME
|
||||
A2A_ALLOWED_KEY=***********
|
||||
SESSION_TICKET_SECRET=
|
||||
|
||||
# ── Central Gitea (shared org-wide, changes.md §2A) ──
|
||||
# Public URL is used for Git remotes and must be reachable by OpenCode containers.
|
||||
|
||||
@@ -2,14 +2,15 @@ FROM node:22-alpine AS base
|
||||
WORKDIR /app
|
||||
|
||||
FROM base AS deps
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN npm install
|
||||
RUN corepack enable && corepack prepare pnpm@10.24.0 --activate
|
||||
COPY package.json pnpm-lock.yaml ./
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
FROM base AS build
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY tsconfig.json ./
|
||||
COPY src ./src
|
||||
RUN npx tsc -p tsconfig.json
|
||||
RUN ./node_modules/.bin/tsc -p tsconfig.json
|
||||
|
||||
FROM base AS runtime
|
||||
ARG RIVET_RUNNER_VERSION=dev
|
||||
|
||||
@@ -36,7 +36,7 @@ Use the interview-service when the user wants any of the following:
|
||||
Do not use interview-service for:
|
||||
- Resume writing, tailoring, ATS optimization, resume scoring, resume bullet generation → Resume Agent.
|
||||
- Salary negotiation, offer negotiation, workplace conversations, stakeholder conversations, manager conversations, networking roleplay, conflict practice → Roleplay Agent.
|
||||
- Overall market-readiness/Q-score computation → Q Score Agent.
|
||||
- Overall market-readiness/RQ Score computation → RQ Score Agent.
|
||||
- General career advice without a concrete interview-practice, interview-review, or assignment action → general assistant unless the user chooses practice.
|
||||
|
||||
## Service capabilities from a client perspective
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
---
|
||||
id: qscore
|
||||
name: Q Score Agent
|
||||
role: Q Score Agent
|
||||
name: RQ Score Agent
|
||||
role: RQ Score Agent
|
||||
service: qscore-service
|
||||
tools: ["compute_qscore"]
|
||||
---
|
||||
|
||||
# Q Score Agent
|
||||
# RQ Score Agent
|
||||
|
||||
The Q Score Agent is GrowQR's API client for the `qscore-service`. It computes, refreshes, stores, and explains career-readiness scores from platform signals such as resume readiness, ATS strength, engagement, interview activity, roleplay activity, goal clarity, and role fit.
|
||||
The RQ Score Agent is GrowQR's API client for the `qscore-service`. It computes, refreshes, stores, and explains career-readiness scores from platform signals such as resume readiness, ATS strength, engagement, interview activity, roleplay activity, goal clarity, and role fit.
|
||||
|
||||
Write from a service-client perspective. Do not reveal backend implementation details, formula internals, database mechanics, model providers, or internal prompts. Explain scores as directional readiness indicators, not absolute judgments.
|
||||
|
||||
## Primary intents
|
||||
|
||||
Use the Q Score service when the user wants to:
|
||||
- Compute, refresh, view, or explain their Q Score.
|
||||
Use the RQ Score service when the user wants to:
|
||||
- Compute, refresh, view, or explain their RQ Score.
|
||||
- Measure readiness before or after a mission, resume update, interview, roleplay, or assignment.
|
||||
- Understand which readiness dimensions to improve next.
|
||||
- Compare before/after progress snapshots.
|
||||
@@ -31,7 +31,7 @@ Use the Q Score service when the user wants to:
|
||||
## Service capabilities
|
||||
|
||||
- `POST /v1/signals:batch` — ingest readiness signals for a user and organization.
|
||||
- `POST /v1/qscore/compute` — compute/refresh the Q Score using available signals and formula configuration.
|
||||
- `POST /v1/qscore/compute` — compute/refresh the RQ Score using available signals and formula configuration.
|
||||
- `GET /v1/qscore/{user_id}?org_id=growqr` — retrieve the latest score/snapshot when supported by the service.
|
||||
|
||||
## Signal normalization
|
||||
@@ -39,15 +39,15 @@ 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.
|
||||
|
||||
Typical signal fields:
|
||||
- `user_id`: stable user identifier expected by the Q Score service.
|
||||
- `user_id`: stable user identifier expected by the RQ Score service.
|
||||
- `org_id`: default `growqr` unless a current organization is provided.
|
||||
- `signal_id`: event/dimension identifier such as `resume.ats_compatibility`.
|
||||
- `value`: numeric, boolean, or categorical readiness value.
|
||||
@@ -59,7 +59,7 @@ Typical signal fields:
|
||||
1. If no recent signals exist, ingest the best available signals first.
|
||||
2. Then call `POST /v1/qscore/compute`.
|
||||
3. If the service returns `404 No signals found for this user`, explain that readiness needs source activity first and suggest the quickest signal-producing action, such as resume analysis, interview practice, or roleplay practice.
|
||||
4. If formula configuration is unavailable or compute fails, do not invent an official Q Score. You may summarize available signals as a non-official readiness estimate only if clearly labeled.
|
||||
4. If formula configuration is unavailable or compute fails, do not invent an official RQ Score. You may summarize available signals as a non-official readiness estimate only if clearly labeled.
|
||||
|
||||
## Explanation strategy
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ Use the resume service when the user wants to:
|
||||
|
||||
- Mock interviews, interview practice, interview assignments, or interview reviews → Interview Agent.
|
||||
- Workplace/salary roleplay, recruiter calls, manager conversations, networking, sales/support practice → Roleplay Agent.
|
||||
- Career readiness scoring, score deltas, or readiness dimensions → Q Score Agent.
|
||||
- Career readiness scoring, score deltas, or readiness dimensions → RQ Score Agent.
|
||||
- Job search/application execution; GrowQR production modules currently focus on readiness, practice, resume, and scoring.
|
||||
|
||||
## Service capabilities
|
||||
|
||||
@@ -25,7 +25,7 @@ Use the roleplay service when the user wants to:
|
||||
|
||||
- Mock job interviews, interview rounds, interview question practice → Interview Agent.
|
||||
- Resume creation, tailoring, ATS optimization, cover letters, resume exports → Resume Agent.
|
||||
- Career readiness scoring or score deltas → Q Score Agent.
|
||||
- Career readiness scoring or score deltas → RQ Score Agent.
|
||||
- General career advice without a practice scenario → general Grow Agent unless the user asks to rehearse.
|
||||
|
||||
## Service capabilities
|
||||
@@ -59,7 +59,7 @@ Required/typical fields:
|
||||
- `roleplay_type`: one of `sales`, `customer_success`, `support`, `custom`.
|
||||
- `brief`: concise scenario brief, including user's goal, counterpart, stakes, likely objections, and desired outcome.
|
||||
- `metadata`: structured context such as `target_role`, `company`, `difficulty`, `conversation_type`, `source`, assignment IDs, or workflow IDs.
|
||||
- `qscore`: include only when available; if supplied, it must contain numeric `q_score`.
|
||||
- `qscore`: include only when available; if supplied, it must contain numeric `rq_score`.
|
||||
|
||||
Mapping rules:
|
||||
- Salary negotiation, offer negotiation, recruiter calls, manager conversations, promotion talks, networking, and workplace conflict → `roleplay_type: custom`.
|
||||
|
||||
51
docker-compose.override.yml
Normal file
51
docker-compose.override.yml
Normal file
@@ -0,0 +1,51 @@
|
||||
# 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:
|
||||
environment:
|
||||
DATABASE_URL: postgres://growqr:growqr@growqr-postgres:5432/growqr
|
||||
RESUME_SERVICE_URL: http://growqr_resume_api:8000
|
||||
SOCIAL_BRANDING_SERVICE_URL: http://host.docker.internal:18015
|
||||
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: kimi-k2.6
|
||||
GROW_AGENT_MODEL: kimi-k2.6
|
||||
CONVERSATION_ACTOR_MODEL: kimi-k2.6
|
||||
|
||||
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-staging_default
|
||||
course-service:
|
||||
external: true
|
||||
name: course-service_default
|
||||
qscore-service-staging:
|
||||
external: true
|
||||
name: qscore-service-staging_default
|
||||
matchmaking-v2-staging:
|
||||
external: true
|
||||
name: matchmaking-v2_default
|
||||
user-service-staging:
|
||||
external: true
|
||||
name: user-service-staging_default
|
||||
@@ -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}
|
||||
@@ -97,14 +97,17 @@ services:
|
||||
# Auth
|
||||
CLERK_SECRET_KEY: ${CLERK_SECRET_KEY}
|
||||
CLERK_PUBLISHABLE_KEY: ${CLERK_PUBLISHABLE_KEY}
|
||||
SERVICE_TOKEN: ${SERVICE_TOKEN:-dev-service-token}
|
||||
A2A_ALLOWED_KEY: ${A2A_ALLOWED_KEY:-dev-a2a-key}
|
||||
SERVICE_TOKEN: ${SERVICE_TOKEN:-}
|
||||
SESSION_TICKET_SECRET: ${SESSION_TICKET_SECRET:?SESSION_TICKET_SECRET must be set}
|
||||
A2A_ALLOWED_KEY: ${A2A_ALLOWED_KEY:-}
|
||||
# LLM
|
||||
OPENCODE_API_KEY: ${OPENCODE_API_KEY}
|
||||
LLM_PROVIDER: ${LLM_PROVIDER:-opencode}
|
||||
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 +119,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
|
||||
|
||||
121
docs/prm-80-pr12-final-audit.md
Normal file
121
docs/prm-80-pr12-final-audit.md
Normal 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.
|
||||
154
docs/qa/prm-71-backend-qa-evidence.md
Normal file
154
docs/qa/prm-71-backend-qa-evidence.md
Normal 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.
|
||||
1
drizzle/0011_conversation_metadata.sql
Normal file
1
drizzle/0011_conversation_metadata.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE "grow_conversations" ADD COLUMN IF NOT EXISTS "metadata" jsonb;
|
||||
33
drizzle/0012_system_notifications.sql
Normal file
33
drizzle/0012_system_notifications.sql
Normal 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");
|
||||
13
drizzle/0013_onboarding.sql
Normal file
13
drizzle/0013_onboarding.sql
Normal 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");
|
||||
4
drizzle/0014_session_identity_user_scope.sql
Normal file
4
drizzle/0014_session_identity_user_scope.sql
Normal file
@@ -0,0 +1,4 @@
|
||||
-- Session external ids are only unique within a user's service namespace.
|
||||
DROP INDEX IF EXISTS mission_service_sessions_external_idx;
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS mission_service_sessions_external_idx
|
||||
ON mission_service_sessions(user_id, service_id, external_id);
|
||||
21
drizzle/0015_event_outbox.sql
Normal file
21
drizzle/0015_event_outbox.sql
Normal file
@@ -0,0 +1,21 @@
|
||||
CREATE TABLE IF NOT EXISTS "event_outbox" (
|
||||
"id" text PRIMARY KEY DEFAULT gen_random_uuid()::text NOT NULL,
|
||||
"topic" text NOT NULL,
|
||||
"aggregate_id" text NOT NULL,
|
||||
"destination" text NOT NULL,
|
||||
"payload" jsonb NOT NULL,
|
||||
"headers" jsonb DEFAULT '{}'::jsonb NOT NULL,
|
||||
"status" text DEFAULT 'pending' NOT NULL,
|
||||
"attempts" integer DEFAULT 0 NOT NULL,
|
||||
"available_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"locked_at" timestamp with time zone,
|
||||
"published_at" timestamp with time zone,
|
||||
"last_error" text,
|
||||
"response" jsonb,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "event_outbox_status_check" CHECK ("status" IN ('pending', 'processing', 'published'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "event_outbox_delivery_idx" ON "event_outbox" USING btree ("status", "available_at");
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "event_outbox_topic_aggregate_idx" ON "event_outbox" USING btree ("topic", "aggregate_id");
|
||||
2
drizzle/0016_rq_score_snapshot.sql
Normal file
2
drizzle/0016_rq_score_snapshot.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE "qscore_snapshots"
|
||||
RENAME COLUMN "score" TO "rq_score";
|
||||
@@ -78,6 +78,48 @@
|
||||
"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
|
||||
},
|
||||
{
|
||||
"idx": 14,
|
||||
"version": "7",
|
||||
"when": 1784024806000,
|
||||
"tag": "0014_session_identity_user_scope",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 15,
|
||||
"version": "7",
|
||||
"when": 1783987200000,
|
||||
"tag": "0015_event_outbox",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 16,
|
||||
"version": "7",
|
||||
"when": 1784010000000,
|
||||
"tag": "0016_rq_score_snapshot",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
6394
package-lock.json
generated
Normal file
6394
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
20
package.json
20
package.json
@@ -6,6 +6,20 @@
|
||||
"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",
|
||||
"test:qscore-raw-events": "tsx scripts/qscore-raw-event-contract.test.ts",
|
||||
"test:rq-score-contract": "tsx scripts/rq-score-contract.test.ts",
|
||||
"test:home-insight-snapshot": "tsx scripts/home-insight-snapshot.test.ts",
|
||||
"test:career-report": "tsx scripts/career-report.test.ts",
|
||||
"test:onboarding-read": "tsx scripts/onboarding-rev10-read.test.ts",
|
||||
"test:interview-user-identity": "tsx scripts/interview-user-identity.test.ts",
|
||||
"test:ws-ticket": "tsx scripts/ws-ticket.test.ts",
|
||||
"test:roleplay-drafts": "tsx scripts/roleplay-drafts.test.ts",
|
||||
"test:resume-gateway": "tsx scripts/resume-gateway.test.ts",
|
||||
"start": "node dist/index.js",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||
"workflows:smoke": "tsx src/workflows/smoke-test.ts",
|
||||
@@ -16,8 +30,10 @@
|
||||
"db:generate": "drizzle-kit generate",
|
||||
"db:migrate": "tsx src/db/migrate.ts",
|
||||
"db:studio": "drizzle-kit studio",
|
||||
"compose:up": "docker compose up -d",
|
||||
"compose:down": "docker compose down",
|
||||
"compose:up": "scripts/staging-compose.sh up",
|
||||
"compose:down": "scripts/staging-compose.sh down",
|
||||
"staging:preflight": "scripts/staging-compose.sh preflight",
|
||||
"staging:verify": "scripts/staging-compose.sh verify",
|
||||
"docker:opencode:build": "docker build --build-arg GROWQR_IMAGE_VERSION=${OPENCODE_IMAGE_VERSION:-dev} --build-arg GROWQR_PROMPT_VERSION=${PROMPT_VERSION:-4} --build-arg GROWQR_MIGRATION_VERSION=${MIGRATION_VERSION:-1} -f docker/opencode/Dockerfile -t growqr/opencode:dev ."
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -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 RQ 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
|
||||
|
||||
|
||||
264
scripts/career-report.test.ts
Normal file
264
scripts/career-report.test.ts
Normal file
@@ -0,0 +1,264 @@
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
buildCareerReportProjection,
|
||||
parseCareerReportDisplay,
|
||||
type CareerReportActivity,
|
||||
} from "../src/v1/analytics/career-report.js";
|
||||
import type { HomeInsightSnapshots, InsightSnapshot } from "../src/v1/analytics/home-insight-snapshot.js";
|
||||
import type { QscoreProxyResult } from "../src/services/qscore-proxy.js";
|
||||
|
||||
const categorySpecs = [
|
||||
["learning_credentials", "Learning & Credentials"],
|
||||
["resume_presence", "Resume Presence"],
|
||||
["linkedin_presence", "LinkedIn Presence"],
|
||||
["matching_activity", "Matching Activity"],
|
||||
["interview_performance", "Interview Performance"],
|
||||
["pathway_progress", "Pathway Progress"],
|
||||
["roleplay_performance", "Roleplay Performance"],
|
||||
["engagement_consistency", "Engagement & Consistency"],
|
||||
["goals_readiness", "Goals & Readiness"],
|
||||
];
|
||||
|
||||
function displayFixture() {
|
||||
let signalIndex = 0;
|
||||
const categories = categorySpecs.map(([id, label], categoryIndex) => {
|
||||
const count = categoryIndex === categorySpecs.length - 1 ? 3 : 8;
|
||||
const signals = Array.from({ length: count }, () => {
|
||||
const index = signalIndex++;
|
||||
return { id: `signal.${index}`, label: `Signal ${index}`, present: index < 4, score: index < 4 ? 50 : 0 };
|
||||
});
|
||||
return {
|
||||
id,
|
||||
label,
|
||||
input_ids: [`input_${categoryIndex}`],
|
||||
weight_percent: 10,
|
||||
score: signals.some((signal) => signal.present) ? 50 : 0,
|
||||
active: signals.some((signal) => signal.present),
|
||||
present_rules: signals.filter((signal) => signal.present).length,
|
||||
total_rules: signals.length,
|
||||
signals,
|
||||
};
|
||||
});
|
||||
return {
|
||||
version: "rq-score-display-v1",
|
||||
registered_signal_count: 67,
|
||||
active_signal_count: 4,
|
||||
categories,
|
||||
};
|
||||
}
|
||||
|
||||
function metric(overrides: Partial<InsightSnapshot> = {}): InsightSnapshot {
|
||||
return {
|
||||
status: "empty",
|
||||
source: "fixture",
|
||||
value: 0,
|
||||
unit: "count",
|
||||
label: "Fixture",
|
||||
updatedAt: null,
|
||||
reason: "no_evidence",
|
||||
detail: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const snapshots: HomeInsightSnapshots = {
|
||||
rqScore: metric({ status: "ready", source: "qscore.current", value: 61, reason: null }),
|
||||
interview: metric({ source: "interview.page-state" }),
|
||||
resume: metric({ status: "ready", source: "resume.state", value: 72, reason: null }),
|
||||
opportunity: metric({ source: "matchmaking.scout-stats" }),
|
||||
pathway: metric({ status: "unsupported", source: "qscore.breakdown.display", value: null, reason: "pathway_signal_contract_missing" }),
|
||||
socialBrand: metric({ source: "social.state" }),
|
||||
roleplay: metric({ source: "roleplay.page-state" }),
|
||||
learning: metric({ source: "courses.course-state" }),
|
||||
skillGap: metric({ source: "assessment.list" }),
|
||||
};
|
||||
|
||||
const qscore: QscoreProxyResult = {
|
||||
org_id: "growqr",
|
||||
user_id: "00000000-0000-4000-8000-000000000000",
|
||||
rq_score: 61,
|
||||
iq_score: null,
|
||||
eq_score: null,
|
||||
sq_score: null,
|
||||
profession: "",
|
||||
formula_version: "v2",
|
||||
formula_id: "fixture",
|
||||
calculated_at: "2026-07-16T00:00:00Z",
|
||||
quotients: {},
|
||||
breakdown: { display: displayFixture() },
|
||||
ledger_seq_from: null,
|
||||
ledger_seq_to: null,
|
||||
};
|
||||
|
||||
const activity: CareerReportActivity = {
|
||||
windowDays: 14,
|
||||
totalEvents: 0,
|
||||
completedEvents: 0,
|
||||
openedEvents: 0,
|
||||
services: [],
|
||||
opportunityEvents: 0,
|
||||
};
|
||||
|
||||
const producerStatuses = {
|
||||
interview: { status: "ready" as const, reason: null },
|
||||
roleplay: { status: "ready" as const, reason: null },
|
||||
resume: { status: "ready" as const, reason: null },
|
||||
linkedin: { status: "ready" as const, reason: null },
|
||||
matchmaking: { status: "ready" as const, reason: null },
|
||||
courses: { status: "ready" as const, reason: null },
|
||||
assessment: { status: "ready" as const, reason: null },
|
||||
};
|
||||
|
||||
const report = buildCareerReportProjection({
|
||||
generatedAt: "2026-07-16T01:00:00Z",
|
||||
profile: {
|
||||
status: "ready",
|
||||
data: {
|
||||
first_name: "Ada",
|
||||
last_name: "Lovelace",
|
||||
avatar_url: "https://images.example/ada.png",
|
||||
updated_at: "2026-07-15T22:00:00Z",
|
||||
preferences: {
|
||||
onboarding: {
|
||||
progress: { updated_at: "2026-07-15T23:00:00Z" },
|
||||
responses: {
|
||||
target_role: "Platform Engineer",
|
||||
target_field: "Technology",
|
||||
experience_level: "3-5 years",
|
||||
current_situation: "employed",
|
||||
desired_outcomes: ["Move into platform engineering"],
|
||||
career_barriers: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
qscore: { status: "ready", data: qscore },
|
||||
qscoreHistory: { status: "ready", data: [{ date: "2026-07-15", rq_score: 61 }] },
|
||||
snapshots,
|
||||
producerStatuses,
|
||||
activity,
|
||||
});
|
||||
|
||||
assert.equal(report.contractVersion, "career-report-v1");
|
||||
assert.deepEqual(report.subject.displayName, {
|
||||
status: "ready",
|
||||
source: "user-service.profile",
|
||||
value: "Ada Lovelace",
|
||||
updatedAt: "2026-07-15T22:00:00Z",
|
||||
reason: null,
|
||||
});
|
||||
assert.equal(report.subject.targetRole.value, "Platform Engineer");
|
||||
assert.equal(report.subject.barriers.status, "empty");
|
||||
assert.deepEqual(report.subject.barriers.value, []);
|
||||
assert.equal(report.rqScore.score.value, 61);
|
||||
assert.equal(report.rqScore.display.status, "ready");
|
||||
assert.equal(report.rqScore.display.value?.registeredSignalCount, 67);
|
||||
assert.equal(report.rqScore.display.value?.categories.length, 9);
|
||||
assert.deepEqual(report.activity.value, activity);
|
||||
assert.equal(report.sources.find((source) => source.id === "grow_events")?.status, "ready");
|
||||
|
||||
const malformed = structuredClone(displayFixture());
|
||||
malformed.registered_signal_count = 66;
|
||||
assert.equal(parseCareerReportDisplay(malformed), null, "mislabelled canonical display must be rejected");
|
||||
const reportWithMalformedDisplay = buildCareerReportProjection({
|
||||
profile: { status: "ready", data: {} },
|
||||
qscore: { status: "ready", data: { ...qscore, breakdown: { display: malformed } } },
|
||||
qscoreHistory: { status: "ready", data: [] },
|
||||
snapshots,
|
||||
producerStatuses,
|
||||
activity,
|
||||
});
|
||||
assert.equal(reportWithMalformedDisplay.rqScore.display.status, "unavailable");
|
||||
assert.equal(reportWithMalformedDisplay.rqScore.display.value, null);
|
||||
assert.equal(reportWithMalformedDisplay.rqScore.display.reason, "display_contract_invalid");
|
||||
|
||||
const reportWithoutDisplay = buildCareerReportProjection({
|
||||
profile: { status: "ready", data: {} },
|
||||
qscore: { status: "ready", data: { ...qscore, breakdown: {} } },
|
||||
qscoreHistory: { status: "ready", data: [] },
|
||||
snapshots,
|
||||
producerStatuses,
|
||||
activity,
|
||||
});
|
||||
assert.equal(reportWithoutDisplay.rqScore.display.status, "unsupported");
|
||||
assert.equal(reportWithoutDisplay.rqScore.display.value, null);
|
||||
assert.equal(reportWithoutDisplay.rqScore.display.reason, "display_contract_missing");
|
||||
|
||||
const wrongCategoryId = structuredClone(displayFixture());
|
||||
wrongCategoryId.categories[0].id = "not_a_canonical_category";
|
||||
assert.equal(parseCareerReportDisplay(wrongCategoryId), null, "wrong category ids must be rejected");
|
||||
|
||||
const wrongCategoryOrder = structuredClone(displayFixture());
|
||||
[wrongCategoryOrder.categories[0], wrongCategoryOrder.categories[1]] = [wrongCategoryOrder.categories[1], wrongCategoryOrder.categories[0]];
|
||||
assert.equal(parseCareerReportDisplay(wrongCategoryOrder), null, "canonical categories out of order must be rejected");
|
||||
|
||||
const outOfRangeCategoryScore = structuredClone(displayFixture());
|
||||
outOfRangeCategoryScore.categories[0].score = 101;
|
||||
assert.equal(parseCareerReportDisplay(outOfRangeCategoryScore), null, "category scores above 100 must be rejected");
|
||||
|
||||
const outOfRangeSignalScore = structuredClone(displayFixture());
|
||||
outOfRangeSignalScore.categories[0].signals[0].score = -1;
|
||||
assert.equal(parseCareerReportDisplay(outOfRangeSignalScore), null, "signal scores below zero must be rejected");
|
||||
|
||||
const outOfRangeWeight = structuredClone(displayFixture());
|
||||
outOfRangeWeight.categories[0].weight_percent = 101;
|
||||
assert.equal(parseCareerReportDisplay(outOfRangeWeight), null, "category weights above 100 must be rejected");
|
||||
|
||||
const duplicatedSignal = structuredClone(displayFixture());
|
||||
duplicatedSignal.categories[1].signals[0].id = duplicatedSignal.categories[0].signals[0].id;
|
||||
assert.equal(parseCareerReportDisplay(duplicatedSignal), null, "signals assigned more than once must be rejected");
|
||||
|
||||
const categoryCountMismatch = structuredClone(displayFixture());
|
||||
categoryCountMismatch.categories[0].present_rules += 1;
|
||||
assert.equal(parseCareerReportDisplay(categoryCountMismatch), null, "category count mismatches must be rejected");
|
||||
|
||||
const malformedOnboarding = buildCareerReportProjection({
|
||||
profile: {
|
||||
status: "ready",
|
||||
data: {
|
||||
preferences: {
|
||||
onboarding: {
|
||||
responses: {
|
||||
target_role: 42,
|
||||
desired_outcomes: ["Real goal", 7],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
qscore: { status: "ready", data: qscore },
|
||||
qscoreHistory: { status: "ready", data: [] },
|
||||
snapshots,
|
||||
producerStatuses,
|
||||
activity,
|
||||
});
|
||||
assert.equal(malformedOnboarding.subject.targetRole.status, "unavailable");
|
||||
assert.equal(malformedOnboarding.subject.targetRole.value, null);
|
||||
assert.equal(malformedOnboarding.subject.targetRole.reason, "producer_response_invalid");
|
||||
assert.equal(malformedOnboarding.subject.goals.status, "unavailable");
|
||||
assert.equal(malformedOnboarding.subject.goals.value, null);
|
||||
assert.equal(malformedOnboarding.subject.barriers.status, "empty", "missing optional arrays remain confirmed empty");
|
||||
assert.deepEqual(malformedOnboarding.subject.barriers.value, []);
|
||||
|
||||
const unavailable = buildCareerReportProjection({
|
||||
profile: { status: "unavailable", reason: "profile_unavailable" },
|
||||
qscore: { status: "unavailable", reason: "request_failed" },
|
||||
qscoreHistory: { status: "unavailable", reason: "request_failed" },
|
||||
snapshots: Object.fromEntries(
|
||||
Object.keys(snapshots).map((key) => [key, metric({ status: "unavailable", value: null, reason: "producer_unavailable" })]),
|
||||
) as HomeInsightSnapshots,
|
||||
producerStatuses: Object.fromEntries(
|
||||
Object.keys(producerStatuses).map((key) => [key, { status: "unavailable", reason: "producer_unavailable" }]),
|
||||
) as typeof producerStatuses,
|
||||
activity,
|
||||
});
|
||||
assert.equal(unavailable.subject.displayName.status, "unavailable");
|
||||
assert.equal(unavailable.subject.displayName.value, null);
|
||||
assert.equal(unavailable.rqScore.score.status, "unavailable");
|
||||
assert.equal(unavailable.rqScore.score.value, null);
|
||||
assert.equal(unavailable.rqScore.history.value, null);
|
||||
assert.equal(unavailable.rqScore.display.value, null);
|
||||
|
||||
console.log("career report contract: ok");
|
||||
24
scripts/count-fallback.test.ts
Normal file
24
scripts/count-fallback.test.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { canonicalGrowEventEnvelope } from "../src/events/record-grow-event.js";
|
||||
import { normalizeGrowEvent } from "../src/events/normalize.js";
|
||||
|
||||
/** Bare completions stay bare. QScore may aggregate them as real events, but
|
||||
* the backend must never invent session_count/scenario_count or a score. */
|
||||
for (const [source, type] of [
|
||||
["interview-service", "interview.session.completed"],
|
||||
["roleplay-service", "roleplay.scenario.completed"],
|
||||
] as const) {
|
||||
const event = canonicalGrowEventEnvelope(normalizeGrowEvent({
|
||||
id: `event-${type}`,
|
||||
source,
|
||||
type,
|
||||
category: "service",
|
||||
occurredAt: "2026-07-14T00:00:00.000Z",
|
||||
payload: {},
|
||||
}), "real-user");
|
||||
assert.deepEqual(event.payload, {});
|
||||
assert.equal("score" in event.payload as object, false);
|
||||
assert.equal("session_count" in event.payload as object, false);
|
||||
assert.equal("scenario_count" in event.payload as object, false);
|
||||
}
|
||||
console.log("no count fallback tests passed");
|
||||
30
scripts/course-matchmaking-routes.test.ts
Normal file
30
scripts/course-matchmaking-routes.test.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { serviceRoutes } from "../src/routes/services.js";
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
const calls: Array<{ url: string; body: Record<string, unknown> }> = [];
|
||||
globalThis.fetch = (async (input, init) => {
|
||||
const body = JSON.parse(String(init?.body ?? "{}")) as Record<string, unknown>;
|
||||
calls.push({ url: String(input), body });
|
||||
return new Response(JSON.stringify({ task_id: "a2a-task", status: "completed", messages: [] }), { status: 200, headers: { "content-type": "application/json" } });
|
||||
}) as typeof fetch;
|
||||
const app = serviceRoutes({ skipAuth: true });
|
||||
try {
|
||||
const courseResponse = await app.request("http://backend.test/courses/a2a?missionId=mission-1&missionInstanceId=instance-1&stageId=stage-1&curatorTaskId=task-1", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ action: "record_swipe", params: { video_id: "video-1", anchor_id: "anchor-1" } }) });
|
||||
assert.equal(courseResponse.status, 200);
|
||||
assert.ok(calls[0]?.url.endsWith("/a2a/tasks"));
|
||||
assert.equal(calls[0]?.body.user_id, "user_test");
|
||||
const params = calls[0]?.body.params as Record<string, unknown>;
|
||||
assert.equal(params.missionId, "mission-1");
|
||||
assert.equal(params.missionInstanceId, "instance-1");
|
||||
assert.equal(params.stageId, "stage-1");
|
||||
assert.equal(params.curatorTaskId, "task-1");
|
||||
|
||||
calls.length = 0;
|
||||
const matchmakingResponse = await app.request("http://backend.test/matchmaking/a2a", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ action: "mark_applied", params: { opportunity_id: "job-1" } }) });
|
||||
assert.equal(matchmakingResponse.status, 200);
|
||||
assert.ok(calls[0]?.url.endsWith("/a2a/tasks"));
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
console.log("course-matchmaking HTTP route tests passed");
|
||||
58
scripts/curator-access-choice.test.ts
Normal file
58
scripts/curator-access-choice.test.ts
Normal 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");
|
||||
20
scripts/curator-direct-completion-policy.test.ts
Normal file
20
scripts/curator-direct-completion-policy.test.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { allowsDirectServiceCompletion } from "../src/v1/curator/curator-actor.js";
|
||||
import { v1CuratorRoutes } from "../src/v1/curator/curator-routes.js";
|
||||
|
||||
assert.equal(
|
||||
allowsDirectServiceCompletion(undefined, "matchmaking_matches_reviewed"),
|
||||
false,
|
||||
"a recovered or arbitrary task ID must not bypass exact Curator task lookup",
|
||||
);
|
||||
assert.equal(allowsDirectServiceCompletion("matchmaking-service", "matchmaking_matches_reviewed"), true);
|
||||
assert.equal(allowsDirectServiceCompletion("qscore-service", "qscore_review_opened"), true);
|
||||
assert.equal(allowsDirectServiceCompletion("resume-service", "resume_uploaded"), false);
|
||||
|
||||
const serviceImpactRoute = v1CuratorRoutes().routes.find(
|
||||
(route) => route.method === "POST" && route.path === "/events/service-impact",
|
||||
);
|
||||
assert.equal(serviceImpactRoute, undefined, "the false-positive service-impact compatibility route must not be exposed");
|
||||
|
||||
console.log("curator direct-completion policy tests passed");
|
||||
process.exit(0);
|
||||
98
scripts/curator-onboarding-reconcile.test.ts
Normal file
98
scripts/curator-onboarding-reconcile.test.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import {
|
||||
curatorSprintPlanFingerprint,
|
||||
curatorSprintTaskPlan,
|
||||
decideCuratorSprintReconciliation,
|
||||
resolveCuratorSprintAccess,
|
||||
shouldReconcileCuratorSprint,
|
||||
shouldRebaseCuratorSprintForOnboardingContext,
|
||||
} 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.equal(
|
||||
shouldRebaseCuratorSprintForOnboardingContext({
|
||||
existingStartDate: "2026-07-14",
|
||||
focusDate: "2026-07-15",
|
||||
onboardingContextProvided: true,
|
||||
existingCompatible: true,
|
||||
}),
|
||||
false,
|
||||
"a normal sprint read with onboarding context must preserve the active sprint across days",
|
||||
);
|
||||
assert.equal(
|
||||
shouldRebaseCuratorSprintForOnboardingContext({
|
||||
existingStartDate: "2026-07-14",
|
||||
focusDate: "2026-07-15",
|
||||
onboardingContextProvided: true,
|
||||
existingCompatible: false,
|
||||
}),
|
||||
true,
|
||||
"changed onboarding compatibility may replace the existing sprint without resetting its calendar identity",
|
||||
);
|
||||
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");
|
||||
148
scripts/curator-persisted-reconcile.test.ts
Normal file
148
scripts/curator-persisted-reconcile.test.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
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 noCompletionDay2 = await buildCuratorSprint(userId, addDays(startDate, 1));
|
||||
const noCompletionTitles = noCompletionDay2.plan.days[1]?.tasks.map((task) => task.title) ?? [];
|
||||
assert.ok(noCompletionTitles.includes("Executive Leadership Interview"), "zero-completion Day 2 keeps experienced recovery behavior");
|
||||
assert.ok(!noCompletionTitles.some((title) => ["Complete GrowQR Profile", "Career Baseline", "First AI Resume", "Self-Introduction"].includes(title)), "recovery must not reintroduce student tasks");
|
||||
|
||||
await db.insert(growEvents).values((day1.plan.days[0]?.tasks ?? []).map((task, index) => ({
|
||||
userId,
|
||||
source: "curator-v1",
|
||||
type: "curator.task.completed",
|
||||
category: "mission" as const,
|
||||
occurredAt: new Date(Date.now() + index),
|
||||
dedupeKey: `${userId}:curator.task.completed:${task.id}`,
|
||||
payload: { taskId: task.id },
|
||||
})));
|
||||
|
||||
const day2 = await buildCuratorSprint(userId, addDays(startDate, 1));
|
||||
assert.deepEqual(day2.plan.days[1]?.tasks.map(({ id, title }) => ({ id, title })),
|
||||
expectedTasks(startDate, 2), "completed Day 1 must expose exact experienced Day 2 registry tasks");
|
||||
|
||||
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]!,
|
||||
}));
|
||||
}
|
||||
20
scripts/curator-profile-task-policy.test.ts
Normal file
20
scripts/curator-profile-task-policy.test.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { resolveProfileTaskPolicy } from "../src/v1/curator/streak-utils.js";
|
||||
|
||||
assert.deepEqual(
|
||||
resolveProfileTaskPolicy({ resumeAvailable: true, linkedinAvailable: false }),
|
||||
{ complete: false, serviceId: "social-branding-service" },
|
||||
"a resume upload must not complete or reroute the LinkedIn profile task",
|
||||
);
|
||||
assert.deepEqual(
|
||||
resolveProfileTaskPolicy({ resumeAvailable: false, linkedinAvailable: true }),
|
||||
{ complete: true, serviceId: "social-branding-service" },
|
||||
"an existing LinkedIn connection completes the task on the next sprint read",
|
||||
);
|
||||
assert.deepEqual(
|
||||
resolveProfileTaskPolicy({ resumeAvailable: true, linkedinAvailable: true }),
|
||||
{ complete: true, serviceId: "social-branding-service" },
|
||||
"LinkedIn remains the completion signal when both profile assets exist",
|
||||
);
|
||||
|
||||
console.log("curator profile-task policy regression: ok");
|
||||
134
scripts/curator-service-completion-contract.test.ts
Normal file
134
scripts/curator-service-completion-contract.test.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { getCompletionEvents } from "../src/services/service-registry.js";
|
||||
import { isStreakCompletionType, isTaskCompletionEvent, planSeedsForVariant, type CuratorCompletionEvent } from "../src/v1/curator/curator-store.js";
|
||||
import { templateSetFor } from "../src/v1/curator/task-registry.js";
|
||||
import type { CuratorIcpId } from "../src/v1/curator/icp-registry.js";
|
||||
import { canonicalGrowEventType } from "../src/events/normalize.js";
|
||||
|
||||
const taskId = "curator-task-1";
|
||||
const event = (type: string, correlatedTaskId = taskId): CuratorCompletionEvent => ({
|
||||
type,
|
||||
payload: {},
|
||||
correlation: { task_id: correlatedTaskId },
|
||||
});
|
||||
|
||||
const roleplayEvents = getCompletionEvents("roleplay-service");
|
||||
assert.deepEqual(roleplayEvents, ["roleplay.scenario.completed", "roleplay.feedback.generated"]);
|
||||
assert.equal(isTaskCompletionEvent(taskId, roleplayEvents, event("roleplay.scenario.configured")), false);
|
||||
assert.equal(isTaskCompletionEvent(taskId, roleplayEvents, event("roleplay.scenario.completed")), true);
|
||||
assert.equal(isTaskCompletionEvent(taskId, roleplayEvents, event("roleplay.feedback.generated")), true);
|
||||
assert.equal(isTaskCompletionEvent(taskId, roleplayEvents, event("roleplay.scenario.completed", "other-task")), false);
|
||||
|
||||
const assertCompletionContract = (serviceId: string, accepted: string[], excluded: string[]) => {
|
||||
const completionEvents = getCompletionEvents(serviceId);
|
||||
assert.deepEqual(completionEvents, accepted);
|
||||
for (const type of accepted) {
|
||||
assert.equal(isTaskCompletionEvent(taskId, completionEvents, event(type)), true, `${serviceId} accepts ${type}`);
|
||||
assert.equal(isTaskCompletionEvent(taskId, completionEvents, event(type, "other-task")), false, `${serviceId} requires task correlation for ${type}`);
|
||||
}
|
||||
for (const type of excluded) {
|
||||
assert.equal(isTaskCompletionEvent(taskId, completionEvents, event(type)), false, `${serviceId} excludes ${type}`);
|
||||
}
|
||||
};
|
||||
|
||||
assertCompletionContract(
|
||||
"courses-service",
|
||||
["course.generated", "course.started", "course.progress.recorded", "course.completed"],
|
||||
[],
|
||||
);
|
||||
|
||||
assertCompletionContract(
|
||||
"social-branding-service",
|
||||
["brand.account.connected", "brand.post.drafted", "brand.profile.updated", "brand.post.exported", "brand.content.published"],
|
||||
["social.account.connected", "social.profile.updated", "social.post.scheduled", "social.post.published"],
|
||||
);
|
||||
|
||||
assertCompletionContract(
|
||||
"resume-service",
|
||||
["resume.uploaded", "resume.parsed", "resume.analysis.completed", "resume.updated", "resume.exported"],
|
||||
["resume.configured"],
|
||||
);
|
||||
|
||||
assertCompletionContract(
|
||||
"matchmaking-service",
|
||||
[
|
||||
"matchmaking.feed.viewed",
|
||||
"matchmaking.match.viewed",
|
||||
"matchmaking.application.started",
|
||||
"matchmaking.match.saved",
|
||||
"matchmaking.matches.reviewed",
|
||||
"matchmaking.match.applied",
|
||||
"matchmaking.application.completed",
|
||||
],
|
||||
["matchmaking.matches.generated", "matchmaking.match.dismissed"],
|
||||
);
|
||||
|
||||
assertCompletionContract(
|
||||
"qscore-service",
|
||||
["qscore.review.opened", "qscore.weak_driver.reviewed", "qscore.signal.projected", "qscore.updated"],
|
||||
["qscore.baseline.created"],
|
||||
);
|
||||
|
||||
for (const type of [
|
||||
"resume.uploaded",
|
||||
"resume.parsed",
|
||||
"course.generated",
|
||||
"course.started",
|
||||
"course.progress.recorded",
|
||||
"qscore.review.opened",
|
||||
"brand.account.connected",
|
||||
"brand.post.drafted",
|
||||
"matchmaking.feed.viewed",
|
||||
"matchmaking.match.viewed",
|
||||
"matchmaking.application.started",
|
||||
]) {
|
||||
assert.equal(isStreakCompletionType(type), true, `${type} must qualify for the broad streak activity rule`);
|
||||
}
|
||||
for (const type of ["qscore.baseline.created", "assessment.started", "matchmaking.matches.generated"]) {
|
||||
assert.equal(isStreakCompletionType(type), false, `${type} must remain non-completing`);
|
||||
}
|
||||
|
||||
assert.equal(canonicalGrowEventType("interview.session_completed"), "interview.session.completed");
|
||||
assert.equal(canonicalGrowEventType("roleplay.session_completed"), "roleplay.scenario.completed");
|
||||
|
||||
const icpIds: CuratorIcpId[] = [
|
||||
"student_recent_grad",
|
||||
"intern",
|
||||
"fresher_early_professional",
|
||||
"experienced_professional",
|
||||
"freelancer",
|
||||
"founder",
|
||||
"enterprise",
|
||||
];
|
||||
|
||||
for (const icpId of icpIds) {
|
||||
const trialDays = planSeedsForVariant(templateSetFor(icpId), "2026-07-14", 2, "trial");
|
||||
assert.equal(trialDays.length, 2, `${icpId} must expose exactly two trial days`);
|
||||
for (const day of trialDays) {
|
||||
assert.equal(day.plannedTasks.length, 4, `${icpId} day ${day.dayIndex} must expose four tasks`);
|
||||
for (const [index, task] of day.plannedTasks.entries()) {
|
||||
const completionEvents = getCompletionEvents(task.serviceId);
|
||||
const syntheticTaskId = `${icpId}:day-${day.dayIndex}:task-${index + 1}`;
|
||||
assert.ok(completionEvents.length > 0, `${syntheticTaskId} must have completion evidence`);
|
||||
assert.equal(
|
||||
isTaskCompletionEvent(syntheticTaskId, completionEvents, event(completionEvents[0]!, syntheticTaskId)),
|
||||
true,
|
||||
`${syntheticTaskId} must complete from its exact correlated service event`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const serviceId of [
|
||||
"resume-service",
|
||||
"interview-service",
|
||||
"roleplay-service",
|
||||
"courses-service",
|
||||
"social-branding-service",
|
||||
"matchmaking-service",
|
||||
"qscore-service",
|
||||
]) {
|
||||
assert.ok(getCompletionEvents(serviceId).length > 0, `${serviceId} must define completion evidence`);
|
||||
}
|
||||
|
||||
console.log("curator service completion contract tests passed");
|
||||
140
scripts/curator-static-registry.test.ts
Normal file
140
scripts/curator-static-registry.test.ts
Normal 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);
|
||||
});
|
||||
55
scripts/curator-trial-progression.test.ts
Normal file
55
scripts/curator-trial-progression.test.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
composeTrialDay2,
|
||||
curatorLogicalDate,
|
||||
resolveAgedTrialReuse,
|
||||
} from "../src/v1/curator/streak-utils.js";
|
||||
|
||||
assert.equal(
|
||||
curatorLogicalDate(new Date("2026-07-15T03:29:59.999Z")),
|
||||
"2026-07-14",
|
||||
"the previous Curator day remains active until 09:00 Asia/Kolkata",
|
||||
);
|
||||
assert.equal(
|
||||
curatorLogicalDate(new Date("2026-07-15T03:30:00.000Z")),
|
||||
"2026-07-15",
|
||||
"the next Curator day starts at 09:00 Asia/Kolkata",
|
||||
);
|
||||
assert.equal(
|
||||
curatorLogicalDate(new Date("2026-07-15T02:30:00.000Z"), 8),
|
||||
"2026-07-15",
|
||||
"deployments may explicitly configure a different business-morning hour",
|
||||
);
|
||||
const reuse = resolveAgedTrialReuse({
|
||||
existing: { startDate: "2026-07-14", version: "icp-v10-static", durationDays: 2, access: "trial" },
|
||||
desired: { durationDays: 2, access: "trial" },
|
||||
today: curatorLogicalDate(new Date("2026-07-18T00:00:00.000Z")),
|
||||
planVersion: "icp-v10-static",
|
||||
});
|
||||
assert.equal(reuse.action, "reuse");
|
||||
assert.equal(reuse.startDate, "2026-07-14");
|
||||
assert.equal(reuse.activeDayIndex, 2);
|
||||
|
||||
const fullDay7 = resolveAgedTrialReuse({
|
||||
existing: { startDate: "2026-07-14", version: "icp-v10-static", durationDays: 7, access: "full" },
|
||||
desired: { durationDays: 7, access: "full" },
|
||||
today: "2026-07-20",
|
||||
planVersion: "icp-v10-static",
|
||||
});
|
||||
assert.equal(fullDay7.action, "reuse", "a full sprint must remain on its seventh day");
|
||||
assert.equal(fullDay7.activeDayIndex, 7);
|
||||
|
||||
const fullDay8 = resolveAgedTrialReuse({
|
||||
existing: { startDate: "2026-07-14", version: "icp-v10-static", durationDays: 7, access: "full" },
|
||||
desired: { durationDays: 7, access: "full" },
|
||||
today: "2026-07-21",
|
||||
planVersion: "icp-v10-static",
|
||||
});
|
||||
assert.equal(fullDay8.action, "restart", "a full sprint may restart only after its seventh day");
|
||||
|
||||
const native = [{ id: "native-1" }, { id: "native-2" }, { id: "native-3" }, { id: "native-4" }];
|
||||
const recovery = { id: "recovery" };
|
||||
assert.deepEqual(composeTrialDay2("student_recent_grad", 0, native, recovery), [recovery, ...native.slice(0, 3)]);
|
||||
assert.deepEqual(composeTrialDay2("student_recent_grad", 1, native, recovery), native);
|
||||
|
||||
console.log("curator trial progression regression: ok");
|
||||
105
scripts/event-dedupe-routing.test.ts
Normal file
105
scripts/event-dedupe-routing.test.ts
Normal 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);
|
||||
145
scripts/event-ingress-route-integration.test.ts
Normal file
145
scripts/event-ingress-route-integration.test.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
import { normalizeGrowEvent } from "../src/events/normalize.js";
|
||||
|
||||
// Mirrors /v1/events/track's accepted body shape without requiring Postgres.
|
||||
{
|
||||
const event = normalizeGrowEvent({
|
||||
source: "interview-service",
|
||||
type: "interview.session.completed",
|
||||
correlation: { task_id: "task-nested", keep: "preserve", session_id: "session-1" },
|
||||
payload: { status: "completed" },
|
||||
});
|
||||
if (event.correlation?.task_id !== "task-nested" || event.correlation.keep !== "preserve" || event.correlation.session_id !== "session-1") {
|
||||
throw new Error("nested correlation fields were not preserved at ingress");
|
||||
}
|
||||
}
|
||||
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);
|
||||
17
scripts/event-route-timeout.test.ts
Normal file
17
scripts/event-route-timeout.test.ts
Normal 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");
|
||||
47
scripts/fixtures/onboarding-rev10.json
Normal file
47
scripts/fixtures/onboarding-rev10.json
Normal file
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"onboarding": {
|
||||
"import": {
|
||||
"method": "manual",
|
||||
"status": "not_started",
|
||||
"resume_id": null,
|
||||
"linkedin_url": null,
|
||||
"resume_summary": null,
|
||||
"resume_filename": null,
|
||||
"linkedin_profile_id": null
|
||||
},
|
||||
"status": "completed",
|
||||
"consent": {
|
||||
"accepted_at": "2026-07-12T21:31:40.189Z",
|
||||
"terms_version": "2026-07",
|
||||
"privacy_accepted": true
|
||||
},
|
||||
"profile": {
|
||||
"icp": "experienced",
|
||||
"mode": "individual",
|
||||
"intent": "grow",
|
||||
"question_branch": "professional"
|
||||
},
|
||||
"progress": {
|
||||
"stage": "reveal",
|
||||
"updated_at": "2026-07-12T21:33:01.914Z",
|
||||
"branch_index": 4
|
||||
},
|
||||
"revision": 10,
|
||||
"responses": {
|
||||
"target_role": "Senior Software Engineer",
|
||||
"target_field": "technical",
|
||||
"work_context": "clients",
|
||||
"career_barriers": ["offers"],
|
||||
"desired_outcomes": ["role"],
|
||||
"experience_level": "5+",
|
||||
"target_milestone": null,
|
||||
"venture_industry": null,
|
||||
"current_situation": "employed",
|
||||
"weekly_time_commitment": "steady"
|
||||
},
|
||||
"qx_estimate": 100,
|
||||
"completed_at": "2026-07-12T21:31:42.957Z",
|
||||
"access_choice": "trial",
|
||||
"schema_version": 3
|
||||
}
|
||||
}
|
||||
239
scripts/home-insight-snapshot.test.ts
Normal file
239
scripts/home-insight-snapshot.test.ts
Normal file
@@ -0,0 +1,239 @@
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { buildHomeInsightSnapshots } from "../src/v1/analytics/home-insight-snapshot.js";
|
||||
import type { HomeInsightProducerStatuses } from "../src/v1/analytics/home-insight-snapshot.js";
|
||||
import type { QscoreProxyResult, QscoreReadResult } from "../src/services/qscore-proxy.js";
|
||||
|
||||
function qscoreFixture(): QscoreProxyResult {
|
||||
const category = (id: string, score: number, active = true, totalRules = 2) => ({
|
||||
id,
|
||||
label: id,
|
||||
score,
|
||||
active,
|
||||
present_rules: active ? 1 : 0,
|
||||
total_rules: totalRules,
|
||||
weight_percent: 10,
|
||||
signals: [],
|
||||
});
|
||||
return {
|
||||
org_id: "growqr",
|
||||
user_id: "00000000-0000-4000-8000-000000000000",
|
||||
rq_score: 0,
|
||||
iq_score: null,
|
||||
eq_score: null,
|
||||
sq_score: null,
|
||||
profession: "",
|
||||
formula_version: "v2",
|
||||
formula_id: "fixture",
|
||||
calculated_at: "2026-07-15T00:00:00Z",
|
||||
quotients: {},
|
||||
breakdown: {
|
||||
display: {
|
||||
version: "rq-score-display-v1",
|
||||
registered_signal_count: 67,
|
||||
active_signal_count: 7,
|
||||
categories: [
|
||||
category("learning_credentials", 45),
|
||||
category("resume_presence", 55),
|
||||
category("linkedin_presence", 65),
|
||||
category("matching_activity", 75),
|
||||
category("interview_performance", 85),
|
||||
category("pathway_progress", 0, false, 0),
|
||||
category("roleplay_performance", 70),
|
||||
category("engagement_consistency", 40),
|
||||
category("goals_readiness", 35),
|
||||
],
|
||||
},
|
||||
},
|
||||
ledger_seq_from: null,
|
||||
ledger_seq_to: null,
|
||||
};
|
||||
}
|
||||
|
||||
function inactiveCategoryQscoreFixture(): QscoreProxyResult {
|
||||
const fixture = qscoreFixture();
|
||||
const display = fixture.breakdown.display as { categories: Array<Record<string, unknown>> };
|
||||
for (const category of display.categories) {
|
||||
if (category.id === "pathway_progress") continue;
|
||||
category.score = 0;
|
||||
category.active = false;
|
||||
category.present_rules = 0;
|
||||
}
|
||||
return fixture;
|
||||
}
|
||||
|
||||
const readyQscore: QscoreReadResult<QscoreProxyResult> = { status: "ready", data: qscoreFixture() };
|
||||
const history = { status: "ready" as const, data: [{ rq_score: 0, date: "2026-07-15" }] };
|
||||
|
||||
const producers = {
|
||||
interviewPageState: async () => ({ recent_sessions: [{ overall_score: 0, completed_at: "2026-07-15T01:00:00Z" }] }),
|
||||
roleplayPageState: async () => ({ recent_sessions: [{ overall_score: 0, completed_at: "2026-07-15T02:00:00Z" }] }),
|
||||
resumeState: async () => ({ resume_count: 1, resume_completeness: 0, resume_status: "draft" }),
|
||||
socialState: async () => ({
|
||||
linkedin_connected: true,
|
||||
profile_analysis_score: 0,
|
||||
followers_count: 0,
|
||||
connections_count: 0,
|
||||
skills_count: 0,
|
||||
experience_count: 0,
|
||||
improvements_count: 0,
|
||||
section_scores: {},
|
||||
}),
|
||||
matchmakingStats: async () => ({
|
||||
status: "completed",
|
||||
messages: [{ action: "scout_stats", data: { matchesFound: 0, funnel: [], searches: 0, tailoredResumes: 0 } }],
|
||||
}),
|
||||
courseSummary: async () => ({
|
||||
status: "completed",
|
||||
messages: [{
|
||||
action: "course_state_loaded",
|
||||
data: { has_interest_profile: true, watch_count: 0, watch_stats: { watched: 0, total_s: 0, anchors: [] } },
|
||||
}],
|
||||
}),
|
||||
assessments: async () => ({ items: [], total: 0 }),
|
||||
};
|
||||
|
||||
const genuineZeros = await buildHomeInsightSnapshots({
|
||||
userId: "user_fixture",
|
||||
qscore: readyQscore,
|
||||
qscoreHistory: history,
|
||||
recentEvents: [{ payload: { result: { weakness_tags: ["SQL", "sql", "System design"] } } }],
|
||||
}, producers);
|
||||
|
||||
assert.deepEqual(Object.keys(genuineZeros), [
|
||||
"rqScore",
|
||||
"interview",
|
||||
"resume",
|
||||
"opportunity",
|
||||
"pathway",
|
||||
"socialBrand",
|
||||
"roleplay",
|
||||
"learning",
|
||||
"skillGap",
|
||||
]);
|
||||
for (const value of Object.values(genuineZeros)) {
|
||||
assert.deepEqual(Object.keys(value), ["status", "source", "value", "unit", "label", "updatedAt", "reason", "detail"]);
|
||||
}
|
||||
|
||||
assert.deepEqual(
|
||||
{
|
||||
rq: [genuineZeros.rqScore.status, genuineZeros.rqScore.value],
|
||||
interview: [genuineZeros.interview.status, genuineZeros.interview.value],
|
||||
resume: [genuineZeros.resume.status, genuineZeros.resume.value],
|
||||
opportunity: [genuineZeros.opportunity.status, genuineZeros.opportunity.value],
|
||||
social: [genuineZeros.socialBrand.status, genuineZeros.socialBrand.value],
|
||||
roleplay: [genuineZeros.roleplay.status, genuineZeros.roleplay.value],
|
||||
learning: [genuineZeros.learning.status, genuineZeros.learning.value],
|
||||
skillGap: [genuineZeros.skillGap.status, genuineZeros.skillGap.value],
|
||||
pathway: [genuineZeros.pathway.status, genuineZeros.pathway.value],
|
||||
},
|
||||
{
|
||||
rq: ["ready", 0],
|
||||
interview: ["ready", 0],
|
||||
resume: ["ready", 0],
|
||||
opportunity: ["empty", 0],
|
||||
social: ["ready", 0],
|
||||
roleplay: ["ready", 0],
|
||||
learning: ["empty", 0],
|
||||
skillGap: ["ready", 2],
|
||||
pathway: ["unsupported", null],
|
||||
},
|
||||
"genuine zero, confirmed empty, and unsupported must remain distinct",
|
||||
);
|
||||
assert.deepEqual(genuineZeros.socialBrand.detail?.audienceReach, {
|
||||
value: 0,
|
||||
unit: "followers",
|
||||
label: "Audience reach",
|
||||
updatedAt: null,
|
||||
}, "Audience Reach must be an explicit producer-owned display object and preserve genuine zero");
|
||||
|
||||
const failed = async () => { throw new Error("unreachable"); };
|
||||
const unavailable = await buildHomeInsightSnapshots({
|
||||
userId: "user_fixture",
|
||||
qscore: { status: "unavailable", reason: "request_failed" },
|
||||
qscoreHistory: { status: "unavailable", reason: "request_failed" },
|
||||
recentEvents: [],
|
||||
}, {
|
||||
interviewPageState: failed,
|
||||
roleplayPageState: failed,
|
||||
resumeState: failed,
|
||||
socialState: failed,
|
||||
matchmakingStats: failed,
|
||||
courseSummary: failed,
|
||||
assessments: failed,
|
||||
});
|
||||
|
||||
for (const [key, value] of Object.entries(unavailable)) {
|
||||
assert.equal(value.status, "unavailable", `${key} outage must be unavailable`);
|
||||
assert.equal(value.value, null, `${key} outage must not become zero`);
|
||||
}
|
||||
|
||||
const inactiveQscore: QscoreReadResult<QscoreProxyResult> = {
|
||||
status: "ready",
|
||||
data: inactiveCategoryQscoreFixture(),
|
||||
};
|
||||
const primaryOutageWithNoQscoreEvidence = await buildHomeInsightSnapshots({
|
||||
userId: "user_fixture",
|
||||
qscore: inactiveQscore,
|
||||
qscoreHistory: history,
|
||||
recentEvents: [],
|
||||
}, {
|
||||
interviewPageState: failed,
|
||||
roleplayPageState: failed,
|
||||
resumeState: failed,
|
||||
socialState: failed,
|
||||
matchmakingStats: failed,
|
||||
courseSummary: failed,
|
||||
assessments: failed,
|
||||
});
|
||||
|
||||
for (const key of ["interview", "resume", "opportunity", "socialBrand", "roleplay", "learning", "skillGap"] as const) {
|
||||
assert.equal(primaryOutageWithNoQscoreEvidence[key].status, "unavailable", `${key} outage must not be masked by inactive qscore evidence`);
|
||||
assert.equal(primaryOutageWithNoQscoreEvidence[key].value, null, `${key} outage with inactive qscore must not become zero`);
|
||||
}
|
||||
|
||||
const malformedCourseA2a = await buildHomeInsightSnapshots({
|
||||
userId: "user_fixture",
|
||||
qscore: inactiveQscore,
|
||||
qscoreHistory: history,
|
||||
recentEvents: [],
|
||||
}, {
|
||||
...producers,
|
||||
courseSummary: async () => ({ status: "completed", messages: [] }),
|
||||
});
|
||||
assert.equal(malformedCourseA2a.learning.status, "unavailable");
|
||||
assert.equal(malformedCourseA2a.learning.value, null);
|
||||
assert.equal(malformedCourseA2a.learning.reason, "producer_response_invalid");
|
||||
assert.equal(malformedCourseA2a.learning.source, "courses.course-state");
|
||||
|
||||
const malformedA2a = await buildHomeInsightSnapshots({
|
||||
userId: "user_fixture",
|
||||
qscore: readyQscore,
|
||||
qscoreHistory: history,
|
||||
recentEvents: [],
|
||||
}, {
|
||||
...producers,
|
||||
matchmakingStats: async () => ({ status: "completed", messages: [] }),
|
||||
});
|
||||
assert.equal(malformedA2a.opportunity.status, "ready", "valid qscore category is the honest fallback");
|
||||
assert.equal(malformedA2a.opportunity.source, "qscore.breakdown.display");
|
||||
assert.equal(malformedA2a.opportunity.value, 75);
|
||||
|
||||
let malformedDirectStatuses: HomeInsightProducerStatuses | null = null;
|
||||
await buildHomeInsightSnapshots({
|
||||
userId: "user_fixture",
|
||||
qscore: readyQscore,
|
||||
qscoreHistory: history,
|
||||
recentEvents: [],
|
||||
onProducerStatuses: (statuses) => { malformedDirectStatuses = statuses; },
|
||||
}, {
|
||||
...producers,
|
||||
resumeState: async () => ({}),
|
||||
assessments: async () => ({}),
|
||||
});
|
||||
assert.equal(malformedDirectStatuses?.resume.status, "unavailable", "malformed resume objects must not mark the direct source ready");
|
||||
assert.equal(malformedDirectStatuses?.resume.reason, "producer_response_invalid");
|
||||
assert.equal(malformedDirectStatuses?.assessment.status, "unavailable", "malformed assessment objects must not mark the direct source ready");
|
||||
assert.equal(malformedDirectStatuses?.assessment.reason, "producer_response_invalid");
|
||||
|
||||
console.log("home insight snapshot contract: ok");
|
||||
31
scripts/interview-user-identity.test.ts
Normal file
31
scripts/interview-user-identity.test.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { interviewService } from "../src/services/product-service-clients.js";
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
const requests: Array<{ url: string; headers: Headers; body: string }> = [];
|
||||
|
||||
globalThis.fetch = async (input, init) => {
|
||||
const request = new Request(input, init);
|
||||
requests.push({
|
||||
url: request.url,
|
||||
headers: request.headers,
|
||||
body: await request.text(),
|
||||
});
|
||||
return new Response("{}", { status: 200, headers: { "content-type": "application/json" } });
|
||||
};
|
||||
|
||||
try {
|
||||
await interviewService.configure({ user_id: "user_from_payload" }, "user_canonical");
|
||||
await interviewService.preview({ user_id: "user_from_payload" }, "user_canonical");
|
||||
await interviewService.editQuestions({ session_id: "session-1", questions: ["q1"] }, "user_canonical");
|
||||
await interviewService.approve("session-1", "user_canonical");
|
||||
|
||||
assert.equal(requests.length, 4);
|
||||
for (const request of requests) {
|
||||
assert.equal(request.headers.get("x-growqr-user"), "user_canonical");
|
||||
assert.match(request.headers.get("authorization") ?? "", /^Bearer /);
|
||||
}
|
||||
console.log("interview user identity forwarding: all assertions passed");
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
53
scripts/matchmaking-events.test.ts
Normal file
53
scripts/matchmaking-events.test.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { buildMatchmakingGatewayEvent } from "../src/services/matchmaking-events.js";
|
||||
|
||||
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 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");
|
||||
|
||||
console.log("matchmaking-events tests passed");
|
||||
process.exit(0);
|
||||
46
scripts/mission-lifecycle.test.ts
Normal file
46
scripts/mission-lifecycle.test.ts
Normal 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
194
scripts/onboarding-bulk-reset.ts
Executable 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);
|
||||
});
|
||||
114
scripts/onboarding-ledger.test.ts
Normal file
114
scripts/onboarding-ledger.test.ts
Normal 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);
|
||||
160
scripts/onboarding-preferences.test.ts
Normal file
160
scripts/onboarding-preferences.test.ts
Normal 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");
|
||||
176
scripts/onboarding-reset.test.ts
Normal file
176
scripts/onboarding-reset.test.ts
Normal 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);
|
||||
94
scripts/onboarding-rev10-read.test.ts
Normal file
94
scripts/onboarding-rev10-read.test.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { serializeOnboardingState } from "../src/services/onboarding-serialization.js";
|
||||
|
||||
type StoredFixture = { onboarding: Record<string, unknown> };
|
||||
const fixturePath = fileURLToPath(new URL("./fixtures/onboarding-rev10.json", import.meta.url));
|
||||
const fixture = JSON.parse(readFileSync(fixturePath, "utf8")) as StoredFixture;
|
||||
const response = serializeOnboardingState("user_3EX1LG3gBk3KY6kfD9PiTkWubs4", fixture.onboarding);
|
||||
|
||||
assert.equal(response.id, "user_3EX1LG3gBk3KY6kfD9PiTkWubs4");
|
||||
assert.equal(response.revision, 10);
|
||||
assert.equal(response.data.revision, 10);
|
||||
assert.equal(response.data.status, "completed");
|
||||
assert.equal(response.data.profile.icp, "experienced");
|
||||
assert.equal(response.data.access_choice, "trial");
|
||||
assert.equal(response.data.completed_at, "2026-07-12T21:31:42.957Z");
|
||||
assert.equal(response.updatedAt, "2026-07-12T21:33:01.914Z");
|
||||
assert.deepEqual(response.payload, {
|
||||
schema_version: 1,
|
||||
source_revision: 10,
|
||||
primary_intent: "grow",
|
||||
mode: "individual",
|
||||
question_branch: "professional",
|
||||
onboarding_icp: "experienced",
|
||||
curator_registry_icp: null,
|
||||
target_role: "Senior Software Engineer",
|
||||
target_field: "technical",
|
||||
experience_context: "5+ · clients",
|
||||
goals: ["role"],
|
||||
barriers: ["offers"],
|
||||
priority: "role",
|
||||
weekly_time_commitment: "steady",
|
||||
});
|
||||
|
||||
const serialized = JSON.parse(JSON.stringify(response));
|
||||
assert.deepEqual(serialized, response, "GET response must be JSON serializable without losing rev10 state");
|
||||
|
||||
const compose = readFileSync(fileURLToPath(new URL("../docker-compose.override.yml", import.meta.url)), "utf8");
|
||||
assert.match(
|
||||
compose,
|
||||
/user-service-staging:\s+external:\s+true\s+name:\s+user-service-staging_default/s,
|
||||
"backend must join the canonical user-service staging network",
|
||||
);
|
||||
assert.doesNotMatch(compose, /name:\s+growqr-app_growqr-net/, "obsolete substitute user network must not be configured");
|
||||
assert.match(compose, /RESUME_SERVICE_URL:\s+http:\/\/growqr_resume_api:8000/, "backend must route Resume through canonical Docker DNS");
|
||||
for (const modelKey of ["LLM_MODEL", "GROW_AGENT_MODEL", "CONVERSATION_ACTOR_MODEL"]) {
|
||||
assert.match(compose, new RegExp(`${modelKey}:\\s+kimi-k2\\.6`), `${modelKey} must stay on the verified staging model`);
|
||||
}
|
||||
assert.doesNotMatch(compose, /minimax-m3/, "staging compose must not restore the broken MiniMax model pin");
|
||||
|
||||
const canonicalServiceNetworks = [
|
||||
["interview-service", "interview-service_default"],
|
||||
["roleplay-service", "roleplay-service_default"],
|
||||
["resume-builder-staging", "resume-builder-staging_default"],
|
||||
["course-service", "course-service_default"],
|
||||
["qscore-service-staging", "qscore-service-staging_default"],
|
||||
["matchmaking-v2-staging", "matchmaking-v2_default"],
|
||||
["user-service-staging", "user-service-staging_default"],
|
||||
] as const;
|
||||
|
||||
for (const [key, name] of canonicalServiceNetworks) {
|
||||
assert.match(
|
||||
compose,
|
||||
new RegExp(`${key}:\\s+external:\\s+true\\s+name:\\s+${name.replace(/[.*+?^${}()|[\\]\\]/g, "\\$&")}`, "s"),
|
||||
`backend must join canonical network ${name}`,
|
||||
);
|
||||
}
|
||||
|
||||
for (const obsoleteName of [
|
||||
"resume-builder_default",
|
||||
"courses_service_default",
|
||||
"qscore-service_default",
|
||||
"matchmaking-service_default",
|
||||
"growqr-app_growqr-net",
|
||||
]) {
|
||||
assert.doesNotMatch(compose, new RegExp(`name:\\s+${obsoleteName}`), `obsolete substitute network ${obsoleteName} must not be configured`);
|
||||
}
|
||||
|
||||
|
||||
const stagingCompose = readFileSync(fileURLToPath(new URL("./staging-compose.sh", import.meta.url)), "utf8");
|
||||
assert.match(stagingCompose, /PROJECT=growqr-backend-staging/, "staging wrapper must pin the compose project name");
|
||||
for (const [, name] of canonicalServiceNetworks) {
|
||||
assert.match(stagingCompose, new RegExp(name.replace(/[.*+?^${}()|[\\]\\]/g, "\\$&")), `staging wrapper must verify ${name}`);
|
||||
}
|
||||
|
||||
const packageJson = JSON.parse(readFileSync(fileURLToPath(new URL("../package.json", import.meta.url)), "utf8")) as {
|
||||
scripts?: Record<string, string>;
|
||||
};
|
||||
assert.equal(packageJson.scripts?.["compose:up"], "scripts/staging-compose.sh up");
|
||||
assert.equal(packageJson.scripts?.["compose:down"], "scripts/staging-compose.sh down");
|
||||
assert.equal(packageJson.scripts?.["staging:preflight"], "scripts/staging-compose.sh preflight");
|
||||
assert.equal(packageJson.scripts?.["staging:verify"], "scripts/staging-compose.sh verify");
|
||||
console.log("onboarding-rev10-read: fixture serialization and staging network assertions passed");
|
||||
208
scripts/passive-actions.test.ts
Normal file
208
scripts/passive-actions.test.ts
Normal 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);
|
||||
95
scripts/qscore-raw-event-contract.test.ts
Normal file
95
scripts/qscore-raw-event-contract.test.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { canonicalGrowEventEnvelope, correlateEventWithServiceSession } from "../src/events/record-grow-event.js";
|
||||
import { normalizeGrowEvent } from "../src/events/normalize.js";
|
||||
import {
|
||||
projectedSignalsFromQscoreResponse,
|
||||
QSCORE_GROW_EVENT_DESTINATION,
|
||||
QSCORE_GROW_EVENT_TOPIC,
|
||||
} from "../src/events/qscore-event-outbox.js";
|
||||
|
||||
const input = {
|
||||
id: "evt-real-1",
|
||||
userId: "untrusted-body-user",
|
||||
orgId: "growqr",
|
||||
source: "interview-service",
|
||||
type: "interview.review.completed",
|
||||
category: "service" as const,
|
||||
occurredAt: "2026-07-14T10:00:00.000Z",
|
||||
mission: { instanceId: "mission-1", missionId: "interview-to-offer", stageId: "mock-interview" },
|
||||
subject: { serviceId: "interview", externalId: "session-1" },
|
||||
correlation: { sessionId: "session-1", taskId: "task-1" },
|
||||
payload: {
|
||||
review: {
|
||||
overall_score: 78,
|
||||
rubric_scores: { communication: 82, technical_accuracy: 70 },
|
||||
},
|
||||
},
|
||||
dedupeKey: "interview:session-1:review",
|
||||
};
|
||||
|
||||
const normalized = normalizeGrowEvent(input, { userId: "clerk-user-real" });
|
||||
assert.equal(
|
||||
normalized.type,
|
||||
"interview.feedback.generated",
|
||||
"backend-only consumers may retain staging's legacy event alias",
|
||||
);
|
||||
const canonical = canonicalGrowEventEnvelope(normalized, "clerk-user-real");
|
||||
assert.deepEqual(canonical, {
|
||||
...input,
|
||||
userId: "clerk-user-real",
|
||||
raw: input,
|
||||
});
|
||||
const sessionOnlyInput = { ...input, correlation: { sessionId: "session-1" } };
|
||||
const sessionOnlyNormalized = normalizeGrowEvent(sessionOnlyInput, { userId: "clerk-user-real" });
|
||||
const correlated = correlateEventWithServiceSession(sessionOnlyNormalized, "clerk-user-real", {
|
||||
userId: "clerk-user-real",
|
||||
missionInstanceId: "mission-1",
|
||||
missionId: "interview-to-offer",
|
||||
stageId: "mock-interview",
|
||||
metadata: { task_id: "task-derived" },
|
||||
});
|
||||
const enrichedCanonical = canonicalGrowEventEnvelope(correlated, "clerk-user-real");
|
||||
assert.deepEqual(enrichedCanonical.correlation, {
|
||||
sessionId: "session-1",
|
||||
task_id: "task-derived",
|
||||
missionInstanceId: "mission-1",
|
||||
missionId: "interview-to-offer",
|
||||
stageId: "mock-interview",
|
||||
});
|
||||
assert.deepEqual(enrichedCanonical.raw, sessionOnlyInput, "raw producer correlation must remain unchanged");
|
||||
assert.equal(QSCORE_GROW_EVENT_TOPIC, "qscore.grow_event.raw");
|
||||
assert.equal(QSCORE_GROW_EVENT_DESTINATION, "/v1/events/ingest");
|
||||
|
||||
const projected = projectedSignalsFromQscoreResponse({
|
||||
projected_signals: [
|
||||
{ signal_id: "interview.overall_score", score: 45, present: true, evidence: { eventId: input.id } },
|
||||
{ signal_id: "interview.sessions_completed", evidence: { eventId: input.id } },
|
||||
],
|
||||
});
|
||||
assert.deepEqual(projected, [
|
||||
{ signalId: "interview.overall_score", score: 45, present: true, raw: { eventId: input.id } },
|
||||
{ signalId: "interview.sessions_completed", raw: { eventId: input.id } },
|
||||
]);
|
||||
|
||||
const projectorSource = await readFile(new URL("../src/events/projectors/qscore-projector.ts", import.meta.url), "utf8");
|
||||
for (const forbidden of ["clampScore", "BASELINE_SCORE", "extractResumeSignals", "forwardSignalsToQscoreService"]) {
|
||||
assert.equal(projectorSource.includes(forbidden), false, `projector must not contain ${forbidden}`);
|
||||
}
|
||||
|
||||
const serviceAgentSource = await readFile(new URL("../src/services/service-agents.ts", import.meta.url), "utf8");
|
||||
assert.equal(/q_score\s*:\s*\d/.test(serviceAgentSource), false, "service agents must not inject numeric QScore data");
|
||||
|
||||
const homeSource = await readFile(new URL("../src/home/home-feed.ts", import.meta.url), "utf8");
|
||||
for (const forbidden of ["|| 47", "score - 29", "Math.max(35", "ensureOnboardingBaselineQscore"]) {
|
||||
assert.equal(homeSource.includes(forbidden), false, `Home must not contain synthetic QScore fallback ${forbidden}`);
|
||||
}
|
||||
|
||||
const servicesSource = await readFile(new URL("../src/routes/services.ts", import.meta.url), "utf8");
|
||||
assert.equal(
|
||||
/app\.get\("\/qscore\/current"[\s\S]*?qscore\.review\.opened/.test(servicesSource),
|
||||
false,
|
||||
"polling the current QScore must remain a read and must not emit a synthetic review event",
|
||||
);
|
||||
|
||||
console.log("qscore raw-event ownership contract: ok");
|
||||
112
scripts/redis-gating.test.ts
Normal file
112
scripts/redis-gating.test.ts
Normal 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);
|
||||
37
scripts/resume-gateway.test.ts
Normal file
37
scripts/resume-gateway.test.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { serviceRoutes } from "../src/routes/services.js";
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
const requests: Request[] = [];
|
||||
|
||||
globalThis.fetch = (async (input, init) => {
|
||||
const request = new Request(input, init);
|
||||
requests.push(request);
|
||||
const path = new URL(request.url).pathname;
|
||||
if (request.method === "DELETE" && path.endsWith("/api/v1/resumes/resume-1")) {
|
||||
return new Response(null, { status: 204 });
|
||||
}
|
||||
return new Response("<html>preview</html>", {
|
||||
status: 200,
|
||||
headers: { "content-type": "text/html; charset=utf-8" },
|
||||
});
|
||||
}) as typeof fetch;
|
||||
|
||||
const app = serviceRoutes({ skipAuth: true });
|
||||
try {
|
||||
const preview = await app.request("http://backend.test/resume/export/resumes/resume-1/preview");
|
||||
assert.equal(preview.status, 200);
|
||||
assert.equal(await preview.text(), "<html>preview</html>");
|
||||
assert.equal(new URL(requests[0]!.url).pathname, "/api/v1/export/resumes/resume-1/preview");
|
||||
assert.equal(requests[0]!.headers.get("x-growqr-user"), "user_test");
|
||||
assert.match(requests[0]!.headers.get("authorization") ?? "", /^Bearer /);
|
||||
|
||||
const deleted = await app.request("http://backend.test/resume/resumes/resume-1", { method: "DELETE" });
|
||||
assert.equal(deleted.status, 204);
|
||||
assert.equal(await deleted.text(), "");
|
||||
assert.equal(new URL(requests[1]!.url).pathname, "/api/v1/resumes/resume-1");
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
|
||||
console.log("resume gateway proxy tests passed");
|
||||
30
scripts/resume-user-identity.test.ts
Normal file
30
scripts/resume-user-identity.test.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { resumeService } from "../src/services/product-service-clients.js";
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
const requests: Array<{ url: string; headers: Headers; body: string }> = [];
|
||||
|
||||
globalThis.fetch = async (input, init) => {
|
||||
const request = new Request(input, init);
|
||||
requests.push({
|
||||
url: request.url,
|
||||
headers: request.headers,
|
||||
body: await request.text(),
|
||||
});
|
||||
return new Response("{}", { status: 200, headers: { "content-type": "application/json" } });
|
||||
};
|
||||
|
||||
try {
|
||||
await resumeService.state("user_canonical");
|
||||
await resumeService.listResumes("user_canonical");
|
||||
await resumeService.createResume({ user_id: "user_from_payload" }, "user_canonical");
|
||||
|
||||
assert.equal(requests.length, 3);
|
||||
for (const request of requests) {
|
||||
assert.equal(request.headers.get("x-growqr-user"), "user_canonical");
|
||||
assert.match(request.headers.get("authorization") ?? "", /^Bearer /);
|
||||
}
|
||||
console.log("resume user identity forwarding: all assertions passed");
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
58
scripts/roleplay-drafts.test.ts
Normal file
58
scripts/roleplay-drafts.test.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { serviceRoutes } from "../src/routes/services.js";
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
const requests: Request[] = [];
|
||||
|
||||
globalThis.fetch = (async (input, init) => {
|
||||
const request = new Request(input, init);
|
||||
requests.push(request);
|
||||
const url = new URL(request.url);
|
||||
const body = url.pathname.endsWith("/drafts")
|
||||
? { drafts: [{ session_id: "draft-1" }] }
|
||||
: url.pathname.endsWith("/plan")
|
||||
? { session_id: "draft-1", status: "draft" }
|
||||
: url.pathname.endsWith("/configure/approve")
|
||||
? { session_id: "draft-1", status: "configured", approved: true }
|
||||
: { deleted: true, session_id: "draft-1" };
|
||||
return new Response(JSON.stringify(body), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}) as typeof fetch;
|
||||
|
||||
const app = serviceRoutes({ skipAuth: true });
|
||||
try {
|
||||
const drafts = await app.request("http://backend.test/roleplay/drafts?limit=25");
|
||||
assert.equal(drafts.status, 200);
|
||||
assert.deepEqual(await drafts.json(), { drafts: [{ session_id: "draft-1" }] });
|
||||
|
||||
const plan = await app.request("http://backend.test/roleplay/plan/draft-1");
|
||||
assert.equal(plan.status, 200);
|
||||
assert.deepEqual(await plan.json(), { session_id: "draft-1", status: "draft" });
|
||||
const approved = await app.request("http://backend.test/roleplay/approve", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ session_id: "draft-1" }),
|
||||
});
|
||||
assert.equal(approved.status, 200);
|
||||
assert.deepEqual(await approved.json(), { session_id: "draft-1", status: "configured", approved: true });
|
||||
|
||||
const deleted = await app.request("http://backend.test/roleplay/drafts/draft-1", { method: "DELETE" });
|
||||
assert.equal(deleted.status, 200);
|
||||
assert.deepEqual(await deleted.json(), { deleted: true, session_id: "draft-1" });
|
||||
|
||||
assert.equal(requests.length, 4);
|
||||
assert.match(requests[0]!.url, /\/api\/v1\/roleplays\/drafts\?user_id=user_test&limit=25$/);
|
||||
assert.match(requests[1]!.url, /\/api\/v1\/roleplays\/sessions\/draft-1\/plan$/);
|
||||
assert.match(requests[2]!.url, /\/api\/v1\/roleplays\/configure\/approve$/);
|
||||
assert.equal(requests[3]!.method, "DELETE");
|
||||
assert.match(requests[3]!.url, /\/api\/v1\/roleplays\/drafts\/draft-1$/);
|
||||
for (const request of requests) {
|
||||
assert.equal(request.headers.get("x-growqr-user"), "user_test");
|
||||
assert.match(request.headers.get("authorization") ?? "", /^Bearer /);
|
||||
}
|
||||
console.log("roleplay draft gateway: all assertions passed");
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
101
scripts/roleplay-qscore-identity.test.ts
Normal file
101
scripts/roleplay-qscore-identity.test.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { buildPersonalizedRoleplayConfigurePayload } from "../src/routes/services.js";
|
||||
import { toQscoreUserId } from "../src/services/qscore-proxy.js";
|
||||
|
||||
const clerkUserId = "user_3GTcs6PBFcUrXUP6bH6U8dcwXBq";
|
||||
const originalFetch = globalThis.fetch;
|
||||
const requests: Request[] = [];
|
||||
|
||||
globalThis.fetch = (async (input, init) => {
|
||||
const request = new Request(input, init);
|
||||
requests.push(request);
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
org_id: "growqr",
|
||||
user_id: toQscoreUserId(clerkUserId),
|
||||
rq_score: 61,
|
||||
iq_score: 60,
|
||||
eq_score: 62,
|
||||
sq_score: 61,
|
||||
profession: "General",
|
||||
formula_version: "v1",
|
||||
formula_id: "default",
|
||||
calculated_at: "2026-07-14T00:00:00Z",
|
||||
quotients: {},
|
||||
breakdown: {},
|
||||
ledger_seq_from: null,
|
||||
ledger_seq_to: null,
|
||||
}),
|
||||
{ status: 200, headers: { "content-type": "application/json" } },
|
||||
);
|
||||
}) as typeof fetch;
|
||||
|
||||
try {
|
||||
const payload = await buildPersonalizedRoleplayConfigurePayload(
|
||||
new Request("http://backend.test/roleplay/preview"),
|
||||
{
|
||||
scenario_type: "salary-negotiation",
|
||||
metadata: { difficulty: "medium" },
|
||||
},
|
||||
clerkUserId,
|
||||
{
|
||||
resolveUserContext: async () => ({ clerk_id: clerkUserId }),
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(requests.length, 1);
|
||||
const qscoreUrl = new URL(requests[0]!.url);
|
||||
assert.equal(qscoreUrl.pathname, `/v1/qscore/${toQscoreUserId(clerkUserId)}`);
|
||||
assert.equal(qscoreUrl.searchParams.get("org_id"), "growqr");
|
||||
assert.equal((payload.qscore as Record<string, unknown>).rq_score, 61);
|
||||
assert.equal(payload.user_id, clerkUserId);
|
||||
|
||||
requests.length = 0;
|
||||
const suppliedPayload = await buildPersonalizedRoleplayConfigurePayload(
|
||||
new Request("http://backend.test/roleplay/preview"),
|
||||
{
|
||||
qscore: { rq_score: 74, source: "caller" },
|
||||
metadata: {},
|
||||
},
|
||||
clerkUserId,
|
||||
{
|
||||
resolveUserContext: async () => ({
|
||||
clerk_id: clerkUserId,
|
||||
qscore: { rq_score: 68, source: "context" },
|
||||
}),
|
||||
},
|
||||
);
|
||||
assert.equal(requests.length, 0);
|
||||
assert.deepEqual(suppliedPayload.qscore, { rq_score: 74, source: "caller" });
|
||||
|
||||
const curatorTaskId = "curator-sprint:icp-v10-static:2026-07-14:day-2:practice:roleplay";
|
||||
const correlatedPayload = await buildPersonalizedRoleplayConfigurePayload(
|
||||
new Request("http://backend.test/roleplay/preview"),
|
||||
{
|
||||
qscore: { rq_score: 74 },
|
||||
metadata: {},
|
||||
curatorTaskId,
|
||||
taskId: curatorTaskId,
|
||||
mission: {
|
||||
source: "curator-v1",
|
||||
missionId: "curator-sprint",
|
||||
missionInstanceId: "curator-sprint:icp-v10-static:2026-07-14",
|
||||
stageId: "wk-1:day-2:practice:roleplay",
|
||||
},
|
||||
},
|
||||
clerkUserId,
|
||||
{ resolveUserContext: async () => ({ clerk_id: clerkUserId }) },
|
||||
);
|
||||
assert.deepEqual(correlatedPayload.mission, {
|
||||
source: "curator-v1",
|
||||
missionId: "curator-sprint",
|
||||
missionInstanceId: "curator-sprint:icp-v10-static:2026-07-14",
|
||||
stageId: "wk-1:day-2:practice:roleplay",
|
||||
});
|
||||
assert.equal(correlatedPayload.curatorTaskId, curatorTaskId);
|
||||
assert.equal(correlatedPayload.taskId, curatorTaskId);
|
||||
|
||||
console.log("roleplay QScore identity: all assertions passed");
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
23
scripts/rq-score-contract.test.ts
Normal file
23
scripts/rq-score-contract.test.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
|
||||
const [proxy, latestRoute, servicesRoute, schema, migration, homeTypes] = await Promise.all([
|
||||
readFile(new URL("../src/services/qscore-proxy.ts", import.meta.url), "utf8"),
|
||||
readFile(new URL("../src/v1/qscore/qscore-routes.ts", import.meta.url), "utf8"),
|
||||
readFile(new URL("../src/routes/services.ts", import.meta.url), "utf8"),
|
||||
readFile(new URL("../src/db/schema.ts", import.meta.url), "utf8"),
|
||||
readFile(new URL("../drizzle/0016_rq_score_snapshot.sql", import.meta.url), "utf8"),
|
||||
readFile(new URL("../src/home/types.ts", import.meta.url), "utf8"),
|
||||
]);
|
||||
|
||||
assert.match(proxy, /const rqScore = body\.rq_score;/, "service proxy must require rq_score");
|
||||
assert.doesNotMatch(proxy, /body\.q_score/, "service proxy must not accept the old q_score field");
|
||||
assert.match(latestRoute, /rq_score: result\.rq_score/, "latest API must expose rq_score");
|
||||
assert.doesNotMatch(latestRoute, /\bscore: result\.rq_score/, "latest API must not alias RQ Score as score");
|
||||
assert.match(servicesRoute, /rq_score: rqScore/, "gateway API must expose rq_score");
|
||||
assert.match(schema, /rqScore: integer\("rq_score"\)/, "snapshot schema must map rq_score");
|
||||
assert.match(migration, /RENAME COLUMN "score" TO "rq_score"/, "migration must preserve snapshot data while renaming the column");
|
||||
assert.match(homeTypes, /rqScore: \{ from:/, "home API must expose rqScore");
|
||||
assert.doesNotMatch(homeTypes, /\bqx:/, "home API must not expose the old qx field");
|
||||
|
||||
console.log("rq score API and database contract: ok");
|
||||
30
scripts/service-ingest-projector.test.ts
Normal file
30
scripts/service-ingest-projector.test.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { canonicalGrowEventEnvelope } from "../src/events/record-grow-event.js";
|
||||
import { normalizeGrowEvent } from "../src/events/normalize.js";
|
||||
|
||||
/** Every service family is forwarded as a canonical event with unchanged real
|
||||
* payload. Extraction and signal validation now run only in QScore. */
|
||||
const cases = [
|
||||
["resume-builder", "resume.analysis.completed", { analysis: { score_breakdown: [{ category: "ATS Compatibility", score: 75 }] } }],
|
||||
["qscore-service", "qscore.signal.projected", { score: 65 }],
|
||||
["social-branding-service", "brand.profile.updated", { profile: { headline: "Real headline" } }],
|
||||
["unknown-service", "service.completed", {}],
|
||||
["matchmaking-v2", "matchmaking.match.applied", { applications_submitted: 3 }],
|
||||
] as const;
|
||||
|
||||
for (const [source, type, payload] of cases) {
|
||||
const canonical = canonicalGrowEventEnvelope(normalizeGrowEvent({
|
||||
id: `event-${type}`,
|
||||
userId: "real-user",
|
||||
source,
|
||||
type,
|
||||
category: "service",
|
||||
occurredAt: "2026-07-14T00:00:00.000Z",
|
||||
payload,
|
||||
}), "real-user");
|
||||
assert.equal(canonical.source, source);
|
||||
assert.equal(canonical.type, type);
|
||||
assert.deepEqual(canonical.payload, payload);
|
||||
}
|
||||
|
||||
console.log("service raw-ingest forwarding tests passed");
|
||||
143
scripts/service-registry-acceptance.mjs
Executable file
143
scripts/service-registry-acceptance.mjs
Executable 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) }));
|
||||
148
scripts/service-registry-content-quality.mjs
Executable file
148
scripts/service-registry-content-quality.mjs
Executable 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 }));
|
||||
216
scripts/service-registry-smoke.mjs
Normal file
216
scripts/service-registry-smoke.mjs
Normal 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 }));
|
||||
236
scripts/service-registry-write-flow.mjs
Executable file
236
scripts/service-registry-write-flow.mjs
Executable 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 }));
|
||||
116
scripts/service-session-correlation.test.ts
Normal file
116
scripts/service-session-correlation.test.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { correlateEventWithServiceSession, normalizeServiceId, resolveUniqueSessionUserId, serviceEventExternalIds } from "../src/events/record-grow-event.js";
|
||||
import { canonicalGrowEventType, normalizeGrowEvent } from "../src/events/normalize.js";
|
||||
import type { GrowEventEnvelope } from "../src/events/envelope.js";
|
||||
|
||||
{
|
||||
const normalized = normalizeGrowEvent({
|
||||
source: "interview-service",
|
||||
type: "interview.session.completed",
|
||||
correlation: { session_id: "session-1", taskId: "nested-task", keep: "yes" },
|
||||
task_id: "top-level-task",
|
||||
payload: { curator_task_id: "payload-task" },
|
||||
});
|
||||
assert.equal(normalized.correlation?.session_id, "session-1");
|
||||
assert.equal(normalized.correlation?.keep, "yes");
|
||||
assert.equal(normalized.correlation?.task_id, "nested-task");
|
||||
assert.equal(normalized.correlation?.taskId, undefined);
|
||||
assert.equal(normalized.correlation?.curatorTaskId, undefined);
|
||||
}
|
||||
{
|
||||
const standalone = normalizeGrowEvent({ source: "social-service", type: "social.account.connected" });
|
||||
assert.equal(standalone.correlation, undefined);
|
||||
assert.equal(standalone.type, "brand.account.connected");
|
||||
}
|
||||
{
|
||||
assert.equal(resolveUniqueSessionUserId([{ userId: "user-a" }]), "user-a");
|
||||
assert.equal(resolveUniqueSessionUserId([{ userId: "user-a" }, { userId: "user-a" }]), "user-a");
|
||||
assert.equal(resolveUniqueSessionUserId([{ userId: "user-a" }, { userId: "user-b" }]), undefined);
|
||||
}
|
||||
|
||||
const event: GrowEventEnvelope = {
|
||||
id: "event-1",
|
||||
userId: "user-a",
|
||||
source: "interview-service",
|
||||
type: "interview.session.completed",
|
||||
category: "service",
|
||||
occurredAt: "2026-07-14T00:00:00.000Z",
|
||||
correlation: { sessionId: "session-1" },
|
||||
payload: { status: "completed" },
|
||||
};
|
||||
|
||||
const link = {
|
||||
userId: "user-a",
|
||||
missionInstanceId: "sprint-1",
|
||||
missionId: "curator-sprint",
|
||||
stageId: "day-1:interview",
|
||||
metadata: { task_id: "curator-task-1" },
|
||||
};
|
||||
|
||||
{
|
||||
const correlated = correlateEventWithServiceSession(event, "user-a", link);
|
||||
assert.deepEqual(correlated.correlation, {
|
||||
sessionId: "session-1",
|
||||
task_id: "curator-task-1",
|
||||
missionInstanceId: "sprint-1",
|
||||
missionId: "curator-sprint",
|
||||
stageId: "day-1:interview",
|
||||
});
|
||||
assert.deepEqual(correlated.mission, {
|
||||
instanceId: "sprint-1",
|
||||
missionId: "curator-sprint",
|
||||
stageId: "day-1:interview",
|
||||
});
|
||||
}
|
||||
|
||||
{
|
||||
const foreign = correlateEventWithServiceSession(event, "user-a", { ...link, userId: "user-b" });
|
||||
assert.strictEqual(foreign, event, "a service session owned by another user must never enrich the event");
|
||||
}
|
||||
{
|
||||
const sessionless = correlateEventWithServiceSession(event, "user-a", undefined);
|
||||
assert.strictEqual(sessionless, event, "without an exact session link, historical events must not enrich correlation");
|
||||
}
|
||||
|
||||
{
|
||||
const unresolved = correlateEventWithServiceSession(event, undefined, link);
|
||||
assert.strictEqual(unresolved, event, "an unresolved event must never inherit session context");
|
||||
}
|
||||
|
||||
{
|
||||
const explicit = correlateEventWithServiceSession({
|
||||
...event,
|
||||
mission: { missionId: "explicit-mission", stageId: "explicit-stage" },
|
||||
correlation: { sessionId: "session-1", task_id: "explicit-task" },
|
||||
}, "user-a", link);
|
||||
assert.equal(explicit.correlation?.task_id, "explicit-task");
|
||||
assert.deepEqual(explicit.mission, { missionId: "explicit-mission", stageId: "explicit-stage" });
|
||||
}
|
||||
|
||||
{
|
||||
assert.deepEqual(serviceEventExternalIds({
|
||||
...event,
|
||||
correlation: { courseId: "course-1", lesson_id: "lesson-1", externalId: "course-1" },
|
||||
subject: { serviceId: "courses-service", externalId: "lesson-1" },
|
||||
payload: { resume_id: "resume-1", assessmentId: "assessment-1" },
|
||||
}), ["course-1", "lesson-1", "resume-1", "assessment-1"]);
|
||||
}
|
||||
|
||||
{
|
||||
assert.equal(normalizeServiceId("social-branding-service"), "brand");
|
||||
assert.equal(normalizeServiceId("courses-service"), "course");
|
||||
assert.equal(normalizeServiceId("cover-letter-service"), "cover_letter");
|
||||
assert.equal(normalizeServiceId("matchmaking-v2"), "matchmaking");
|
||||
}
|
||||
|
||||
{
|
||||
assert.equal(canonicalGrowEventType("roleplay.session.completed"), "roleplay.scenario.completed");
|
||||
assert.equal(canonicalGrowEventType("roleplay.review.completed"), "roleplay.feedback.generated");
|
||||
assert.equal(canonicalGrowEventType("course.video.completed"), "course.completed");
|
||||
assert.equal(canonicalGrowEventType("course.video.started"), "course.started");
|
||||
assert.equal(canonicalGrowEventType("course.lesson.started"), "course.started");
|
||||
assert.equal(canonicalGrowEventType("social.profile.synced"), "brand.profile.updated");
|
||||
assert.equal(canonicalGrowEventType("resume.analysis.completed"), "resume.analysis.completed");
|
||||
}
|
||||
|
||||
console.log("service session correlation tests passed");
|
||||
122
scripts/service-session-projection-order.test.ts
Normal file
122
scripts/service-session-projection-order.test.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import assert from "node:assert/strict";
|
||||
import type { GrowEventRow } from "../src/db/schema.js";
|
||||
import { recordGatewayEvent } from "../src/routes/services.js";
|
||||
|
||||
for (const service of [
|
||||
{
|
||||
source: "interview-service",
|
||||
type: "interview.session.configured",
|
||||
serviceId: "interview",
|
||||
},
|
||||
{
|
||||
source: "roleplay-service",
|
||||
type: "roleplay.scenario.configured",
|
||||
serviceId: "roleplay",
|
||||
},
|
||||
] as const) {
|
||||
const event = {
|
||||
id: `event-${service.serviceId}`,
|
||||
userId: "user-1",
|
||||
orgId: null,
|
||||
source: service.source,
|
||||
type: service.type,
|
||||
category: "service",
|
||||
occurredAt: new Date("2026-07-14T00:00:00.000Z"),
|
||||
receivedAt: new Date("2026-07-14T00:00:00.000Z"),
|
||||
mission: null,
|
||||
subject: { serviceId: service.serviceId, externalId: "session-1" },
|
||||
correlation: { sessionId: "session-1" },
|
||||
payload: {},
|
||||
raw: null,
|
||||
dedupeKey: `dedupe-${service.serviceId}`,
|
||||
processingStatus: "pending",
|
||||
processingError: null,
|
||||
processedAt: null,
|
||||
} as GrowEventRow;
|
||||
|
||||
const order: string[] = [];
|
||||
const returned = await recordGatewayEvent(
|
||||
{
|
||||
userId: "user-1",
|
||||
source: service.source,
|
||||
type: service.type,
|
||||
payload: { session_id: "session-1" },
|
||||
},
|
||||
{
|
||||
recordLegacyEvent: async () => {
|
||||
order.push("legacy");
|
||||
},
|
||||
recordGrowEvent: async () => {
|
||||
order.push("record");
|
||||
return event;
|
||||
},
|
||||
applyServiceSessionProjection: async (projectedEvent) => {
|
||||
assert.strictEqual(projectedEvent, event);
|
||||
order.push("project");
|
||||
return null;
|
||||
},
|
||||
routeGrowEventToUserActor: async (routedEvent) => {
|
||||
assert.strictEqual(routedEvent, event);
|
||||
order.push("route");
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
assert.strictEqual(returned, event);
|
||||
assert.deepEqual(order, ["legacy", "record", "project", "route"], `${service.serviceId} must project ownership before actor routing`);
|
||||
}
|
||||
|
||||
const configureDedupeKeys: string[] = [];
|
||||
for (const sessionId of ["streak-session-1", "streak-session-2"]) {
|
||||
await recordGatewayEvent(
|
||||
{
|
||||
userId: "user-1",
|
||||
source: "interview-service",
|
||||
type: "interview.session.configured",
|
||||
payload: {
|
||||
request: { curatorTaskId: "curator-task-1", taskId: "curator-task-1" },
|
||||
result: { session_id: sessionId },
|
||||
},
|
||||
correlation: {
|
||||
sessionId,
|
||||
curatorTaskId: "curator-task-1",
|
||||
taskId: "curator-task-1",
|
||||
},
|
||||
},
|
||||
{
|
||||
recordLegacyEvent: async () => undefined,
|
||||
recordGrowEvent: async (input) => {
|
||||
configureDedupeKeys.push(input.dedupeKey ?? "");
|
||||
return {
|
||||
id: `event-${sessionId}`,
|
||||
userId: "user-1",
|
||||
orgId: null,
|
||||
source: "interview-service",
|
||||
type: "interview.session.configured",
|
||||
category: "service",
|
||||
occurredAt: new Date("2026-07-14T00:00:00.000Z"),
|
||||
receivedAt: new Date("2026-07-14T00:00:00.000Z"),
|
||||
mission: null,
|
||||
subject: { serviceId: "interview", externalId: sessionId },
|
||||
correlation: { sessionId, curatorTaskId: "curator-task-1" },
|
||||
payload: {},
|
||||
raw: null,
|
||||
dedupeKey: input.dedupeKey ?? null,
|
||||
processingStatus: "pending",
|
||||
processingError: null,
|
||||
processedAt: null,
|
||||
} as GrowEventRow;
|
||||
},
|
||||
applyServiceSessionProjection: async () => null,
|
||||
routeGrowEventToUserActor: async () => undefined,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
assert.notEqual(
|
||||
configureDedupeKeys[0],
|
||||
configureDedupeKeys[1],
|
||||
"reconfiguring the same curator streak task must record ownership for each generated interview session",
|
||||
);
|
||||
|
||||
console.log("interview and roleplay service session projection ordering tests passed");
|
||||
11
scripts/service-token-config.test.ts
Normal file
11
scripts/service-token-config.test.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { resolveServiceToken } from "../src/config.js";
|
||||
|
||||
assert.equal(resolveServiceToken({}), "", "missing SERVICE_TOKEN must resolve to an empty config value");
|
||||
assert.equal(
|
||||
resolveServiceToken({ SERVICE_TOKEN: "explicit" }),
|
||||
"explicit",
|
||||
"explicit SERVICE_TOKEN must be preserved",
|
||||
);
|
||||
|
||||
console.log("service-token-config: all assertions passed");
|
||||
30
scripts/signal-registry.test.ts
Normal file
30
scripts/signal-registry.test.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { canonicalGrowEventEnvelope } from "../src/events/record-grow-event.js";
|
||||
import { normalizeGrowEvent } from "../src/events/normalize.js";
|
||||
|
||||
/**
|
||||
* Signal registration and all 67 extraction policies belong to QScore. This
|
||||
* backend contract test prevents the old local registry/projector from being
|
||||
* reintroduced while proving the complete raw evidence reaches QScore.
|
||||
*/
|
||||
const raw = {
|
||||
id: "evt-full-evidence",
|
||||
source: "resume-builder",
|
||||
type: "resume.analysis.completed",
|
||||
category: "service" as const,
|
||||
occurredAt: "2026-07-14T00:00:00.000Z",
|
||||
payload: {
|
||||
analysis: {
|
||||
score_breakdown: [{ category: "ATS Compatibility", score: 70 }],
|
||||
dimensional_scores: [{ dimension: "Keywords", score: 60 }],
|
||||
},
|
||||
},
|
||||
};
|
||||
const event = canonicalGrowEventEnvelope(normalizeGrowEvent(raw), "real-user");
|
||||
assert.deepEqual(event.payload, raw.payload, "raw evidence must reach QScore without signal extraction");
|
||||
|
||||
const projector = await readFile(new URL("../src/events/projectors/qscore-projector.ts", import.meta.url), "utf8");
|
||||
assert.equal(projector.includes("extractQscoreSignals"), false);
|
||||
assert.equal(projector.includes("signalId"), false);
|
||||
console.log("signal registry ownership test passed");
|
||||
64
scripts/staging-compose.sh
Executable file
64
scripts/staging-compose.sh
Executable file
@@ -0,0 +1,64 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
PROJECT=growqr-backend-staging
|
||||
COMPOSE="docker compose -p $PROJECT -f docker-compose.yml -f docker-compose.override.yml"
|
||||
REQUIRED_NETWORKS="growqr-backend-staging_default interview-service_default roleplay-service_default resume-builder-staging_default course-service_default qscore-service-staging_default matchmaking-v2_default user-service-staging_default"
|
||||
EXTERNAL_NETWORKS="interview-service_default roleplay-service_default resume-builder-staging_default course-service_default qscore-service-staging_default matchmaking-v2_default user-service-staging_default"
|
||||
|
||||
preflight() {
|
||||
for network in $EXTERNAL_NETWORKS; do
|
||||
docker network inspect "$network" >/dev/null
|
||||
done
|
||||
$COMPOSE config --quiet
|
||||
}
|
||||
|
||||
verify() {
|
||||
actual=$(docker inspect growqr-backend --format '{{range $name, $_ := .NetworkSettings.Networks}}{{$name}} {{end}}')
|
||||
for network in $REQUIRED_NETWORKS; do
|
||||
case " $actual " in
|
||||
*" $network "*) ;;
|
||||
*) echo "missing backend network: $network" >&2; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
for network in $actual; do
|
||||
case " $REQUIRED_NETWORKS " in
|
||||
*" $network "*) ;;
|
||||
*) echo "unexpected backend network: $network" >&2; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
docker exec growqr-backend node --input-type=module -e '
|
||||
const checks = [
|
||||
["user-service", "http://growqr_user_service:8003/health"],
|
||||
["qscore", "http://qscore-service-staging-api-1:8000/health"],
|
||||
["course", "http://growqr-course-service:8010/health"],
|
||||
["resume", "http://growqr_resume_api:8000/health"],
|
||||
["interview", "http://interview-service-api-1:8000/health"],
|
||||
["roleplay", "http://roleplay-service-api-1:8000/health"],
|
||||
];
|
||||
let failed = false;
|
||||
for (const [name, url] of checks) {
|
||||
try {
|
||||
const response = await fetch(url, { signal: AbortSignal.timeout(3000) });
|
||||
console.log(name, response.status);
|
||||
if (response.status !== 200) failed = true;
|
||||
} catch (error) {
|
||||
console.error(name, "ERROR", error.message);
|
||||
failed = true;
|
||||
}
|
||||
}
|
||||
if (failed) process.exit(1);
|
||||
'
|
||||
docker exec growqr-backend node --input-type=module -e "const response=await fetch('http://127.0.0.1:4000/healthz',{signal:AbortSignal.timeout(3000)});console.log('backend',response.status);if(response.status!==200)process.exit(1)"
|
||||
}
|
||||
|
||||
action=${1:-}
|
||||
[ "$#" -gt 0 ] && shift
|
||||
case "$action" in
|
||||
preflight) preflight ;;
|
||||
verify) verify ;;
|
||||
up) preflight; $COMPOSE up -d "$@"; verify ;;
|
||||
down) $COMPOSE down "$@" ;;
|
||||
*) echo "Usage: scripts/staging-compose.sh <preflight|verify|up|down> [compose args]" >&2; exit 2 ;;
|
||||
esac
|
||||
95
scripts/streak-policy.test.ts
Normal file
95
scripts/streak-policy.test.ts
Normal 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_days, 3, "current streak should be 3 for 3 consecutive days ending today");
|
||||
assert.equal(streak.longest_days, 3, "longest should match current when run is unbroken");
|
||||
assert.equal(streak.last_completed_on, "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_days, 2, "duplicate day must not inflate current streak");
|
||||
assert.equal(
|
||||
streak.longest_days,
|
||||
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_days, 1, "gap resets current streak to 1 (only today)");
|
||||
assert.equal(streak.longest_days, 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 1–5
|
||||
"2026-07-04",
|
||||
"2026-07-03",
|
||||
"2026-07-02",
|
||||
"2026-07-01",
|
||||
];
|
||||
const streak = computeStreakFromDays(days, TODAY);
|
||||
assert.equal(streak.current_days, 1, "current is 1 after a gap even if a longer past run exists");
|
||||
assert.equal(streak.longest_days, 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_days, 7, "7 consecutive days should yield current=7");
|
||||
assert.equal(streak.longest_days, 7, "7 consecutive days should yield longest=7");
|
||||
assert.ok(streak.current_days >= 7, "7-day streak eligibility threshold met");
|
||||
}
|
||||
|
||||
// ── 6. Empty days yields zero streak ─────────────────────────────────────────
|
||||
{
|
||||
const streak = computeStreakFromDays([], TODAY);
|
||||
assert.equal(streak.current_days, 0);
|
||||
assert.equal(streak.longest_days, 0);
|
||||
assert.equal(streak.last_completed_on, null);
|
||||
}
|
||||
|
||||
// ── 7. Yesterday keeps the current streak alive until today's IST day closes ─
|
||||
{
|
||||
const days = ["2026-07-09"];
|
||||
const streak = computeStreakFromDays(days, TODAY);
|
||||
assert.equal(streak.current_days, 1, "yesterday remains current during the one-day grace window");
|
||||
assert.equal(streak.longest_days, 1, "longest should still count the 1-day run");
|
||||
}
|
||||
|
||||
console.log("streak-policy tests passed");
|
||||
process.exit(0);
|
||||
97
scripts/user-event-actor-advance.test.ts
Normal file
97
scripts/user-event-actor-advance.test.ts
Normal 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);
|
||||
315
scripts/user-profile.test.ts
Normal file
315
scripts/user-profile.test.ts
Normal file
@@ -0,0 +1,315 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { config } from "../src/config.js";
|
||||
import { resolveTrustedServiceTokens } from "../src/auth/clerk.js";
|
||||
import { fetchOnboardingReadProfile, 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)");
|
||||
}
|
||||
|
||||
// ── 7. trusted service auth uses A2A state with scoped identity ─────────────
|
||||
{
|
||||
const originalFetch = globalThis.fetch;
|
||||
const mutableConfig = config as unknown as { serviceToken: string; a2aAllowedKey: string; nodeEnv: string };
|
||||
const savedConfig = { serviceToken: config.serviceToken, a2aAllowedKey: config.a2aAllowedKey, nodeEnv: config.nodeEnv };
|
||||
Object.assign(mutableConfig, { serviceToken: "test-service-token", a2aAllowedKey: "test-a2a-key", nodeEnv: "production" });
|
||||
const userId = "user_3EX1LG3gBk3KY6kfD9PiTkWubs4/scope";
|
||||
let target = "";
|
||||
let auth = "";
|
||||
let forwardedScope: string | null = null;
|
||||
globalThis.fetch = (async (input: URL | Request, init?: RequestInit) => {
|
||||
target = String(input instanceof URL ? input : input.url);
|
||||
const headers = new Headers(init?.headers ?? {});
|
||||
auth = headers.get("authorization") ?? "";
|
||||
forwardedScope = headers.get("x-growqr-user");
|
||||
return new Response(JSON.stringify({ preferences: { onboarding: { revision: 10, status: "completed", access_choice: "trial", profile: { icp: "experienced" } } } }), { status: 200 });
|
||||
}) as typeof globalThis.fetch;
|
||||
try {
|
||||
const req = new Request("https://backend.test/users/onboarding", {
|
||||
headers: { authorization: "Bearer test-service-token", "x-growqr-user": userId },
|
||||
});
|
||||
const profile = await fetchOnboardingReadProfile(req, userId);
|
||||
assert.ok(target.endsWith(`/api/state/${encodeURIComponent(userId)}`), `A2A target must be scoped to user: ${target}`);
|
||||
assert.equal(auth, "Bearer test-a2a-key", "A2A key must be used downstream");
|
||||
assert.equal(forwardedScope, null, "user scope must be encoded in URL, not forwarded as trust header");
|
||||
assert.equal((profile.preferences as Record<string, any>).onboarding.revision, 10);
|
||||
const onboarding = (profile.preferences as Record<string, any>).onboarding;
|
||||
assert.equal(onboarding.status, "completed");
|
||||
assert.equal(onboarding.profile.icp, "experienced");
|
||||
assert.equal(onboarding.access_choice, "trial");
|
||||
} finally {
|
||||
Object.assign(mutableConfig, savedConfig);
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
}
|
||||
|
||||
// Trusted auth without a matching scope is rejected before any downstream call.
|
||||
{
|
||||
const originalFetch = globalThis.fetch;
|
||||
let calls = 0;
|
||||
const mutableConfig = config as unknown as { serviceToken: string; a2aAllowedKey: string; nodeEnv: string };
|
||||
const savedConfig = { serviceToken: config.serviceToken, a2aAllowedKey: config.a2aAllowedKey, nodeEnv: config.nodeEnv };
|
||||
Object.assign(mutableConfig, { serviceToken: "test-service-token", a2aAllowedKey: "test-a2a-key", nodeEnv: "production" });
|
||||
globalThis.fetch = (async () => { calls += 1; return new Response("unexpected", { status: 200 }); }) as typeof globalThis.fetch;
|
||||
try {
|
||||
const req = new Request("https://backend.test/users/onboarding", { headers: { authorization: "Bearer test-service-token" } });
|
||||
await assert.rejects(() => fetchOnboardingReadProfile(req, "user_spoof"), /matching x-growqr-user/);
|
||||
assert.equal(calls, 0, "unscoped trusted auth must not call user-service");
|
||||
} finally {
|
||||
Object.assign(mutableConfig, savedConfig);
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
}
|
||||
|
||||
// Production never treats A2A_ALLOWED_KEY as an inbound backend token.
|
||||
{
|
||||
const originalFetch = globalThis.fetch;
|
||||
const mutableConfig = config as unknown as { serviceToken: string; a2aAllowedKey: string; nodeEnv: string };
|
||||
const savedConfig = { serviceToken: config.serviceToken, a2aAllowedKey: config.a2aAllowedKey, nodeEnv: config.nodeEnv };
|
||||
Object.assign(mutableConfig, { serviceToken: "test-service-token", a2aAllowedKey: "test-a2a-key", nodeEnv: "production" });
|
||||
let target = "";
|
||||
let auth = "";
|
||||
globalThis.fetch = (async (input: URL | Request, init?: RequestInit) => {
|
||||
target = String(input instanceof URL ? input : input.url);
|
||||
auth = new Headers(init?.headers ?? {}).get("authorization") ?? "";
|
||||
return new Response(JSON.stringify({ preferences: { onboarding: { revision: 10 } } }), { status: 200 });
|
||||
}) as typeof globalThis.fetch;
|
||||
try {
|
||||
const req = new Request("https://backend.test/users/onboarding", {
|
||||
headers: { authorization: "Bearer test-a2a-key", "x-growqr-user": "user_spoof" },
|
||||
});
|
||||
await fetchOnboardingReadProfile(req, "user_real");
|
||||
assert.ok(target.endsWith("/api/v1/users/me"), "production A2A key must use Clerk /me path");
|
||||
assert.equal(auth, "Bearer test-a2a-key");
|
||||
} finally {
|
||||
Object.assign(mutableConfig, savedConfig);
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
}
|
||||
|
||||
// Clerk JWTs always use /me, even when a spoofed x-growqr-user is present.
|
||||
{
|
||||
const originalFetch = globalThis.fetch;
|
||||
let target = "";
|
||||
let auth = "";
|
||||
let forwardedScope: string | null = null;
|
||||
globalThis.fetch = (async (input: URL | Request, init?: RequestInit) => {
|
||||
target = String(input instanceof URL ? input : input.url);
|
||||
const headers = new Headers(init?.headers ?? {});
|
||||
auth = headers.get("authorization") ?? "";
|
||||
forwardedScope = headers.get("x-growqr-user");
|
||||
return new Response(JSON.stringify({ preferences: { onboarding: { revision: 10 } } }), { status: 200 });
|
||||
}) as typeof globalThis.fetch;
|
||||
try {
|
||||
const req = new Request("https://backend.test/users/onboarding", {
|
||||
headers: { authorization: "Bearer eyJ.clerk.jwt", "x-growqr-user": "user_spoof" },
|
||||
});
|
||||
await fetchOnboardingReadProfile(req, "user_real");
|
||||
assert.ok(target.endsWith("/api/v1/users/me"), `Clerk target must be /me: ${target}`);
|
||||
assert.equal(auth, "Bearer eyJ.clerk.jwt", "Clerk JWT must be forwarded unchanged");
|
||||
assert.equal(forwardedScope, null, "spoofable x-growqr-user must not be forwarded to /me");
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
}
|
||||
|
||||
// An invalid Clerk JWT remains a /me error and never falls back to A2A.
|
||||
{
|
||||
const originalFetch = globalThis.fetch;
|
||||
let calls = 0;
|
||||
globalThis.fetch = (async () => { calls += 1; return new Response('{"detail":"invalid"}', { status: 401 }); }) as typeof globalThis.fetch;
|
||||
try {
|
||||
const req = new Request("https://backend.test/users/onboarding", { headers: { authorization: "Bearer invalid.jwt" } });
|
||||
await assert.rejects(() => fetchOnboardingReadProfile(req, "user_real"), /user-service \/me fetch failed: 401/);
|
||||
assert.equal(calls, 1, "invalid Clerk JWT must not trigger an A2A fallback");
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
}
|
||||
// ── 8. trusted token resolution excludes A2A in production ────────────────
|
||||
{
|
||||
assert.equal(config.a2aAllowedKey, process.env.A2A_ALLOWED_KEY ?? "", "A2A config must not have a dev fallback");
|
||||
const production = resolveTrustedServiceTokens({
|
||||
nodeEnv: "production",
|
||||
serviceToken: "service-token",
|
||||
a2aAllowedKey: "a2a-token",
|
||||
});
|
||||
assert.equal(production.has("service-token"), true);
|
||||
assert.equal(production.has("a2a-token"), false, "production must exclude A2A token");
|
||||
|
||||
const development = resolveTrustedServiceTokens({
|
||||
nodeEnv: "development",
|
||||
serviceToken: "service-token",
|
||||
a2aAllowedKey: "a2a-token",
|
||||
});
|
||||
assert.equal(development.has("service-token"), true);
|
||||
assert.equal(development.has("a2a-token"), true, "development must include configured A2A token");
|
||||
}
|
||||
|
||||
// Compose must never inject a development service token in production.
|
||||
{
|
||||
const compose = readFileSync(new URL("../docker-compose.yml", import.meta.url), "utf8");
|
||||
assert.doesNotMatch(compose, /dev-service-token/, "compose must not contain a development service token fallback");
|
||||
assert.match(compose, /SERVICE_TOKEN:\s*\$\{SERVICE_TOKEN:-\}/, "compose SERVICE_TOKEN default must be explicitly empty");
|
||||
}
|
||||
|
||||
|
||||
console.log("user-profile: all assertions passed");
|
||||
15
scripts/ws-ticket.test.ts
Normal file
15
scripts/ws-ticket.test.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { signWsTicket, verifyWsTicket } from "../src/services/ws-tickets.js";
|
||||
|
||||
const issued = signWsTicket({ secret: "ticket-secret", audience: "interview", subject: "user-1", sessionId: "session-1", nowSeconds: 1_000 });
|
||||
assert.equal(verifyWsTicket({ secret: "ticket-secret", ticket: issued.ticket, audience: "interview", sessionId: "session-1", subject: "user-1", nowSeconds: 1_001 }).sid, "session-1");
|
||||
for (const invalid of [
|
||||
{ audience: "roleplay" as const, sessionId: "session-1", subject: "user-1", nowSeconds: 1_001 },
|
||||
{ audience: "interview" as const, sessionId: "session-2", subject: "user-1", nowSeconds: 1_001 },
|
||||
{ audience: "interview" as const, sessionId: "session-1", subject: "user-2", nowSeconds: 1_001 },
|
||||
{ audience: "interview" as const, sessionId: "session-1", subject: "user-1", nowSeconds: 1_061 },
|
||||
]) assert.throws(() => verifyWsTicket({ secret: "ticket-secret", ticket: issued.ticket, ...invalid }));
|
||||
assert.throws(() => verifyWsTicket({ secret: "ticket-secret", ticket: "not-a-ticket", audience: "interview", sessionId: "session-1", nowSeconds: 1_001 }));
|
||||
assert.throws(() => verifyWsTicket({ secret: "a2a-key", ticket: issued.ticket, audience: "interview", sessionId: "session-1", subject: "user-1", nowSeconds: 1_001 }));
|
||||
assert.throws(() => signWsTicket({ secret: "", audience: "interview", subject: "user-1", sessionId: "session-1" }));
|
||||
console.log("ws-ticket tests passed");
|
||||
148
src/actors/analytics/analytics-actor.ts
Normal file
148
src/actors/analytics/analytics-actor.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
import { actor } from "rivetkit";
|
||||
import { count, desc, eq, sql } from "drizzle-orm";
|
||||
import { db } from "../../db/client.js";
|
||||
import {
|
||||
growActiveMissions,
|
||||
growEvents,
|
||||
missionActions,
|
||||
} from "../../db/schema.js";
|
||||
import { listActiveMissionsPg } from "../../grow/persistence.js";
|
||||
import { listMissionActions } from "../../missions/actions.js";
|
||||
import { DEFAULT_QSCORE_ORG_ID, getQscoreFromService, getQscoreLatestSignalsFromService } 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,
|
||||
] = 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")),
|
||||
]);
|
||||
|
||||
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,
|
||||
},
|
||||
serviceUsage,
|
||||
};
|
||||
}
|
||||
|
||||
async function userQscoreAnalytics(userId: string) {
|
||||
const [result, latestEvidence] = await Promise.all([
|
||||
getQscoreFromService(userId, DEFAULT_QSCORE_ORG_ID),
|
||||
getQscoreLatestSignalsFromService(userId, DEFAULT_QSCORE_ORG_ID),
|
||||
]);
|
||||
|
||||
const breakdown = result?.breakdown ?? {};
|
||||
const latestSignals = latestEvidence ?? [];
|
||||
const timelineRaw = latestSignals;
|
||||
const signalTimeline = timelineRaw.map((signal) => ({
|
||||
signalId: signal.signal_id,
|
||||
score: signal.score,
|
||||
present: signal.present,
|
||||
source: signal.source,
|
||||
occurredAt: signal.last_occurred_at,
|
||||
updatedAt: signal.last_seen_at || null,
|
||||
}));
|
||||
|
||||
return {
|
||||
kind: "user-qscore",
|
||||
userId,
|
||||
generatedAt: new Date().toISOString(),
|
||||
current: result
|
||||
? {
|
||||
rq_score: result.rq_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: 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);
|
||||
},
|
||||
},
|
||||
});
|
||||
1
src/actors/analytics/index.ts
Normal file
1
src/actors/analytics/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { analyticsActor } from "./analytics-actor.js";
|
||||
@@ -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: "RQ Score Agent", role: "RQ 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),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -117,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,
|
||||
@@ -143,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);
|
||||
@@ -211,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);
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -24,7 +24,7 @@ This mission is not an autonomous agent. The conversation layer (Grow) owns the
|
||||
2. Interview prep plan
|
||||
3. Mock interview session
|
||||
4. Communication roleplay
|
||||
5. Final readiness Q Score
|
||||
5. Final readiness RQ Score
|
||||
|
||||
## Artifacts
|
||||
- Interview prep plan
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 RQ 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 RQ 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 },
|
||||
@@ -810,7 +810,7 @@ async function dispatchUnifiedTool(
|
||||
|
||||
case "compute_qscore": {
|
||||
const qscoreModule = getSubAgentModule("qscore");
|
||||
if (!qscoreModule?.service) return { ok: false, error: "Q Score Agent module not available" };
|
||||
if (!qscoreModule?.service) return { ok: false, error: "RQ Score Agent module not available" };
|
||||
const result = await runServiceAgentProbe(
|
||||
{ id: qscoreModule.id, name: qscoreModule.name, role: qscoreModule.role, kind: "score", description: qscoreModule.description, service: qscoreModule.service },
|
||||
{ userId, goal: c.state.workflowGoal || "general assessment" },
|
||||
@@ -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: RQ 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", "RQ 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", `RQ Score computation failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
449
src/agents/daily-mission.ts
Normal 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 an RQ 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,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -14,6 +14,17 @@ export type AuthContext = {
|
||||
};
|
||||
};
|
||||
|
||||
/** Resolve inbound service tokens without allowing A2A in production. */
|
||||
export function resolveTrustedServiceTokens(input: {
|
||||
nodeEnv: string;
|
||||
serviceToken: string;
|
||||
a2aAllowedKey: string;
|
||||
}): ReadonlySet<string> {
|
||||
return new Set(
|
||||
[input.serviceToken, input.nodeEnv !== "production" ? input.a2aAllowedKey : ""].filter(Boolean),
|
||||
);
|
||||
}
|
||||
|
||||
export const clerk = config.clerkSecretKey
|
||||
? createClerkClient({ secretKey: config.clerkSecretKey })
|
||||
: null;
|
||||
@@ -26,14 +37,9 @@ 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(
|
||||
[
|
||||
config.serviceToken,
|
||||
config.nodeEnv !== "production" ? config.a2aAllowedKey : "",
|
||||
].filter(Boolean),
|
||||
);
|
||||
const trustedServiceTokens = resolveTrustedServiceTokens(config);
|
||||
|
||||
if (
|
||||
token &&
|
||||
|
||||
@@ -7,7 +7,45 @@ 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";
|
||||
}
|
||||
|
||||
|
||||
/** Resolve the optional service-to-service token without implicit defaults. */
|
||||
export function resolveServiceToken(env: Record<string, string | undefined>): string {
|
||||
return env.SERVICE_TOKEN ?? "";
|
||||
}
|
||||
// ── 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",
|
||||
@@ -22,20 +60,31 @@ export const config = {
|
||||
clerkSecretKey: process.env.CLERK_SECRET_KEY ?? "",
|
||||
clerkPublishableKey: process.env.CLERK_PUBLISHABLE_KEY ?? "",
|
||||
// Optional: lock service-to-service calls (actor → backend).
|
||||
serviceToken: process.env.SERVICE_TOKEN ?? "",
|
||||
a2aAllowedKey: process.env.A2A_ALLOWED_KEY ?? "dev-a2a-key",
|
||||
serviceToken: resolveServiceToken(process.env),
|
||||
// A2A is intentionally unset unless an operator configures it.
|
||||
a2aAllowedKey: process.env.A2A_ALLOWED_KEY ?? "",
|
||||
// Dedicated browser WebSocket ticket signing secret; never reuse A2A credentials.
|
||||
sessionTicketSecret: process.env.SESSION_TICKET_SECRET ?? "",
|
||||
|
||||
// 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.
|
||||
@@ -71,16 +120,35 @@ export const config = {
|
||||
process.env.ROLEPLAY_PUBLIC_URL ?? process.env.ROLEPLAY_SERVICE_URL ?? "http://localhost:8008",
|
||||
qscoreServiceUrl:
|
||||
process.env.QSCORE_SERVICE_URL ?? "http://localhost:8000",
|
||||
qscoreEventDeliveryTimeoutMs: Number(process.env.QSCORE_EVENT_DELIVERY_TIMEOUT_MS ?? 5_000),
|
||||
resumeServiceUrl:
|
||||
process.env.RESUME_SERVICE_URL ?? "http://localhost:8002",
|
||||
userServiceUrl:
|
||||
process.env.USER_SERVICE_URL ?? "http://localhost:8003",
|
||||
resumePublicUrl:
|
||||
process.env.RESUME_PUBLIC_URL ?? process.env.RESUME_SERVICE_URL ?? "http://localhost:8002",
|
||||
coursesServiceUrl:
|
||||
process.env.COURSES_SERVICE_URL ?? "http://localhost:8060",
|
||||
coursesPublicUrl:
|
||||
process.env.COURSES_PUBLIC_URL ?? process.env.COURSES_SERVICE_URL ?? "http://localhost:8060",
|
||||
assessmentServiceUrl:
|
||||
process.env.ASSESSMENT_SERVICE_URL ?? "http://localhost:8070",
|
||||
assessmentPublicUrl:
|
||||
process.env.ASSESSMENT_PUBLIC_URL ?? process.env.ASSESSMENT_SERVICE_URL ?? "http://localhost:8070",
|
||||
matchmakingServiceUrl:
|
||||
process.env.MATCHMAKING_SERVICE_URL ?? "http://localhost:8006",
|
||||
matchmakingPublicUrl:
|
||||
process.env.MATCHMAKING_PUBLIC_URL ?? process.env.MATCHMAKING_SERVICE_URL ?? "http://localhost:8006",
|
||||
pathwaysServiceUrl:
|
||||
process.env.PATHWAYS_SERVICE_URL ?? "http://localhost:8009",
|
||||
pathwaysPublicUrl:
|
||||
process.env.PATHWAYS_PUBLIC_URL ?? process.env.PATHWAYS_SERVICE_URL ?? "http://localhost:8009",
|
||||
socialBrandingServiceUrl:
|
||||
process.env.SOCIAL_BRANDING_SERVICE_URL ?? "http://localhost:8005",
|
||||
socialBrandingPublicUrl:
|
||||
process.env.SOCIAL_BRANDING_PUBLIC_URL ?? process.env.SOCIAL_BRANDING_SERVICE_URL ?? "http://localhost:8005",
|
||||
qscorePublicUrl:
|
||||
process.env.QSCORE_PUBLIC_URL ?? process.env.QSCORE_SERVICE_URL ?? "http://localhost:8000",
|
||||
workflowsDashboardUrl:
|
||||
process.env.WORKFLOWS_DASHBOARD_URL ??
|
||||
process.env.FRONTEND_ORIGIN ??
|
||||
@@ -126,6 +194,12 @@ export const config = {
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean),
|
||||
|
||||
// Passive mission refresh loop. Dedupe keys make this safe across retries and
|
||||
// multiple staging replicas; set MISSION_PASSIVE_LOOP_ENABLED=false to disable.
|
||||
missionPassiveLoopEnabled: (process.env.MISSION_PASSIVE_LOOP_ENABLED ?? "true").toLowerCase() !== "false",
|
||||
missionPassiveLoopIntervalMs: Number(process.env.MISSION_PASSIVE_LOOP_INTERVAL_MS ?? 60 * 60 * 1000),
|
||||
missionPassiveLoopBatchSize: Number(process.env.MISSION_PASSIVE_LOOP_BATCH_SIZE ?? 100),
|
||||
|
||||
// Used by LLM requests.
|
||||
maxAgentTokens: Number(process.env.MAX_AGENT_TOKENS ?? 4096),
|
||||
|
||||
|
||||
93
src/db/ensure-runtime-schema.ts
Normal file
93
src/db/ensure-runtime-schema.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
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)`);
|
||||
}
|
||||
|
||||
async function ensureEventOutboxTable() {
|
||||
await db.execute(`
|
||||
CREATE TABLE IF NOT EXISTS event_outbox (
|
||||
id text PRIMARY KEY DEFAULT gen_random_uuid()::text NOT NULL,
|
||||
topic text NOT NULL,
|
||||
aggregate_id text NOT NULL,
|
||||
destination text NOT NULL,
|
||||
payload jsonb NOT NULL,
|
||||
headers jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
status text NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'processing', 'published')),
|
||||
attempts integer NOT NULL DEFAULT 0,
|
||||
available_at timestamp with time zone NOT NULL DEFAULT now(),
|
||||
locked_at timestamp with time zone,
|
||||
published_at timestamp with time zone,
|
||||
last_error text,
|
||||
response 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 INDEX IF NOT EXISTS event_outbox_delivery_idx ON event_outbox (status, available_at)`);
|
||||
await db.execute(`CREATE UNIQUE INDEX IF NOT EXISTS event_outbox_topic_aggregate_idx ON event_outbox (topic, aggregate_id)`);
|
||||
}
|
||||
|
||||
export async function ensureRuntimeSchema() {
|
||||
await ensureUserPlanColumn();
|
||||
await ensureGrowConversationsMetadataColumn();
|
||||
await ensureSystemNotificationsTables();
|
||||
await ensureOnboardingTable();
|
||||
await ensureEventOutboxTable();
|
||||
log.info("runtime schema ensured");
|
||||
}
|
||||
@@ -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.
|
||||
@@ -259,7 +279,7 @@ export const qscoreSnapshots = pgTable("qscore_snapshots", {
|
||||
userId: text("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
|
||||
runId: text("run_id").references(() => workflowRuns.id, { onDelete: "cascade" }),
|
||||
snapshotType: text("snapshot_type", { enum: ["baseline", "module", "final"] }).notNull(),
|
||||
score: integer("score"),
|
||||
rqScore: integer("rq_score"),
|
||||
payload: jsonb("payload").$type<Record<string, unknown>>(),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
||||
});
|
||||
@@ -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(),
|
||||
},
|
||||
@@ -359,6 +380,36 @@ export const growEvents = pgTable(
|
||||
}),
|
||||
);
|
||||
|
||||
// Transactional delivery queue for canonical GrowEvents. The grow_events row
|
||||
// and its QScore delivery intent are inserted in the same database transaction;
|
||||
// delivery is retried independently and is idempotent on (topic, aggregateId).
|
||||
export const eventOutbox = pgTable(
|
||||
"event_outbox",
|
||||
{
|
||||
id: text("id").primaryKey().default(sql`gen_random_uuid()::text`),
|
||||
topic: text("topic").notNull(),
|
||||
aggregateId: text("aggregate_id").notNull(),
|
||||
destination: text("destination").notNull(),
|
||||
payload: jsonb("payload").$type<Record<string, unknown>>().notNull(),
|
||||
headers: jsonb("headers").$type<Record<string, string>>().notNull().default(sql`'{}'::jsonb`),
|
||||
status: text("status", {
|
||||
enum: ["pending", "processing", "published"],
|
||||
}).notNull().default("pending"),
|
||||
attempts: integer("attempts").notNull().default(0),
|
||||
availableAt: timestamp("available_at", { withTimezone: true }).defaultNow().notNull(),
|
||||
lockedAt: timestamp("locked_at", { withTimezone: true }),
|
||||
publishedAt: timestamp("published_at", { withTimezone: true }),
|
||||
lastError: text("last_error"),
|
||||
response: jsonb("response").$type<Record<string, unknown>>(),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
|
||||
},
|
||||
(t) => ({
|
||||
deliveryIdx: index("event_outbox_delivery_idx").on(t.status, t.availableAt),
|
||||
aggregateIdx: uniqueIndex("event_outbox_topic_aggregate_idx").on(t.topic, t.aggregateId),
|
||||
}),
|
||||
);
|
||||
|
||||
export const missionServiceSessions = pgTable(
|
||||
"mission_service_sessions",
|
||||
{
|
||||
@@ -378,7 +429,7 @@ export const missionServiceSessions = pgTable(
|
||||
},
|
||||
(t) => ({
|
||||
userIdx: index("mission_service_sessions_user_idx").on(t.userId, t.updatedAt),
|
||||
externalIdx: uniqueIndex("mission_service_sessions_external_idx").on(t.serviceId, t.externalId),
|
||||
externalIdx: uniqueIndex("mission_service_sessions_external_idx").on(t.userId, t.serviceId, t.externalId),
|
||||
missionIdx: index("mission_service_sessions_mission_idx").on(t.missionInstanceId, t.stageId),
|
||||
}),
|
||||
);
|
||||
@@ -588,6 +639,44 @@ export const growHomeNotifications = pgTable(
|
||||
}),
|
||||
);
|
||||
|
||||
export const systemNotifications = pgTable(
|
||||
"system_notifications",
|
||||
{
|
||||
id: text("id").primaryKey().default(sql`gen_random_uuid()::text`),
|
||||
userId: text("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
|
||||
kind: text("kind", { enum: ["session", "billing", "security", "feature", "account"] }).notNull(),
|
||||
title: text("title").notNull(),
|
||||
sub: text("sub").notNull(),
|
||||
whenLabel: text("when_label").notNull(),
|
||||
href: text("href"),
|
||||
source: text("source").notNull().default("system"),
|
||||
sourceRef: jsonb("source_ref").$type<Record<string, unknown>>().notNull().default(sql`'{}'::jsonb`),
|
||||
readAt: timestamp("read_at", { withTimezone: true }),
|
||||
occurredAt: timestamp("occurred_at", { withTimezone: true }).defaultNow().notNull(),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
|
||||
},
|
||||
(t) => ({
|
||||
userIdx: index("system_notifications_user_idx").on(t.userId, t.readAt, t.occurredAt),
|
||||
kindIdx: index("system_notifications_kind_idx").on(t.userId, t.kind, t.occurredAt),
|
||||
}),
|
||||
);
|
||||
|
||||
export const systemNotificationPreferences = pgTable(
|
||||
"system_notification_preferences",
|
||||
{
|
||||
userId: text("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
|
||||
kind: text("kind", { enum: ["session", "billing", "security", "feature", "account"] }).notNull(),
|
||||
inApp: boolean("in_app").notNull().default(true),
|
||||
email: boolean("email").notNull().default(true),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
|
||||
},
|
||||
(t) => ({
|
||||
pk: primaryKey({ columns: [t.userId, t.kind] }),
|
||||
userIdx: index("system_notification_preferences_user_idx").on(t.userId),
|
||||
}),
|
||||
);
|
||||
|
||||
export type GrowEventRow = typeof growEvents.$inferSelect;
|
||||
export type NewGrowEvent = typeof growEvents.$inferInsert;
|
||||
export type MissionActionRow = typeof missionActions.$inferSelect;
|
||||
@@ -597,3 +686,6 @@ export type NewMissionSuggestion = typeof missionSuggestions.$inferInsert;
|
||||
export type MissionCoachRunRow = typeof missionCoachRuns.$inferSelect;
|
||||
export type GrowHomeNotificationRow = typeof growHomeNotifications.$inferSelect;
|
||||
export type NewGrowHomeNotification = typeof growHomeNotifications.$inferInsert;
|
||||
export type SystemNotificationRow = typeof systemNotifications.$inferSelect;
|
||||
export type NewSystemNotification = typeof systemNotifications.$inferInsert;
|
||||
export type SystemNotificationPreferenceRow = typeof systemNotificationPreferences.$inferSelect;
|
||||
|
||||
@@ -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)");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 = {
|
||||
@@ -51,8 +53,10 @@ export type StoredGrowEvent = GrowEventEnvelope & {
|
||||
|
||||
export type QscoreSignal = {
|
||||
signalId: string;
|
||||
score: number;
|
||||
present: boolean;
|
||||
// QScore owns normalization and scoring. Raw readiness evidence returned by
|
||||
// its event-ingest API may identify a projected signal before a score run.
|
||||
score?: number;
|
||||
present?: boolean;
|
||||
source?: string;
|
||||
raw?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
@@ -12,21 +12,33 @@ 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;
|
||||
return { instanceId, missionId, stageId };
|
||||
}
|
||||
|
||||
function normalizeCorrelation(value: unknown) {
|
||||
function normalizeCorrelation(value: unknown, taskAliases: unknown[] = []) {
|
||||
const record = asRecord(value);
|
||||
const correlation: Record<string, unknown> = { ...record };
|
||||
const correlation: Record<string, unknown> = Object.fromEntries(Object.entries(record).filter(([, value]) => value !== undefined));
|
||||
delete correlation.taskId;
|
||||
delete correlation.curatorTaskId;
|
||||
delete correlation.curator_task_id;
|
||||
delete correlation.task_id;
|
||||
// Curator-launched and standalone service events share this ingress, so optional
|
||||
// task identity is canonicalized once instead of forcing downstream precedence guesses.
|
||||
const taskId = [...[record.task_id, record.taskId, record.curatorTaskId, record.curator_task_id], ...taskAliases]
|
||||
.map(getString)
|
||||
.find((item): item is string => Boolean(item));
|
||||
const requestId = getString(record.requestId ?? record.request_id);
|
||||
const sessionId = getString(record.sessionId ?? record.session_id);
|
||||
const resumeId = getString(record.resumeId ?? record.resume_id);
|
||||
@@ -35,9 +47,37 @@ function normalizeCorrelation(value: unknown) {
|
||||
if (sessionId) correlation.sessionId = sessionId;
|
||||
if (resumeId) correlation.resumeId = resumeId;
|
||||
if (externalId) correlation.externalId = externalId;
|
||||
if (taskId) correlation.task_id = taskId;
|
||||
return Object.keys(correlation).length ? correlation : undefined;
|
||||
}
|
||||
|
||||
function normalizeTaskAliases(raw: Record<string, unknown>, payload: Record<string, unknown>) {
|
||||
return [raw.task_id, raw.taskId, raw.curatorTaskId, raw.curator_task_id, payload.task_id, payload.taskId, payload.curatorTaskId, payload.curator_task_id];
|
||||
}
|
||||
|
||||
const CANONICAL_EVENT_TYPE: Record<string, string> = {
|
||||
"interview.review.completed": "interview.feedback.generated",
|
||||
"interview.session_completed": "interview.session.completed",
|
||||
"roleplay.session.completed": "roleplay.scenario.completed",
|
||||
"roleplay.session_completed": "roleplay.scenario.completed",
|
||||
"roleplay.review.completed": "roleplay.feedback.generated",
|
||||
"course.video.completed": "course.completed",
|
||||
"course.lesson.completed": "course.completed",
|
||||
"course.video.started": "course.started",
|
||||
"course.lesson.started": "course.started",
|
||||
"course.progress_recorded": "course.progress.recorded",
|
||||
"social.profile.synced": "brand.profile.updated",
|
||||
"social.profile.updated": "brand.profile.updated",
|
||||
"social.post.scheduled": "brand.post.exported",
|
||||
"social.post.published": "brand.content.published",
|
||||
"social.content.generated": "brand.post.drafted",
|
||||
"social.account.connected": "brand.account.connected",
|
||||
};
|
||||
|
||||
export function canonicalGrowEventType(type: string): string {
|
||||
return CANONICAL_EVENT_TYPE[type] ?? type;
|
||||
}
|
||||
|
||||
export function normalizeGrowEvent(input: unknown, overrides: { userId?: string; source?: string } = {}): GrowEventEnvelope {
|
||||
const raw = asRecord(input);
|
||||
const payload = asRecord(raw.payload ?? raw.data ?? raw.event_payload);
|
||||
@@ -46,15 +86,18 @@ export function normalizeGrowEvent(input: unknown, overrides: { userId?: string;
|
||||
resume_id: raw.resume_id ?? payload.resume_id,
|
||||
external_id: raw.external_id ?? payload.external_id,
|
||||
request_id: raw.request_id ?? payload.request_id,
|
||||
});
|
||||
}, normalizeTaskAliases(raw, payload));
|
||||
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;
|
||||
})();
|
||||
|
||||
const source = overrides.source ?? getString(raw.source) ?? "unknown-service";
|
||||
const type = getString(raw.type ?? raw.event_type ?? raw.action) ?? "service.event";
|
||||
const type = canonicalGrowEventType(getString(raw.type ?? raw.event_type ?? raw.action) ?? "service.event");
|
||||
const categoryRaw = raw.category ?? payload.category;
|
||||
const category = isGrowEventCategory(categoryRaw) ? categoryRaw : "service";
|
||||
const userId = overrides.userId ?? getString(raw.userId ?? raw.user_id ?? payload.userId ?? payload.user_id);
|
||||
|
||||
528
src/events/onboarding-ledger.ts
Normal file
528
src/events/onboarding-ledger.ts
Normal file
@@ -0,0 +1,528 @@
|
||||
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 {
|
||||
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;
|
||||
}
|
||||
|
||||
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 {
|
||||
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 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 { 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;
|
||||
}
|
||||
@@ -1,152 +0,0 @@
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { db } from "../db/client.js";
|
||||
import { growQscoreLatest, growQscoreProjectionState, growQscoreSignals } from "../db/schema.js";
|
||||
|
||||
export const ONBOARDING_BASELINE_SIGNAL_ID = "onboarding.completed_baseline";
|
||||
export const ONBOARDING_BASELINE_QSCORE = 35;
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : {};
|
||||
}
|
||||
|
||||
function onboardingCompletedAt(preferences: Record<string, unknown> | undefined): Date | null {
|
||||
const onboarding = asRecord(preferences?.onboarding);
|
||||
const completedAt = onboarding.completed_at;
|
||||
if (typeof completedAt !== "string" || !completedAt.trim()) return null;
|
||||
const parsed = new Date(completedAt);
|
||||
return Number.isNaN(parsed.getTime()) ? new Date() : parsed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed the first real Q Score projection when onboarding is completed.
|
||||
*
|
||||
* The onboarding UI tells users their QX baseline starts at 35. Previously that
|
||||
* number was only cosmetic, while the header showed a separate home-feed
|
||||
* fallback and the Q Score page stayed empty. This makes the onboarding
|
||||
* baseline a persisted readiness signal, but only when the user has no Q Score
|
||||
* signals/projection yet so we do not overwrite mature accounts.
|
||||
*/
|
||||
export async function ensureOnboardingBaselineQscore(
|
||||
userId: string,
|
||||
preferences: Record<string, unknown> | undefined,
|
||||
): Promise<boolean> {
|
||||
const completedAt = onboardingCompletedAt(preferences);
|
||||
if (!completedAt) return false;
|
||||
|
||||
const latestSignals = await db
|
||||
.select({ signalId: growQscoreLatest.signalId, score: growQscoreLatest.score })
|
||||
.from(growQscoreLatest)
|
||||
.where(and(eq(growQscoreLatest.userId, userId), eq(growQscoreLatest.present, true)));
|
||||
|
||||
const [existingProjection] = await db
|
||||
.select({ score: growQscoreProjectionState.score, signalCount: growQscoreProjectionState.signalCount })
|
||||
.from(growQscoreProjectionState)
|
||||
.where(eq(growQscoreProjectionState.userId, userId))
|
||||
.limit(1);
|
||||
|
||||
const now = new Date();
|
||||
|
||||
// Repair users affected by the old resume-upload projector, which treated a
|
||||
// plain upload as a perfect 100 score. Uploading a resume during onboarding is
|
||||
// only baseline evidence; parsed resume/interview/roleplay results should be
|
||||
// what moves the score upward.
|
||||
if (
|
||||
latestSignals.length === 1 &&
|
||||
latestSignals[0]?.signalId === "resume.uploaded" &&
|
||||
latestSignals[0].score > ONBOARDING_BASELINE_QSCORE
|
||||
) {
|
||||
await db
|
||||
.update(growQscoreLatest)
|
||||
.set({
|
||||
score: ONBOARDING_BASELINE_QSCORE,
|
||||
raw: {
|
||||
reason: "resume upload baseline correction",
|
||||
correctedFrom: latestSignals[0].score,
|
||||
correctedAt: now.toISOString(),
|
||||
},
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(and(eq(growQscoreLatest.userId, userId), eq(growQscoreLatest.signalId, "resume.uploaded")));
|
||||
|
||||
await db
|
||||
.insert(growQscoreProjectionState)
|
||||
.values({
|
||||
userId,
|
||||
score: ONBOARDING_BASELINE_QSCORE,
|
||||
signalCount: 1,
|
||||
dimensions: { baseline: true, latestSignalIds: ["resume.uploaded"], corrected: true },
|
||||
summary: "Baseline Q Score from onboarding resume upload.",
|
||||
updatedAt: now,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: growQscoreProjectionState.userId,
|
||||
set: {
|
||||
score: ONBOARDING_BASELINE_QSCORE,
|
||||
signalCount: 1,
|
||||
dimensions: { baseline: true, latestSignalIds: ["resume.uploaded"], corrected: true },
|
||||
summary: "Baseline Q Score from onboarding resume upload.",
|
||||
updatedAt: now,
|
||||
},
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
if (latestSignals.length > 0 || (existingProjection?.score ?? 0) > 0) {
|
||||
return false;
|
||||
}
|
||||
const raw = {
|
||||
reason: "completed onboarding baseline",
|
||||
completedAt: completedAt.toISOString(),
|
||||
};
|
||||
|
||||
const inserted = await db
|
||||
.insert(growQscoreLatest)
|
||||
.values({
|
||||
userId,
|
||||
signalId: ONBOARDING_BASELINE_SIGNAL_ID,
|
||||
score: ONBOARDING_BASELINE_QSCORE,
|
||||
present: true,
|
||||
source: "onboarding",
|
||||
raw,
|
||||
occurredAt: completedAt,
|
||||
updatedAt: now,
|
||||
})
|
||||
.onConflictDoNothing()
|
||||
.returning({ signalId: growQscoreLatest.signalId });
|
||||
|
||||
if (!inserted.length) return false;
|
||||
|
||||
await db.insert(growQscoreSignals).values({
|
||||
userId,
|
||||
signalId: ONBOARDING_BASELINE_SIGNAL_ID,
|
||||
score: ONBOARDING_BASELINE_QSCORE,
|
||||
present: true,
|
||||
source: "onboarding",
|
||||
raw,
|
||||
occurredAt: completedAt,
|
||||
});
|
||||
|
||||
await db
|
||||
.insert(growQscoreProjectionState)
|
||||
.values({
|
||||
userId,
|
||||
score: ONBOARDING_BASELINE_QSCORE,
|
||||
signalCount: 1,
|
||||
dimensions: { baseline: true, latestSignalIds: [ONBOARDING_BASELINE_SIGNAL_ID] },
|
||||
summary: "Baseline Q Score from completed onboarding.",
|
||||
updatedAt: now,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: growQscoreProjectionState.userId,
|
||||
set: {
|
||||
score: ONBOARDING_BASELINE_QSCORE,
|
||||
signalCount: 1,
|
||||
dimensions: { baseline: true, latestSignalIds: [ONBOARDING_BASELINE_SIGNAL_ID] },
|
||||
summary: "Baseline Q Score from completed onboarding.",
|
||||
updatedAt: now,
|
||||
},
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -1,194 +1,21 @@
|
||||
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 { publishQscoreOutboxForEvent } from "../qscore-event-outbox.js";
|
||||
|
||||
function signal(signalId: string, score: number, raw?: Record<string, unknown>, present = true): QscoreSignal {
|
||||
return { signalId, score: clampScore(score), present, raw };
|
||||
}
|
||||
|
||||
function nestedNumber(record: Record<string, unknown>, keys: string[]): number | undefined {
|
||||
for (const key of keys) {
|
||||
const direct = getNumber(record[key]);
|
||||
if (direct !== undefined) return direct;
|
||||
}
|
||||
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);
|
||||
const scoreBreakdown = Array.isArray(analysis.score_breakdown) ? analysis.score_breakdown : [];
|
||||
const dimensions = Array.isArray(analysis.dimensional_scores) ? analysis.dimensional_scores : [];
|
||||
|
||||
const byCategory = new Map<string, number>();
|
||||
for (const item of scoreBreakdown) {
|
||||
const row = asRecord(item);
|
||||
const category = typeof row.category === "string" ? row.category : undefined;
|
||||
const score = getNumber(row.score);
|
||||
if (category && score !== undefined) byCategory.set(category, score);
|
||||
}
|
||||
|
||||
const byDimension = new Map<string, number>();
|
||||
for (const item of dimensions) {
|
||||
const row = asRecord(item);
|
||||
const dimension = typeof row.dimension === "string" ? row.dimension : undefined;
|
||||
const score = getNumber(row.score);
|
||||
if (dimension && score !== undefined) byDimension.set(dimension, score);
|
||||
}
|
||||
|
||||
const signals: QscoreSignal[] = [];
|
||||
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 }));
|
||||
const contentQuality = byCategory.get("Content Quality") ?? nestedNumber(analysis, ["content_quality", "clarity_score"]);
|
||||
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 extractInterviewSignals(event: GrowEventRow): QscoreSignal[] {
|
||||
const payload = event.payload ?? {};
|
||||
const review = asRecord(payload.review ?? payload.result ?? payload);
|
||||
const status = String(review.status ?? payload.status ?? "");
|
||||
if (!event.type.includes("review") && !event.type.includes("completed") && status !== "completed") return [];
|
||||
|
||||
const signals: QscoreSignal[] = [];
|
||||
signals.push(signal("interview.completed", 100, { eventId: event.id }));
|
||||
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);
|
||||
const content = getNumber(rubric.content_quality ?? rubric.content ?? rubric.communication ?? review.communication_score);
|
||||
if (content !== undefined) signals.push(signal("interview.response_clarity", content, { eventId: event.id }));
|
||||
const roleAlignment = getNumber(rubric.role_alignment ?? rubric.technical_accuracy ?? review.role_alignment_score);
|
||||
if (roleAlignment !== undefined) signals.push(signal("interview.technical_accuracy", roleAlignment, { eventId: event.id }));
|
||||
const language = getNumber(rubric.language ?? review.language_score);
|
||||
if (language !== undefined) signals.push(signal("interview.behavioral_quality", language, { 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("interview.improvement_over_time", 50 + delta * 2.5, { eventId: event.id, delta }));
|
||||
return signals;
|
||||
}
|
||||
|
||||
function extractRoleplaySignals(event: GrowEventRow): QscoreSignal[] {
|
||||
const payload = event.payload ?? {};
|
||||
const review = asRecord(payload.review ?? payload.result ?? payload);
|
||||
const status = String(review.status ?? payload.status ?? "");
|
||||
if (!event.type.includes("review") && !event.type.includes("completed") && status !== "completed") return [];
|
||||
|
||||
const signals: QscoreSignal[] = [];
|
||||
signals.push(signal("roleplay.completed", 100, { eventId: event.id }));
|
||||
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 }));
|
||||
const empathy = getNumber(rubric.emotional_intelligence ?? review.emotional_intelligence_score);
|
||||
if (empathy !== undefined) signals.push(signal("roleplay.empathy_demonstrated", empathy, { eventId: event.id }));
|
||||
const adaptability = getNumber(rubric.adaptability ?? review.adaptability_score);
|
||||
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;
|
||||
}
|
||||
|
||||
export function extractQscoreSignals(event: GrowEventRow): QscoreSignal[] {
|
||||
const source = event.source.toLowerCase();
|
||||
if (source.includes("resume") || event.type.startsWith("resume.")) return extractResumeSignals(event);
|
||||
if (source.includes("interview") || event.type.startsWith("interview.")) return extractInterviewSignals(event);
|
||||
if (source.includes("roleplay") || event.type.startsWith("roleplay.")) return extractRoleplaySignals(event);
|
||||
if (event.type === "mission.interview_to_offer.started") {
|
||||
return [signal("goals.goals_set", 100, { eventId: event.id })];
|
||||
}
|
||||
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({
|
||||
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(),
|
||||
},
|
||||
});
|
||||
|
||||
return { signals, score };
|
||||
/**
|
||||
* Compatibility adapter for event/mission processing.
|
||||
*
|
||||
* The backend never extracts, normalizes, thresholds, or scores a signal. The
|
||||
* canonical GrowEvent was queued transactionally when it was recorded. This
|
||||
* method only attempts that delivery and passes QScore-owned projected evidence
|
||||
* back to mission reducers when the QScore API returns it.
|
||||
*/
|
||||
export async function applyQscoreProjection(event: Pick<GrowEventRow, "id" | "userId">) {
|
||||
if (!event.userId) return { signals: [], score: undefined, delivery: "skipped" as const };
|
||||
const result = await publishQscoreOutboxForEvent(event.id);
|
||||
return {
|
||||
signals: result.signals,
|
||||
score: undefined,
|
||||
delivery: result.status,
|
||||
response: result.response,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,57 @@ 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,
|
||||
task_id: getString(correlation.task_id ?? payload.task_id ?? payload.taskId ?? payload.curatorTaskId ?? payload.curator_task_id),
|
||||
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),
|
||||
@@ -57,20 +101,18 @@ export async function applyServiceSessionProjection(event: GrowEventRow) {
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.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(),
|
||||
},
|
||||
target: [missionServiceSessions.userId, missionServiceSessions.serviceId, missionServiceSessions.externalId],
|
||||
set: updateValues,
|
||||
})
|
||||
.returning();
|
||||
|
||||
// Touch the row if Drizzle ever returns no row for an upsert variant.
|
||||
if (!row) {
|
||||
await db.update(missionServiceSessions).set({ updatedAt: new Date() }).where(eq(missionServiceSessions.externalId, externalId));
|
||||
await db.update(missionServiceSessions).set({ updatedAt: new Date() }).where(and(
|
||||
eq(missionServiceSessions.userId, event.userId),
|
||||
eq(missionServiceSessions.serviceId, serviceId),
|
||||
eq(missionServiceSessions.externalId, externalId),
|
||||
));
|
||||
}
|
||||
return row ?? null;
|
||||
}
|
||||
|
||||
197
src/events/qscore-event-outbox.ts
Normal file
197
src/events/qscore-event-outbox.ts
Normal file
@@ -0,0 +1,197 @@
|
||||
import { and, eq, lt, lte, or, sql } from "drizzle-orm";
|
||||
import { config } from "../config.js";
|
||||
import { db } from "../db/client.js";
|
||||
import { eventOutbox } from "../db/schema.js";
|
||||
import { log } from "../log.js";
|
||||
import { asRecord, getNumber, getString, type QscoreSignal } from "./envelope.js";
|
||||
|
||||
export const QSCORE_GROW_EVENT_TOPIC = "qscore.grow_event.raw";
|
||||
export const QSCORE_GROW_EVENT_DESTINATION = "/v1/events/ingest";
|
||||
|
||||
const LOCK_TIMEOUT_MS = 60_000;
|
||||
const DEFAULT_POLL_MS = 2_000;
|
||||
const DEFAULT_BATCH_SIZE = 25;
|
||||
|
||||
export type QscoreEventDeliveryResult = {
|
||||
status: "queued" | "published";
|
||||
signals: QscoreSignal[];
|
||||
response?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
type Deliver = (input: {
|
||||
destination: string;
|
||||
payload: Record<string, unknown>;
|
||||
headers: Record<string, string>;
|
||||
}) => Promise<Record<string, unknown>>;
|
||||
|
||||
function signalFromUnknown(value: unknown): QscoreSignal | null {
|
||||
const row = asRecord(value);
|
||||
const signalId = getString(row.signalId ?? row.signal_id ?? row.id);
|
||||
if (!signalId) return null;
|
||||
const score = getNumber(row.score ?? row.normalizedScore ?? row.normalized_score);
|
||||
const present = typeof row.present === "boolean" ? row.present : undefined;
|
||||
const suppliedRaw = asRecord(row.raw ?? row.evidence);
|
||||
const policyEvidence = compactRecord({
|
||||
rawValue: row.rawValue ?? row.raw_value,
|
||||
rawUnit: row.rawUnit ?? row.raw_unit,
|
||||
band: row.band,
|
||||
materialChange: row.materialChange ?? row.material_change,
|
||||
});
|
||||
return {
|
||||
signalId,
|
||||
...(score !== undefined ? { score } : {}),
|
||||
...(present !== undefined ? { present } : {}),
|
||||
...(getString(row.source) ? { source: getString(row.source) } : {}),
|
||||
raw: Object.keys(suppliedRaw).length ? suppliedRaw : policyEvidence,
|
||||
};
|
||||
}
|
||||
|
||||
function compactRecord(value: Record<string, unknown>) {
|
||||
return Object.fromEntries(Object.entries(value).filter(([, item]) => item !== undefined));
|
||||
}
|
||||
|
||||
/** Read only QScore-owned projected evidence; never infer or score locally. */
|
||||
export function projectedSignalsFromQscoreResponse(body: Record<string, unknown>): QscoreSignal[] {
|
||||
const breakdown = asRecord(body.breakdown);
|
||||
const candidates = Array.isArray(body.projectedSignals)
|
||||
? body.projectedSignals
|
||||
: Array.isArray(body.projected_signals)
|
||||
? body.projected_signals
|
||||
: Array.isArray(body.signals)
|
||||
? body.signals
|
||||
: Array.isArray(breakdown.signals)
|
||||
? breakdown.signals
|
||||
: [];
|
||||
return candidates.map(signalFromUnknown).filter((signal): signal is QscoreSignal => signal !== null);
|
||||
}
|
||||
|
||||
async function postToQscore(input: Parameters<Deliver>[0]): Promise<Record<string, unknown>> {
|
||||
const response = await fetch(`${config.qscoreServiceUrl.replace(/\/$/, "")}${input.destination}`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
...input.headers,
|
||||
...(config.a2aAllowedKey ? { authorization: `Bearer ${config.a2aAllowedKey}` } : {}),
|
||||
},
|
||||
body: JSON.stringify(input.payload),
|
||||
signal: AbortSignal.timeout(config.qscoreEventDeliveryTimeoutMs),
|
||||
});
|
||||
const text = await response.text();
|
||||
let body: unknown = {};
|
||||
if (text) {
|
||||
try { body = JSON.parse(text); } catch { body = { detail: text }; }
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new Error(`qscore raw-event ingest failed (${response.status}): ${text.slice(0, 500)}`);
|
||||
}
|
||||
return asRecord(body);
|
||||
}
|
||||
|
||||
export async function publishQscoreOutboxItem(
|
||||
outboxId: string,
|
||||
deliver: Deliver = postToQscore,
|
||||
): Promise<QscoreEventDeliveryResult> {
|
||||
const now = new Date();
|
||||
const staleBefore = new Date(now.getTime() - LOCK_TIMEOUT_MS);
|
||||
const [claimed] = await db
|
||||
.update(eventOutbox)
|
||||
.set({
|
||||
status: "processing",
|
||||
attempts: sql`${eventOutbox.attempts} + 1`,
|
||||
lockedAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(and(
|
||||
eq(eventOutbox.id, outboxId),
|
||||
or(
|
||||
and(eq(eventOutbox.status, "pending"), lte(eventOutbox.availableAt, now)),
|
||||
and(eq(eventOutbox.status, "processing"), lt(eventOutbox.lockedAt, staleBefore)),
|
||||
),
|
||||
))
|
||||
.returning();
|
||||
|
||||
if (!claimed) {
|
||||
const [existing] = await db.select().from(eventOutbox).where(eq(eventOutbox.id, outboxId)).limit(1);
|
||||
const response = existing?.response ?? undefined;
|
||||
return {
|
||||
status: existing?.status === "published" ? "published" : "queued",
|
||||
signals: response ? projectedSignalsFromQscoreResponse(response) : [],
|
||||
response,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await deliver({
|
||||
destination: claimed.destination,
|
||||
payload: claimed.payload,
|
||||
headers: claimed.headers,
|
||||
});
|
||||
await db.update(eventOutbox).set({
|
||||
status: "published",
|
||||
response,
|
||||
publishedAt: new Date(),
|
||||
lockedAt: null,
|
||||
lastError: null,
|
||||
updatedAt: new Date(),
|
||||
}).where(eq(eventOutbox.id, claimed.id));
|
||||
return { status: "published", signals: projectedSignalsFromQscoreResponse(response), response };
|
||||
} catch (error) {
|
||||
const delaySeconds = Math.min(300, 2 ** Math.min(claimed.attempts, 8));
|
||||
await db.update(eventOutbox).set({
|
||||
status: "pending",
|
||||
availableAt: new Date(Date.now() + delaySeconds * 1_000),
|
||||
lockedAt: null,
|
||||
lastError: error instanceof Error ? error.message : String(error),
|
||||
updatedAt: new Date(),
|
||||
}).where(eq(eventOutbox.id, claimed.id));
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function publishQscoreOutboxForEvent(eventId: string): Promise<QscoreEventDeliveryResult> {
|
||||
const [row] = await db
|
||||
.select({ id: eventOutbox.id, status: eventOutbox.status, response: eventOutbox.response })
|
||||
.from(eventOutbox)
|
||||
.where(and(eq(eventOutbox.topic, QSCORE_GROW_EVENT_TOPIC), eq(eventOutbox.aggregateId, eventId)))
|
||||
.limit(1);
|
||||
if (!row) return { status: "queued", signals: [] };
|
||||
if (row.status === "published") {
|
||||
return {
|
||||
status: "published",
|
||||
signals: row.response ? projectedSignalsFromQscoreResponse(row.response) : [],
|
||||
response: row.response ?? undefined,
|
||||
};
|
||||
}
|
||||
try {
|
||||
return await publishQscoreOutboxItem(row.id);
|
||||
} catch (error) {
|
||||
log.warn({ error, eventId }, "QScore raw GrowEvent queued for retry");
|
||||
return { status: "queued", signals: [] };
|
||||
}
|
||||
}
|
||||
|
||||
export async function flushQscoreEventOutbox(limit = DEFAULT_BATCH_SIZE): Promise<number> {
|
||||
const now = new Date();
|
||||
const staleBefore = new Date(now.getTime() - LOCK_TIMEOUT_MS);
|
||||
const rows = await db
|
||||
.select({ id: eventOutbox.id })
|
||||
.from(eventOutbox)
|
||||
.where(and(
|
||||
eq(eventOutbox.topic, QSCORE_GROW_EVENT_TOPIC),
|
||||
or(
|
||||
and(eq(eventOutbox.status, "pending"), lte(eventOutbox.availableAt, now)),
|
||||
and(eq(eventOutbox.status, "processing"), lt(eventOutbox.lockedAt, staleBefore)),
|
||||
),
|
||||
))
|
||||
.limit(limit);
|
||||
const settled = await Promise.allSettled(rows.map((row) => publishQscoreOutboxItem(row.id)));
|
||||
return settled.filter((result) => result.status === "fulfilled").length;
|
||||
}
|
||||
|
||||
export function startQscoreEventOutboxPublisher(pollMs = DEFAULT_POLL_MS) {
|
||||
const timer = setInterval(() => {
|
||||
flushQscoreEventOutbox().catch((error) => log.error({ error }, "QScore event outbox flush failed"));
|
||||
}, pollMs);
|
||||
timer.unref();
|
||||
void flushQscoreEventOutbox().catch((error) => log.error({ error }, "initial QScore event outbox flush failed"));
|
||||
return () => clearInterval(timer);
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { and, eq, or } from "drizzle-orm";
|
||||
import { db } from "../db/client.js";
|
||||
import { growEvents, missionServiceSessions, users, type GrowEventRow } from "../db/schema.js";
|
||||
import { eventOutbox, growEvents, missionServiceSessions, users, type GrowEventRow } from "../db/schema.js";
|
||||
import { asRecord, getString, type GrowEventEnvelope } from "./envelope.js";
|
||||
import { normalizeGrowEvent } from "./normalize.js";
|
||||
import { QSCORE_GROW_EVENT_DESTINATION, QSCORE_GROW_EVENT_TOPIC } from "./qscore-event-outbox.js";
|
||||
import { DEFAULT_QSCORE_ORG_ID } from "../services/qscore-proxy.js";
|
||||
|
||||
async function ensureUser(userId: string) {
|
||||
await db
|
||||
@@ -11,81 +13,285 @@ async function ensureUser(userId: string) {
|
||||
.onConflictDoNothing();
|
||||
}
|
||||
|
||||
type ServiceSessionLink = Pick<
|
||||
typeof missionServiceSessions.$inferSelect,
|
||||
"userId" | "missionInstanceId" | "missionId" | "stageId" | "metadata"
|
||||
>;
|
||||
|
||||
export function correlateEventWithServiceSession(
|
||||
event: GrowEventEnvelope,
|
||||
resolvedUserId: string | undefined,
|
||||
link: ServiceSessionLink | undefined,
|
||||
): GrowEventEnvelope {
|
||||
if (!resolvedUserId || !link || link.userId !== resolvedUserId) return event;
|
||||
const metadata = asRecord(link.metadata);
|
||||
const taskId = getString(metadata.task_id ?? metadata.taskId ?? metadata.curatorTaskId ?? metadata.curator_task_id);
|
||||
const missionInstanceId = getString(link.missionInstanceId ?? metadata.missionInstanceId ?? metadata.mission_instance_id);
|
||||
const missionId = getString(link.missionId ?? metadata.missionId ?? metadata.mission_id);
|
||||
const stageId = getString(link.stageId ?? metadata.stageId ?? metadata.stage_id);
|
||||
const eventCorrelation = event.correlation ?? {};
|
||||
const explicitTaskId = getString(eventCorrelation.task_id ?? eventCorrelation.taskId ?? eventCorrelation.curatorTaskId ?? eventCorrelation.curator_task_id);
|
||||
const correlation = {
|
||||
...(taskId && !explicitTaskId ? { task_id: taskId } : {}),
|
||||
...(missionInstanceId && !getString(eventCorrelation.missionInstanceId) ? { missionInstanceId } : {}),
|
||||
...(missionId && !getString(eventCorrelation.missionId) ? { missionId } : {}),
|
||||
...(stageId && !getString(eventCorrelation.stageId) ? { stageId } : {}),
|
||||
...eventCorrelation,
|
||||
};
|
||||
const mission = event.mission ?? (
|
||||
missionInstanceId || missionId || stageId
|
||||
? { instanceId: missionInstanceId, missionId, stageId }
|
||||
: undefined
|
||||
);
|
||||
return { ...event, correlation, mission };
|
||||
}
|
||||
|
||||
export function serviceEventExternalIds(event: GrowEventEnvelope): string[] {
|
||||
const correlation = event.correlation ?? {};
|
||||
const payload = event.payload ?? {};
|
||||
const subject = event.subject ?? {};
|
||||
return [...new Set([
|
||||
correlation.sessionId,
|
||||
correlation.session_id,
|
||||
correlation.externalId,
|
||||
correlation.external_id,
|
||||
correlation.resumeId,
|
||||
correlation.resume_id,
|
||||
correlation.courseId,
|
||||
correlation.course_id,
|
||||
correlation.lessonId,
|
||||
correlation.lesson_id,
|
||||
correlation.assessmentId,
|
||||
correlation.assessment_id,
|
||||
subject.externalId,
|
||||
payload.sessionId,
|
||||
payload.session_id,
|
||||
payload.externalId,
|
||||
payload.external_id,
|
||||
payload.resumeId,
|
||||
payload.resume_id,
|
||||
payload.courseId,
|
||||
payload.course_id,
|
||||
payload.lessonId,
|
||||
payload.lesson_id,
|
||||
payload.assessmentId,
|
||||
payload.assessment_id,
|
||||
].map(getString).filter((value): value is string => Boolean(value)))];
|
||||
}
|
||||
|
||||
|
||||
async function resolveUserId(event: GrowEventEnvelope): Promise<string | undefined> {
|
||||
if (event.userId) return event.userId;
|
||||
const sessionId = getString(event.correlation?.sessionId ?? event.correlation?.session_id);
|
||||
const externalIds = serviceEventExternalIds(event);
|
||||
const serviceId = normalizeServiceId(event.source);
|
||||
if (sessionId) {
|
||||
const [row] = await db
|
||||
.select({ userId: missionServiceSessions.userId })
|
||||
.from(missionServiceSessions)
|
||||
.where(and(eq(missionServiceSessions.serviceId, serviceId), eq(missionServiceSessions.externalId, sessionId)))
|
||||
.limit(1);
|
||||
if (row?.userId) return row.userId;
|
||||
}
|
||||
return undefined;
|
||||
if (!externalIds.length) return undefined;
|
||||
const rows = await db
|
||||
.select({ userId: missionServiceSessions.userId })
|
||||
.from(missionServiceSessions)
|
||||
.where(and(
|
||||
eq(missionServiceSessions.serviceId, serviceId),
|
||||
inArrayValues(missionServiceSessions.externalId, externalIds),
|
||||
));
|
||||
return resolveUniqueSessionUserId(rows);
|
||||
}
|
||||
|
||||
function inArrayValues(column: typeof missionServiceSessions.externalId, values: string[]) {
|
||||
return or(...values.map((value) => eq(column, value)))!;
|
||||
}
|
||||
|
||||
// External service IDs are not globally unique. Ambiguous ownership must remain
|
||||
// unresolved rather than silently attaching another user's Curator task context.
|
||||
export function resolveUniqueSessionUserId(rows: Array<{ userId: string }>): string | undefined {
|
||||
const userIds = new Set(rows.map((row) => row.userId));
|
||||
return userIds.size === 1 ? [...userIds][0] : undefined;
|
||||
}
|
||||
async function resolveServiceSessionLink(event: GrowEventEnvelope, userId: string | undefined): Promise<ServiceSessionLink | undefined> {
|
||||
if (!userId) return undefined;
|
||||
const externalIds = serviceEventExternalIds(event);
|
||||
if (!externalIds.length) return undefined;
|
||||
const serviceId = normalizeServiceId(event.source);
|
||||
const [row] = await db
|
||||
.select({
|
||||
userId: missionServiceSessions.userId,
|
||||
missionInstanceId: missionServiceSessions.missionInstanceId,
|
||||
missionId: missionServiceSessions.missionId,
|
||||
stageId: missionServiceSessions.stageId,
|
||||
metadata: missionServiceSessions.metadata,
|
||||
})
|
||||
.from(missionServiceSessions)
|
||||
.where(and(
|
||||
eq(missionServiceSessions.userId, userId),
|
||||
eq(missionServiceSessions.serviceId, serviceId),
|
||||
inArrayValues(missionServiceSessions.externalId, externalIds),
|
||||
))
|
||||
.limit(1);
|
||||
return row;
|
||||
|
||||
}
|
||||
|
||||
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("qscore")) return "qscore";
|
||||
return source;
|
||||
const normalized = source.toLowerCase();
|
||||
if (normalized.includes("interview")) return "interview";
|
||||
if (normalized.includes("roleplay")) return "roleplay";
|
||||
if (normalized.includes("resume")) return "resume";
|
||||
if (normalized.includes("cover-letter") || normalized.includes("cover_letter")) return "cover_letter";
|
||||
if (normalized.includes("course")) return "course";
|
||||
if (normalized.includes("qscore") || normalized.includes("q-score")) return "qscore";
|
||||
if (normalized.includes("assessment")) return "assessment";
|
||||
if (normalized.includes("matchmaking")) return "matchmaking";
|
||||
if (normalized.includes("social") || normalized.includes("brand")) return "brand";
|
||||
return normalized;
|
||||
}
|
||||
|
||||
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);
|
||||
const sessionLink = await resolveServiceSessionLink(normalized, resolvedUserId);
|
||||
const correlated = correlateEventWithServiceSession(normalized, resolvedUserId, sessionLink);
|
||||
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,
|
||||
id: correlated.id,
|
||||
userId: resolvedUserId,
|
||||
orgId: normalized.orgId,
|
||||
source: normalized.source,
|
||||
type: normalized.type,
|
||||
category: normalized.category,
|
||||
occurredAt: new Date(normalized.occurredAt),
|
||||
mission: normalized.mission as Record<string, unknown> | undefined,
|
||||
subject: normalized.subject as Record<string, unknown> | undefined,
|
||||
correlation: normalized.correlation as Record<string, unknown> | undefined,
|
||||
payload: normalized.payload,
|
||||
raw: normalized.raw ?? asRecord(input),
|
||||
dedupeKey: normalized.dedupeKey,
|
||||
orgId: correlated.orgId ?? DEFAULT_QSCORE_ORG_ID,
|
||||
source: correlated.source,
|
||||
type: correlated.type,
|
||||
category: correlated.category,
|
||||
occurredAt: new Date(correlated.occurredAt),
|
||||
mission: correlated.mission as Record<string, unknown> | undefined,
|
||||
subject: correlated.subject as Record<string, unknown> | undefined,
|
||||
correlation: correlated.correlation as Record<string, unknown> | undefined,
|
||||
payload: correlated.payload,
|
||||
raw: correlated.raw ?? asRecord(input),
|
||||
dedupeKey: correlated.dedupeKey,
|
||||
processingStatus,
|
||||
} satisfies typeof growEvents.$inferInsert;
|
||||
|
||||
const [inserted] = 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,
|
||||
},
|
||||
})
|
||||
.returning();
|
||||
// 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 canonicalEvent = canonicalGrowEventEnvelope(correlated, resolvedUserId);
|
||||
|
||||
if (!inserted) throw new Error("failed to record grow event");
|
||||
return inserted;
|
||||
const insertedRows = await db.transaction(async (tx) => {
|
||||
const rows = await tx
|
||||
.insert(growEvents)
|
||||
.values(values)
|
||||
.onConflictDoNothing()
|
||||
.returning();
|
||||
const inserted = rows[0];
|
||||
if (inserted?.userId) {
|
||||
await tx.insert(eventOutbox).values({
|
||||
topic: QSCORE_GROW_EVENT_TOPIC,
|
||||
aggregateId: inserted.id,
|
||||
destination: QSCORE_GROW_EVENT_DESTINATION,
|
||||
payload: canonicalEvent,
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"idempotency-key": inserted.id,
|
||||
},
|
||||
}).onConflictDoNothing();
|
||||
}
|
||||
return rows;
|
||||
});
|
||||
|
||||
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 };
|
||||
}
|
||||
|
||||
export function canonicalGrowEventEnvelope(normalized: GrowEventEnvelope, resolvedUserId?: string) {
|
||||
// `normalizeGrowEvent` may translate legacy producer aliases for backend-only
|
||||
// consumers (for example curator completion matching). QScore owns signal
|
||||
// interpretation, so its generic outbox must retain the producer's event type
|
||||
// and unmodified payload instead of publishing that backend alias.
|
||||
const producer = asRecord(normalized.raw);
|
||||
const producerType = getString(producer.type ?? producer.event_type ?? producer.action);
|
||||
const producerCorrelation = asRecord(producer.correlation);
|
||||
const enrichedCorrelation = asRecord(normalized.correlation);
|
||||
const hasProducerTaskAlias = ["task_id", "taskId", "curatorTaskId", "curator_task_id"]
|
||||
.some((key) => producerCorrelation[key] !== undefined);
|
||||
const correlation = { ...producerCorrelation, ...enrichedCorrelation };
|
||||
// Avoid inventing a snake-case alias when the producer already supplied its
|
||||
// own task key. Backend-derived task_id remains when no producer task exists.
|
||||
if (hasProducerTaskAlias && producerCorrelation.task_id === undefined) delete correlation.task_id;
|
||||
return compactRecord({
|
||||
id: normalized.id,
|
||||
userId: resolvedUserId,
|
||||
orgId: normalized.orgId ?? DEFAULT_QSCORE_ORG_ID,
|
||||
source: normalized.source,
|
||||
type: producerType ?? normalized.type,
|
||||
category: normalized.category,
|
||||
occurredAt: normalized.occurredAt,
|
||||
mission: normalized.mission,
|
||||
subject: normalized.subject,
|
||||
correlation,
|
||||
payload: normalized.payload,
|
||||
raw: normalized.raw,
|
||||
dedupeKey: normalized.dedupeKey,
|
||||
});
|
||||
}
|
||||
|
||||
function compactRecord(value: Record<string, unknown>): Record<string, unknown> {
|
||||
return Object.fromEntries(Object.entries(value).filter(([, item]) => item !== undefined));
|
||||
}
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,90 +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 { isAllowedNotificationHref, 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),
|
||||
});
|
||||
|
||||
const HOME_FEED_AGENT_TIMEOUT_MS = Number(process.env.HOME_FEED_AGENT_TIMEOUT_MS ?? 8000);
|
||||
|
||||
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 (isAllowedNotificationHref(href)) return href;
|
||||
if (href.startsWith("/missions")) return "/missions/active";
|
||||
if (href.startsWith("/social")) return "/social";
|
||||
if (href.startsWith("/pathways")) return "/pathways";
|
||||
if (href.startsWith("/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,
|
||||
timeout: HOME_FEED_AGENT_TIMEOUT_MS,
|
||||
prompt: JSON.stringify({
|
||||
task: "Create coherent GrowQR home dashboard notifications from the provided service context and deterministic candidates.",
|
||||
userId: input.userId,
|
||||
serviceContext: input.context,
|
||||
deterministicCandidates: input.seeds,
|
||||
}),
|
||||
});
|
||||
|
||||
const 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 [];
|
||||
}
|
||||
}
|
||||
@@ -1,22 +1,23 @@
|
||||
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 { ensureOnboardingBaselineQscore } from "../events/onboarding-qscore.js";
|
||||
import { refineHomeNotificationsWithAgent } from "./home-feed-agent.js";
|
||||
import { buildServiceLink } from "../services/service-registry.js";
|
||||
import { DEFAULT_QSCORE_ORG_ID, getQscoreFromService, getQscoreLatestSignalsFromService } from "../services/qscore-proxy.js";
|
||||
import { listAvailableMissionDefinitions } from "../missions/registry.js";
|
||||
import { listServiceCapabilities } from "../workflows/service-capabilities.js";
|
||||
import { buildCuratorSprint } from "../v1/curator/curator-store.js";
|
||||
import {
|
||||
isAllowedNotificationHref,
|
||||
MODULE_IDS,
|
||||
@@ -31,25 +32,24 @@ import {
|
||||
|
||||
const FRESH_MS = 10 * 60 * 1000;
|
||||
const EXPIRY_MS = 24 * 60 * 60 * 1000;
|
||||
const MISSION_MODULE_LIMIT = 4;
|
||||
|
||||
const SERVICE_HREFS = {
|
||||
resume: "/agents/resume",
|
||||
interview: "/agents/interview",
|
||||
roleplay: "/agents/roleplay",
|
||||
qscore: "/agents/qscore",
|
||||
resume: buildServiceLink("resume-service", "workspace") ?? "/agents/resume",
|
||||
interview: buildServiceLink("interview-service", "discovery") ?? "/agents/interview",
|
||||
roleplay: buildServiceLink("roleplay-service", "discovery") ?? "/agents/roleplay",
|
||||
mission: "/missions/active",
|
||||
social: "/social",
|
||||
pathways: "/pathways",
|
||||
pathways: buildServiceLink("matchmaking-service", "jobs") ?? "/agents/matchmaking",
|
||||
rewards: "/rewards",
|
||||
suggestions: "/suggestions",
|
||||
productivity: "/productivity",
|
||||
productivity: buildServiceLink("courses-service", "catalog") ?? "/agents/courses",
|
||||
} as const;
|
||||
|
||||
type SeedNotification = Omit<HomeNotification, "id" | "createdAt"> & { moduleId: HomeModuleId; priority: number };
|
||||
|
||||
type HomeContext = {
|
||||
user: { id: string; email: string; displayName: string | null } | undefined;
|
||||
qscore: { score: number; signalCount: number; summary: string | null; dimensions: Record<string, unknown> | null } | undefined;
|
||||
qscore: { rqScore: 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 }>;
|
||||
@@ -97,22 +97,24 @@ function profileFromPreferences(preferences: Record<string, unknown>) {
|
||||
};
|
||||
}
|
||||
|
||||
function serviceHref(service: "resume" | "interview" | "roleplay" | "qscore", ctx: HomeContext, mission?: { instanceId?: string; missionId?: string; stageId?: string | null }) {
|
||||
function serviceHref(service: "resume" | "interview" | "roleplay", ctx: HomeContext, mission?: { instanceId?: string; missionId?: string; stageId?: string | null }) {
|
||||
const profile = profileFromPreferences(ctx.preferences);
|
||||
const params = new URLSearchParams({ source: "home" });
|
||||
if (mission?.instanceId) params.set("missionInstanceId", mission.instanceId);
|
||||
if (mission?.missionId) params.set("missionId", mission.missionId);
|
||||
if (mission?.stageId) params.set("stageId", mission.stageId);
|
||||
params.set("targetRole", profile.targetRole);
|
||||
if (profile.targetCompany !== "target company") params.set("targetCompany", profile.targetCompany);
|
||||
if (profile.industry) params.set("industry", profile.industry);
|
||||
if (profile.focusAreas.length) params.set("focusAreas", profile.focusAreas.slice(0, 4).join(","));
|
||||
if (profile.weakSpots.length) params.set("weakSpots", profile.weakSpots.slice(0, 3).join(","));
|
||||
if (profile.jobDescription) params.set("jobDescription", profile.jobDescription.slice(0, 900));
|
||||
if (service === "interview") return `/agents/interview/setup?${params.toString()}`;
|
||||
if (service === "roleplay") return `/agents/roleplay/setup?${params.toString()}`;
|
||||
if (service === "resume") return `/agents/resume?${params.toString()}`;
|
||||
return `/agents/qscore?${params.toString()}`;
|
||||
const serviceId = `${service}-service`;
|
||||
const pageId = service === "resume" ? "workspace" : "setup";
|
||||
return buildServiceLink(serviceId, pageId, {
|
||||
source: "home",
|
||||
missionInstanceId: mission?.instanceId,
|
||||
missionId: mission?.missionId,
|
||||
stageId: mission?.stageId ?? undefined,
|
||||
targetRole: profile.targetRole,
|
||||
role: profile.targetRole,
|
||||
targetCompany: profile.targetCompany !== "target company" ? profile.targetCompany : undefined,
|
||||
industry: profile.industry,
|
||||
focusAreas: profile.focusAreas.length ? profile.focusAreas.slice(0, 4).join(",") : undefined,
|
||||
weakSpots: profile.weakSpots.length ? profile.weakSpots.slice(0, 3).join(",") : undefined,
|
||||
jobDescription: profile.jobDescription?.slice(0, 900),
|
||||
type: service === "interview" ? "behavioral" : undefined,
|
||||
}) ?? SERVICE_HREFS[service];
|
||||
}
|
||||
|
||||
function sourceFromSuggestionRole(role: string): HomeSource {
|
||||
@@ -120,7 +122,7 @@ function sourceFromSuggestionRole(role: string): HomeSource {
|
||||
if (value.includes("resume")) return "resume";
|
||||
if (value.includes("roleplay")) return "roleplay";
|
||||
if (value.includes("interview")) return "interview";
|
||||
if (value.includes("q")) return "qscore";
|
||||
if (value.includes("match") || value.includes("pathway") || value.includes("job")) return "pathways";
|
||||
return "mission";
|
||||
}
|
||||
|
||||
@@ -165,24 +167,57 @@ function hasAnyRealActivity(ctx: HomeContext) {
|
||||
|
||||
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 qscore = ctx.qscore?.rqScore;
|
||||
const ats = latestScore(ctx.qscoreSignals, "resume.ats_compatibility");
|
||||
const interviewOverall = latestScore(ctx.qscoreSignals, "interview.overall_score");
|
||||
const roleplayComms = latestScore(ctx.qscoreSignals, "roleplay.communication_effectiveness");
|
||||
@@ -193,7 +228,14 @@ function buildDynamicSeeds(ctx: HomeContext): SeedNotification[] {
|
||||
const roleplayReview = serviceEvent(ctx, "roleplay.", "review");
|
||||
const resumeAnalysis = serviceEvent(ctx, "resume.", "analysis");
|
||||
|
||||
for (const suggestion of ctx.missionSuggestions.slice(0, 5)) {
|
||||
const visibleMissionSuggestions = ctx.missionSuggestions
|
||||
.filter((suggestion) => {
|
||||
const haystack = `${suggestion.role} ${suggestion.title} ${suggestion.body} ${suggestion.ctaLabel} ${suggestion.ctaHref}`.toLowerCase();
|
||||
return !haystack.includes("qscore") && !haystack.includes("q score") && !haystack.includes("assessment") && !haystack.includes("/agents/qscore");
|
||||
})
|
||||
.slice(0, 5);
|
||||
|
||||
for (const suggestion of visibleMissionSuggestions) {
|
||||
const mission = ctx.activeMissions.find((item) => item.instanceId === suggestion.missionInstanceId);
|
||||
const source = sourceFromSuggestionRole(suggestion.role);
|
||||
const href = sanitizeHref(suggestion.ctaHref, mission ? `/missions/active?missionInstanceId=${encodeURIComponent(mission.instanceId)}` : SERVICE_HREFS.mission);
|
||||
@@ -234,15 +276,15 @@ function buildDynamicSeeds(ctx: HomeContext): SeedNotification[] {
|
||||
});
|
||||
}
|
||||
|
||||
if (ctx.qscore || ctx.qscoreSignals.length) {
|
||||
if (qscore !== undefined) {
|
||||
pushSeed(seeds, {
|
||||
moduleId: "suggestions",
|
||||
title: qscore >= 80 ? "Protect your Q Score momentum" : "Raise your Q Score next",
|
||||
subtitle: qscore >= 80 ? `Readiness is trending at ${qscore}. Keep one proof action moving for ${profile.targetRole}.` : `Current estimate is ${qscore || 64}. Resume + mock practice are fastest for ${profile.targetRole}.`,
|
||||
tag: "Q Score",
|
||||
moduleId: "pathways",
|
||||
title: qscore >= 80 ? "Review your best job matches" : "Find better-fit job matches",
|
||||
subtitle: qscore >= 80 ? `Your profile signals are strong enough to compare matched roles for ${profile.targetRole}.` : `Use resume and interview signals to surface roles that fit ${profile.targetRole}.`,
|
||||
tag: "Matches",
|
||||
urgency: qscore >= 80 ? "today" : "now",
|
||||
href: serviceHref("qscore", ctx),
|
||||
source: "qscore",
|
||||
href: SERVICE_HREFS.pathways,
|
||||
source: "pathways",
|
||||
priority: 95,
|
||||
});
|
||||
}
|
||||
@@ -260,7 +302,7 @@ function buildDynamicSeeds(ctx: HomeContext): SeedNotification[] {
|
||||
});
|
||||
}
|
||||
|
||||
for (const mission of ctx.activeMissions.slice(0, 3)) {
|
||||
for (const mission of ctx.activeMissions.slice(0, MISSION_MODULE_LIMIT)) {
|
||||
pushSeed(seeds, {
|
||||
moduleId: "missions",
|
||||
title: `${mission.title} — ${mission.progressPercent}%`,
|
||||
@@ -287,17 +329,6 @@ function buildDynamicSeeds(ctx: HomeContext): SeedNotification[] {
|
||||
});
|
||||
}
|
||||
|
||||
pushSeed(seeds, {
|
||||
moduleId: "social",
|
||||
title: "Turn proof into LinkedIn updates",
|
||||
subtitle: ctx.artifacts.length ? `${ctx.artifacts.length} artifact${ctx.artifacts.length === 1 ? "" : "s"} can feed headline, featured, or post ideas.` : `Connect LinkedIn and use ${profile.targetRole} proof to improve your profile.`,
|
||||
tag: ctx.artifacts.length ? "Proof" : "Setup",
|
||||
urgency: ctx.artifacts.length ? "today" : "soon",
|
||||
href: SERVICE_HREFS.social,
|
||||
source: "social",
|
||||
priority: 70,
|
||||
});
|
||||
|
||||
if (resumeAnalysis || resumeSession || ats !== undefined) {
|
||||
pushSeed(seeds, {
|
||||
moduleId: "productivity",
|
||||
@@ -341,7 +372,7 @@ function buildDynamicSeeds(ctx: HomeContext): SeedNotification[] {
|
||||
}
|
||||
|
||||
if (!ctx.activeMissions.length) {
|
||||
pushSeed(seeds, { moduleId: "missions", title: "Start Interview-to-Offer", subtitle: `Bundle resume fit, mock practice, and Q Score deltas for ${profile.targetRole}.`, tag: "Begin", urgency: "today", href: "/missions/available", source: "mission", priority: 80 });
|
||||
pushSeed(seeds, { moduleId: "missions", title: "Start Interview-to-Offer", subtitle: `Bundle resume fit, mock practice, and matched roles for ${profile.targetRole}.`, tag: "Begin", urgency: "today", href: "/missions/available", source: "mission", priority: 80 });
|
||||
}
|
||||
|
||||
return seeds;
|
||||
@@ -349,17 +380,22 @@ function buildDynamicSeeds(ctx: HomeContext): SeedNotification[] {
|
||||
|
||||
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, latestQscoreSignals] = await Promise.all([
|
||||
getQscoreFromService(userId, DEFAULT_QSCORE_ORG_ID),
|
||||
getQscoreLatestSignalsFromService(userId, DEFAULT_QSCORE_ORG_ID),
|
||||
]);
|
||||
const qscoreSignals = (latestQscoreSignals ?? []).map((s) => {
|
||||
return {
|
||||
signalId: s.signal_id,
|
||||
score: s.score,
|
||||
source: s.source,
|
||||
updatedAt: new Date(s.last_seen_at || s.last_occurred_at || qscoreResult?.calculated_at || 0),
|
||||
};
|
||||
});
|
||||
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
|
||||
@@ -412,12 +448,12 @@ async function collectContext(userId: string, input: { userProfile?: Record<stri
|
||||
|
||||
return {
|
||||
user,
|
||||
qscore: qscore
|
||||
qscore: qscoreResult
|
||||
? {
|
||||
score: qscore.score,
|
||||
signalCount: qscore.signalCount,
|
||||
summary: qscore.summary,
|
||||
dimensions: isRecord(qscore.dimensions) ? qscore.dimensions : null,
|
||||
rqScore: qscoreResult.rq_score,
|
||||
signalCount: qscoreSignals.length,
|
||||
summary: null,
|
||||
dimensions: isRecord(qscoreResult.quotients) ? qscoreResult.quotients : null,
|
||||
}
|
||||
: undefined,
|
||||
qscoreSignals,
|
||||
@@ -456,6 +492,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)
|
||||
@@ -493,17 +543,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;
|
||||
@@ -525,24 +566,17 @@ 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,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function buildIdentity(ctx: HomeContext) {
|
||||
const score = ctx.qscore?.score && ctx.qscore.score > 0 ? ctx.qscore.score : Math.round(ctx.qscoreSignals.reduce((sum, s) => sum + s.score, 0) / Math.max(ctx.qscoreSignals.length, 1)) || 47;
|
||||
const [baselineSnapshot] = await db
|
||||
.select({ score: qscoreSnapshots.score })
|
||||
.from(qscoreSnapshots)
|
||||
.where(and(eq(qscoreSnapshots.userId, ctx.user?.id ?? ""), eq(qscoreSnapshots.snapshotType, "baseline")))
|
||||
.orderBy(desc(qscoreSnapshots.createdAt))
|
||||
.limit(1);
|
||||
const baseline = baselineSnapshot?.score ?? Math.max(35, score - 29);
|
||||
const from = Math.max(baseline, Math.min(score, score - Math.min(17, Math.max(5, score - baseline))));
|
||||
const score = ctx.qscore?.rqScore ?? null;
|
||||
const name = ctx.user?.displayName || ctx.user?.email?.split("@")[0] || "GrowQR User";
|
||||
const completedArtifacts = ctx.artifacts.filter((a) => a.status === "ready" || a.status === "approved").length;
|
||||
|
||||
@@ -550,9 +584,8 @@ async function buildIdentity(ctx: HomeContext) {
|
||||
name,
|
||||
caption: "Your living QR · scan to view profile",
|
||||
qrSrc: undefined,
|
||||
qx: { from, to: score, baseline },
|
||||
rqScore: { from: null, to: score, baseline: null },
|
||||
glance: [
|
||||
{ value: Math.max(0, score - baseline), label: "Growth" },
|
||||
{ value: Math.max(0, ctx.qscore?.signalCount ?? ctx.qscoreSignals.length), label: "Signals" },
|
||||
{ value: completedArtifacts || ctx.events.length, label: "Done" },
|
||||
],
|
||||
@@ -560,15 +593,15 @@ async function buildIdentity(ctx: HomeContext) {
|
||||
}
|
||||
|
||||
export async function getHomeFeed(userId: string, opts: { refresh?: boolean; userProfile?: Record<string, unknown>; preferences?: Record<string, unknown> } = {}): Promise<HomeFeedResponse> {
|
||||
await ensureOnboardingBaselineQscore(userId, opts.preferences);
|
||||
await 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,
|
||||
@@ -579,40 +612,7 @@ export async function getHomeFeed(userId: string, opts: { refresh?: boolean; use
|
||||
|
||||
const dayOneSeeds = buildDayOneSeeds();
|
||||
const deterministic = hasAnyRealActivity(ctx) ? buildDynamicSeeds(ctx) : dayOneSeeds;
|
||||
const agentNotifications = await refineHomeNotificationsWithAgent({
|
||||
userId,
|
||||
context: {
|
||||
qscore: ctx.qscore,
|
||||
qscoreSignals: ctx.qscoreSignals,
|
||||
activeMissions: ctx.activeMissions,
|
||||
sessions: ctx.sessions,
|
||||
artifacts: ctx.artifacts,
|
||||
recentEvents: ctx.events,
|
||||
serviceStates: ctx.serviceStates,
|
||||
missionSuggestions: ctx.missionSuggestions,
|
||||
userProfile: ctx.userProfile,
|
||||
preferences: ctx.preferences,
|
||||
routeRules: SERVICE_HREFS,
|
||||
},
|
||||
seeds: deterministic,
|
||||
});
|
||||
|
||||
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";
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -28,13 +28,13 @@ export type HomeIdentity = {
|
||||
name: string;
|
||||
caption: string;
|
||||
qrSrc?: string;
|
||||
qx: { from: number; to: number; baseline: number };
|
||||
rqScore: { from: number | null; to: number | null; baseline: number | null };
|
||||
glance: { value: number; label: string }[];
|
||||
};
|
||||
|
||||
export type HomeFeedResponse = {
|
||||
generatedAt: string;
|
||||
mode: "day1" | "dynamic" | "demo";
|
||||
mode: "day1" | "dynamic";
|
||||
identity: HomeIdentity;
|
||||
modules: HomeModule[];
|
||||
};
|
||||
@@ -42,43 +42,55 @@ export type HomeFeedResponse = {
|
||||
export const MODULE_META: Record<HomeModuleId, Omit<HomeModule, "count" | "notifications">> = {
|
||||
suggestions: { id: "suggestions", label: "Today's Queue", href: "/suggestions", accent: "orange" },
|
||||
missions: { id: "missions", label: "Missions", href: "/missions", accent: "orange" },
|
||||
social: { id: "social", label: "Social Branding", href: "/social", accent: "blue" },
|
||||
pathways: { id: "pathways", label: "Pathways", href: "/pathways", accent: "teal" },
|
||||
social: { id: "social", label: "Social Branding", href: "/agents/social-branding", accent: "blue" },
|
||||
pathways: { id: "pathways", label: "Pathways", href: "/agents/matchmaking", accent: "teal" },
|
||||
productivity: { id: "productivity", label: "Interview · Roleplay · Resume", href: "/agents", accent: "orange" },
|
||||
rewards: { id: "rewards", label: "Rewards", href: "/rewards", accent: "amber" },
|
||||
};
|
||||
|
||||
export const MODULE_IDS: HomeModuleId[] = ["suggestions", "missions", "productivity"];
|
||||
export const MODULE_IDS: HomeModuleId[] = [
|
||||
"suggestions",
|
||||
"missions",
|
||||
"social",
|
||||
"pathways",
|
||||
"productivity",
|
||||
"rewards",
|
||||
];
|
||||
|
||||
export const ALLOWED_NOTIFICATION_HREFS = new Set([
|
||||
"/suggestions",
|
||||
"/missions",
|
||||
"/missions/active",
|
||||
"/missions/available",
|
||||
"/social",
|
||||
"/pathways",
|
||||
"/productivity",
|
||||
"/agents/social-branding",
|
||||
"/agents/matchmaking",
|
||||
"/agents",
|
||||
"/rewards",
|
||||
"/agents/resume",
|
||||
"/agents/interview",
|
||||
"/agents/interview/setup",
|
||||
"/agents/interview",
|
||||
"/agents/roleplay",
|
||||
"/agents/roleplay",
|
||||
"/agents/roleplay/setup",
|
||||
"/agents/qscore",
|
||||
]);
|
||||
|
||||
export const ALLOWED_NOTIFICATION_HREF_PREFIXES = [
|
||||
"/missions/",
|
||||
"/missions/active",
|
||||
"/missions/available",
|
||||
"/agents/resume",
|
||||
"/agents/interview",
|
||||
"/agents/interview/setup",
|
||||
"/agents/interview",
|
||||
"/agents/roleplay",
|
||||
"/agents/roleplay",
|
||||
"/agents/roleplay/setup",
|
||||
"/agents/qscore",
|
||||
] as const;
|
||||
|
||||
export function isAllowedNotificationHref(href: string) {
|
||||
if (ALLOWED_NOTIFICATION_HREFS.has(href)) return true;
|
||||
return ALLOWED_NOTIFICATION_HREF_PREFIXES.some((prefix) => href === prefix || href.startsWith(`${prefix}?`));
|
||||
return ALLOWED_NOTIFICATION_HREF_PREFIXES.some((prefix) =>
|
||||
prefix.endsWith("/")
|
||||
? href.startsWith(prefix)
|
||||
: href === prefix || href.startsWith(`${prefix}?`),
|
||||
);
|
||||
}
|
||||
|
||||
14
src/index.ts
14
src/index.ts
@@ -18,14 +18,22 @@ 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 { startQscoreEventOutboxPublisher } from "./events/qscore-event-outbox.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 +49,8 @@ async function main() {
|
||||
|
||||
await reconcileOnBoot();
|
||||
startGrowEventsRedisConsumer().catch((err) => log.error({ err }, "failed to start grow events redis consumer"));
|
||||
startQscoreEventOutboxPublisher();
|
||||
startPassiveMissionReviewLoop();
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
@@ -86,7 +96,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());
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user