55 Commits

Author SHA1 Message Date
Sai-karthik
3329eeb2fd Merge PR #12: PRM-80 canonical service events 2026-06-25 12:55:07 +00:00
Sai-karthik
760103f838 fix: harden PRM-80 audit requirements 2026-06-25 12:54:54 +00:00
Sai-karthik
592bbf0f57 docs: add PRM-80 final PR audit 2026-06-25 12:24:50 +00:00
-Puter
57b31d58cc Harden conversation bootstrap on staging 2026-06-25 17:49:39 +05:30
Sai-karthik
e13dfe7d46 fix: keep qscore out of curator tasks 2026-06-25 12:19:39 +00:00
Sai-karthik
b895d6be79 fix: emit canonical service events for PRM-80 2026-06-25 11:35:37 +00:00
-Puter
91600e4e8c fix: make onboarding status ledger-only 2026-06-24 19:41:42 +05:30
-Puter
eaba7f95e3 fix: gate onboarding on ledger snapshot 2026-06-24 19:34:36 +05:30
-Puter
a442f1f53a fix: keep user activity analytics resilient 2026-06-24 19:11:18 +05:30
e88bc02012 Merge PR #11: Register curator actor
Register curatorActor in the Rivet registry and route Curator APIs through the actor handle.
2026-06-24 11:46:00 +00:00
sai karthik
13e82e0a52 Register curator actor 2026-06-24 16:53:27 +05:30
-Puter
750a6ab03b puter fix: service registry and curator onboaridng data fix, home feed route fixes 2026-06-24 15:30:00 +05:30
dv
1ecd964104 Merge pull request 'PRM-71 Backend QA Curator streak loop' (#10) from prm-71-backend-qa-curator-streak-loop into staging
Reviewed-on: #10
2026-06-23 21:26:55 +00:00
sai karthik
97ed70a921 Add PRM-71 backend QA evidence 2026-06-24 02:35:47 +05:30
Sai-karthik
0bfc18305b Project scored service completions into QScore 2026-06-23 21:01:10 +00:00
Sai-karthik
a83a27eb50 Recognize explicit abandoned curator service events 2026-06-23 20:45:12 +00:00
Sai-karthik
2de70d3b8c Solve PRM-71 curator backend QA loop 2026-06-23 20:38:33 +00:00
dv
b379d5b9fc Merge pull request '[PRM-63] Backend service registry issue solved' (#9) from prm-63-service-registry into staging
Reviewed-on: #9
2026-06-23 18:49:19 +00:00
dv
71f18fde9d Merge branch 'staging' into prm-63-service-registry 2026-06-23 18:49:11 +00:00
dv
dfdde7fa4d Merge pull request 'Backend: Add Curator 30-Day Streak Curation and Onboarding Loop' (#8) from backend/service-curation-layer into staging
Reviewed-on: #8
2026-06-23 18:48:19 +00:00
Sai-karthik
dbc984ed7f Keep home feed available when agent generation slows 2026-06-23 08:54:37 +00:00
Sai-karthik
4092025693 Backfill short home feed agent responses 2026-06-23 08:41:55 +00:00
Sai-karthik
29ed0a15cd Throttle user stack provisioning retries 2026-06-23 06:23:56 +00:00
Sai-karthik
7bad0a46c2 Repair missing home feed hrefs 2026-06-23 05:37:47 +00:00
Sai-karthik
f888a6fc0d Remove QScore estimate fallback 2026-06-23 05:04:55 +00:00
Sai-karthik
1cbd3e1a84 Repair missing home feed agent tags 2026-06-22 23:41:00 +00:00
Sai-karthik
bff336baa7 Add generated content quality smoke 2026-06-22 23:16:52 +00:00
Sai-karthik
cad24ea089 Keep public service catalog registry-only 2026-06-22 22:48:30 +00:00
Sai-karthik
459832a2a3 Add service registry acceptance probe 2026-06-22 22:35:06 +00:00
Sai-karthik
610975561f Harden home feed agent generation 2026-06-22 22:29:32 +00:00
Sai-karthik
a3a84faae7 Add service gateway write-flow smoke 2026-06-22 22:19:03 +00:00
Sai-karthik
d493ce8f33 Wire gateway user context through user service 2026-06-22 22:09:38 +00:00
Sai-karthik
fe62662cb6 Harden service gateway smoke coverage 2026-06-22 21:46:06 +00:00
Sai-karthik
6a77bb5d2e Enrich service preview gateway payloads 2026-06-22 21:31:58 +00:00
Sai-karthik
c48c28fdb3 Implement canonical service registry 2026-06-22 21:25:38 +00:00
17a888bd67 feat: update curator schemas to support 6-week plans and enhance user context
- Increased weekIndex max from 5 to 6 in curator task, plan day, and week schemas.
- Adjusted days array in curator week schema to allow a minimum of 1 day.
- Modified weeks array in curator plan schema to accept between 5 and 6 weeks.
- Enhanced CuratorUserContext type to include detailed user information and QScore.
- Introduced Curator ICP Playbooks for various user profiles with structured actions.
- Implemented onboarding loop for user onboarding completion and notification.
- Added prompt builder for generating structured 30-day plans based on user context and playbooks.
2026-06-22 22:24:27 +05:30
Sai-karthik
1be3ab1961 Refine curator sprint planning flow 2026-06-22 07:46:50 +00:00
Sai-karthik
bd582fc6c4 Make nightly analytics operational for active curator users 2026-06-20 10:15:31 +00:00
Sai-karthik
2c5cf1bcf8 Allow nightly analytics fanout runs 2026-06-20 10:11:00 +00:00
Sai-karthik
292e375a37 Use nightly analytics signals in curator day generation 2026-06-20 10:08:21 +00:00
Sai-karthik
9a6518a5d8 Add curator resume handoff from interview evidence 2026-06-20 08:50:32 +00:00
Sai-karthik
c66360cb7e Stabilize curator chat fallbacks 2026-06-19 22:44:53 +00:00
Sai-karthik
abeefc221b Propagate curator task ids through service events 2026-06-19 22:22:51 +00:00
Sai-karthik
20c18583db Build adaptive 30-day curator sprint 2026-06-19 21:51:34 +00:00
Sai-karthik
27c9f58b80 Implement ICP-driven curator sprint flow 2026-06-19 15:10:39 +00:00
Sai-karthik
c73b1a1788 Merge staging into staging-rosh preserving curator flow 2026-06-19 09:53:19 +00:00
Sai-karthik
447b5ca726 Close qscore curator tasks from review 2026-06-18 09:50:56 +00:00
Sai-karthik
e8b4634dd1 Prefer one live curator task per mission 2026-06-18 08:34:18 +00:00
Sai-karthik
a41e8be1e1 Surface qscore stages in curator daily tasks 2026-06-18 08:06:27 +00:00
Sai-karthik
38e68d8273 Ensure curator seeds three live daily missions 2026-06-18 05:55:06 +00:00
Sai-karthik
1d887bc153 Tighten curator mission generation 2026-06-17 13:05:54 +00:00
Sai-karthik
c46b9b11f6 feat: finalize curator preview handoff flow 2026-06-17 12:33:19 +00:00
Sai-karthik
fe449fdc50 refactor: replace personified workflow labels 2026-06-17 12:22:48 +00:00
dv
72b3f03dad Merge pull request 'Canonicalize mission links and preserve mission context in the service gateway' (#5) from prm-47/agent-harness-over-microservice into staging
Reviewed-on: #5
2026-06-14 13:29:25 +00:00
92ab414048 feat: enhance mission detail handling and update hrefs across services 2026-06-10 02:49:18 +05:30
70 changed files with 6477 additions and 938 deletions

View File

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

View File

@@ -0,0 +1,9 @@
# 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"
environment:
SOCIAL_BRANDING_SERVICE_URL: http://host.docker.internal:8015

View File

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

View File

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

View File

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

24
prompts/curator-v1.md Normal file
View File

@@ -0,0 +1,24 @@
# Curator V1 Conversation Prompt
You are currently speaking as the GrowQR V1 Curator through the Conversation Actor.
## Responsibilities
- Own 30 day direction, streak continuity, and service handoff decisions.
- Carry state from the conversation history and captured task memory.
- If the user gives a short answer like a role name, accept it and ask for the next missing slot.
## Guardrails
- Do not ask the same question twice.
- Do not output checklist items as separate baked chat messages.
- Never say: What should I capture next.
- Do not ask about another subtask, another mission, another service, or a later checklist item from this modal.
- When the user has answered the focused subtask enough, summarize what was captured and stop.
- If more detail is needed, ask exactly one follow-up question for the focused subtask only.
- Use captured task memory from previous subtasks as context. Do not ask the user to repeat details already captured there.
## Task Guidance
- For target-role tasks, collect target role, current background, constraints, then offer a resume or interview handoff.
- For service work, prepare preview-oriented handoffs once the focused subtask has enough context.
- Interview preview defaults: type behavioral, difficulty medium, duration 5.
- Roleplay preview should open the builder as the preview surface.
- Keep the tone concise, warm, and practical.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -9,6 +9,7 @@ import { createMissionAction, listMissionActions } from "../../missions/actions.
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 active mission.
@@ -92,16 +93,7 @@ function serviceHref(input: {
stageId?: string;
goal?: string;
}) {
const params = new URLSearchParams({
source: "mission",
missionInstanceId: input.missionInstanceId,
missionId: input.missionId,
});
if (input.stageId) params.set("stageId", input.stageId);
if (input.goal) params.set("goal", input.goal);
if (input.serviceId === "interview-service") return `/agents/interview/setup?${params.toString()}`;
if (input.serviceId === "roleplay-service") return `/agents/roleplay/setup?${params.toString()}`;
return `/agents/resume?${params.toString()}`;
return buildMissionServiceRoute(input);
}
function buildConversationTools(ctx: ConversationRuntimeContext = {}) {

View File

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

View File

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

View File

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

View File

@@ -346,73 +346,7 @@ function formatTask(task: DailyMissionTask) {
return lines.map((line) => `- ${line}`).join("\n");
}
function fallbackDailyMissionResponse(input: { task: DailyMissionTask; messages: DailyMissionMessage[] }) {
const latestUser = [...input.messages].reverse().find((message) => message.role === "user")?.content.trim() ?? "";
const userMessagesAfterStart = input.messages.filter((message) => message.role === "user");
const interviewMission = isInterviewMission(input.task);
if (latestUser.toLowerCase() === "start") {
const serviceReply = serviceStartReply(input.task);
if (serviceReply) {
return {
reply: serviceReply,
completed: false,
updateSummary: undefined,
actionLabel: undefined,
actionRoute: undefined,
};
}
if (isConfidenceCheck(input.task)) {
return {
reply: "Quick confidence check: on a scale of 1 to 10, how confident do you feel about getting interview-ready for your target role this week?",
completed: false,
updateSummary: undefined,
actionLabel: undefined,
actionRoute: undefined,
};
}
if (interviewMission) {
return {
reply: `Let us get your interview room ready. For "${input.task.subtask}", tell me the role you want to practice for and the kind of question you want first.`,
completed: false,
updateSummary: undefined,
actionLabel: undefined,
actionRoute: undefined,
};
}
return {
reply: `Alright, I have this one. For "${input.task.subtask}", tell me your answer in one or two lines. I will use it to update this task here.`,
completed: false,
updateSummary: undefined,
actionLabel: undefined,
actionRoute: undefined,
};
}
const updateSummary = compactAnswer(latestUser);
const completed = userMessagesAfterStart.some((message) => message.content.trim().toLowerCase() === "start") && latestUser.length > 0;
if (interviewMission) {
return {
reply: `Perfect. I will carry this into the interview room setup: ${updateSummary}. Your interview room link is ready.`,
completed,
updateSummary,
actionLabel: completed ? "Generate room" : undefined,
actionRoute: completed ? getInterviewActionRoute(input.task) : undefined,
};
}
return {
reply: `Nice, saved. I updated this task with: ${updateSummary}`,
completed,
updateSummary,
actionLabel: undefined,
actionRoute: undefined,
};
}
export async function runDailyMissionAgent(input: DailyMissionAgentInput) {
const started = input.messages.some(
@@ -453,8 +387,14 @@ ${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; using fallback", { message });
return fallbackDailyMissionResponse({ task: input.task, messages: input.messages });
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,
};
}
}

View File

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

View File

@@ -77,10 +77,28 @@ export const config = {
process.env.USER_SERVICE_URL ?? "http://localhost:8003",
resumePublicUrl:
process.env.RESUME_PUBLIC_URL ?? process.env.RESUME_SERVICE_URL ?? "http://localhost:8002",
coursesServiceUrl:
process.env.COURSES_SERVICE_URL ?? "http://localhost:8060",
coursesPublicUrl:
process.env.COURSES_PUBLIC_URL ?? process.env.COURSES_SERVICE_URL ?? "http://localhost:8060",
assessmentServiceUrl:
process.env.ASSESSMENT_SERVICE_URL ?? "http://localhost:8070",
assessmentPublicUrl:
process.env.ASSESSMENT_PUBLIC_URL ?? process.env.ASSESSMENT_SERVICE_URL ?? "http://localhost:8070",
matchmakingServiceUrl:
process.env.MATCHMAKING_SERVICE_URL ?? "http://localhost:8006",
matchmakingPublicUrl:
process.env.MATCHMAKING_PUBLIC_URL ?? process.env.MATCHMAKING_SERVICE_URL ?? "http://localhost:8006",
pathwaysServiceUrl:
process.env.PATHWAYS_SERVICE_URL ?? "http://localhost:8009",
pathwaysPublicUrl:
process.env.PATHWAYS_PUBLIC_URL ?? process.env.PATHWAYS_SERVICE_URL ?? "http://localhost:8009",
socialBrandingServiceUrl:
process.env.SOCIAL_BRANDING_SERVICE_URL ?? "http://localhost:8005",
socialBrandingPublicUrl:
process.env.SOCIAL_BRANDING_PUBLIC_URL ?? process.env.SOCIAL_BRANDING_SERVICE_URL ?? "http://localhost:8005",
qscorePublicUrl:
process.env.QSCORE_PUBLIC_URL ?? process.env.QSCORE_SERVICE_URL ?? "http://localhost:8000",
workflowsDashboardUrl:
process.env.WORKFLOWS_DASHBOARD_URL ??
process.env.FRONTEND_ORIGIN ??

View File

@@ -0,0 +1,14 @@
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
`);
}
export async function ensureRuntimeSchema() {
await ensureGrowConversationsMetadataColumn();
log.info("runtime schema ensured");
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -3,6 +3,7 @@ import { config } from "../config.js";
import { log } from "../log.js";
import { recordGrowEvent } from "./record-grow-event.js";
import { routeGrowEventToUserActor } from "./route-to-user-actor.js";
import { runCuratorOnboardingLoopForEventSafely } from "../v1/curator/curator-onboarding-loop.js";
// This file has two Redis ingestion modes:
// 1. Canonical GrowEvent stream: grow.events.raw — future service event bus.
@@ -100,26 +101,26 @@ function actionToEventType(serviceId: ServiceRedisSpec["serviceId"], action: str
const effective = msgAction || action || "event";
if (serviceId === "interview") {
if (effective === "interview_configured" || action === "configure_interview") return "interview.configured";
if (effective === "interview_configured" || action === "configure_interview") return "interview.session.configured";
if (effective === "review_loaded") {
const data = asRecord(message.data);
return data.status === "completed" ? "interview.review_completed" : "interview.review_processing";
return data.status === "completed" ? "interview.feedback.generated" : "interview.feedback.processing";
}
if (effective === "interview_page_loaded") return "interview.page_state_loaded";
return `interview.${effective.replaceAll("_", ".")}`;
}
if (serviceId === "roleplay") {
if (effective === "roleplay_configured" || action === "configure_roleplay") return "roleplay.configured";
if (effective === "roleplay_configured" || action === "configure_roleplay") return "roleplay.scenario.configured";
if (effective === "roleplay_review_loaded" || effective === "review_loaded") {
const data = asRecord(message.data);
return data.status === "completed" ? "roleplay.review_completed" : "roleplay.review_processing";
return data.status === "completed" ? "roleplay.feedback.generated" : "roleplay.feedback.processing";
}
if (effective === "roleplay_page_loaded") return "roleplay.page_state_loaded";
return `roleplay.${effective.replaceAll("_", ".")}`;
}
if (effective === "ai_analysis_complete" || action === "ai_analyze") return "resume.analysis_completed";
if (effective === "ai_analysis_complete" || action === "ai_analyze") return "resume.analysis.completed";
if (effective === "resume_loaded") return "resume.loaded";
if (effective === "resume_parsed") return "resume.parsed";
return `resume.${effective.replaceAll("_", ".")}`;
@@ -150,6 +151,7 @@ async function recordAndRoute(input: unknown) {
await routeGrowEventToUserActor(event).catch((err) => {
log.warn({ err, eventId: event.id, userId: event.userId }, "failed to route grow event to user actor");
});
await runCuratorOnboardingLoopForEventSafely(event);
return event;
}

View File

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

View File

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

View File

@@ -1,5 +1,6 @@
import { and, desc, eq, gt, inArray, isNull, or, sql } from "drizzle-orm";
import { db } from "../db/client.js";
import { log } from "../log.js";
import {
growActiveMissions,
growEvents,
@@ -15,8 +16,9 @@ import {
type NewGrowHomeNotification,
} from "../db/schema.js";
import { interviewService, resumeService, roleplayService } from "../services/product-service-clients.js";
import { buildServiceLink } from "../services/service-registry.js";
import { ensureOnboardingBaselineQscore } from "../events/onboarding-qscore.js";
import { refineHomeNotificationsWithAgent } from "./home-feed-agent.js";
import { HomeFeedAgentError, refineHomeNotificationsWithAgent } from "./home-feed-agent.js";
import { listAvailableMissionDefinitions } from "../missions/registry.js";
import { listServiceCapabilities } from "../workflows/service-capabilities.js";
import {
@@ -35,16 +37,16 @@ const FRESH_MS = 10 * 60 * 1000;
const EXPIRY_MS = 24 * 60 * 60 * 1000;
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",
qscore: buildServiceLink("qscore-service", "dashboard") ?? "/agents/qscore",
mission: "/missions/active",
social: "/social",
pathways: "/pathways",
social: buildServiceLink("social-branding-service", "profile") ?? "/agents/social-branding",
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 };
@@ -101,20 +103,22 @@ function profileFromPreferences(preferences: Record<string, unknown>) {
function serviceHref(service: "resume" | "interview" | "roleplay" | "qscore", 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 === "qscore" ? "qscore-service" : `${service}-service`;
const pageId = service === "resume" ? "workspace" : service === "qscore" ? "dashboard" : "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 {
@@ -631,23 +635,29 @@ export async function getHomeFeed(userId: string, opts: { refresh?: boolean; use
const dayOneSeeds = buildDayOneSeeds();
const deterministic = hasAnyRealActivity(ctx) ? buildDynamicSeeds(ctx) : dayOneSeeds;
const agentNotifications = await refineHomeNotificationsWithAgent({
userId,
context: {
qscore: ctx.qscore,
qscoreSignals: ctx.qscoreSignals,
activeMissions: ctx.activeMissions,
sessions: ctx.sessions,
artifacts: ctx.artifacts,
recentEvents: ctx.events,
serviceStates: ctx.serviceStates,
missionSuggestions: ctx.missionSuggestions,
userProfile: ctx.userProfile,
preferences: ctx.preferences,
routeRules: SERVICE_HREFS,
},
seeds: deterministic,
});
let agentNotifications: Awaited<ReturnType<typeof refineHomeNotificationsWithAgent>> = [];
try {
agentNotifications = await refineHomeNotificationsWithAgent({
userId,
context: {
qscore: ctx.qscore,
qscoreSignals: ctx.qscoreSignals,
activeMissions: ctx.activeMissions,
sessions: ctx.sessions,
artifacts: ctx.artifacts,
recentEvents: ctx.events,
serviceStates: ctx.serviceStates,
missionSuggestions: ctx.missionSuggestions,
userProfile: ctx.userProfile,
preferences: ctx.preferences,
routeRules: SERVICE_HREFS,
},
seeds: deterministic,
});
} catch (err) {
if (!(err instanceof HomeFeedAgentError)) throw err;
log.info({ userId }, "home feed agent unavailable, using deterministic notifications");
}
const generatedBy = agentNotifications.length ? "agent" : "deterministic";
const generatedSeeds: SeedNotification[] = agentNotifications.length

View File

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

View File

@@ -42,22 +42,29 @@ 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",
@@ -68,6 +75,7 @@ export const ALLOWED_NOTIFICATION_HREFS = new Set([
]);
export const ALLOWED_NOTIFICATION_HREF_PREFIXES = [
"/missions/",
"/missions/active",
"/missions/available",
"/agents/resume",
@@ -80,5 +88,9 @@ export const ALLOWED_NOTIFICATION_HREF_PREFIXES = [
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}?`),
);
}

View File

@@ -24,12 +24,14 @@ import { logRoutes } from "./routes/logs.js";
import { v1Routes } from "./v1/index.js";
import { startGrowEventsRedisConsumer } from "./events/redis-consumer.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).

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
import type { MissionReducer, MissionReduction, MissionStagePatch } from "../reducer-types.js";
import { actionForAgent, extractResumeSignals, extractWeakAreas, isInterviewEvent, isRelevantServiceEvent, isResumeEvent, isRoleplayEvent, missionExplicitlyMatches, serviceHref } from "../reducer-helpers.js";
import { actionForAgent, extractResumeSignals, extractWeakAreas, isInterviewEvent, isRelevantServiceEvent, isResumeEvent, isRoleplayEvent, missionDetailHref, missionExplicitlyMatches, serviceHref } from "../reducer-helpers.js";
export const personalBrandOpportunityReducer: MissionReducer = {
missionId: "personal-brand-opportunity-engine",
@@ -61,7 +61,7 @@ export const personalBrandOpportunityReducer: MissionReducer = {
mode: "suggestion",
title: "Turn this pitch into weekly content pillars",
body: "Use the networking practice feedback to draft 3 credibility themes for weekly posts.",
payload: { weakAreas, href: `/missions/active?missionInstanceId=${encodeURIComponent(activeMission.instanceId)}` },
payload: { weakAreas, href: missionDetailHref(activeMission.instanceId) },
sourceEventId: event.id,
idempotencyKey: `${activeMission.instanceId}:content-pillars:${event.id}`,
priority: 82,

View File

@@ -1,4 +1,5 @@
import { asRecord, getNumber, getString } from "../events/envelope.js";
import { buildServiceLink } from "../services/service-registry.js";
import type { MissionActionPatch } from "./reducer-types.js";
export function isResumeEvent(source: string, type: string) {
@@ -134,10 +135,12 @@ export function actionForAgent(missionId: string, agent: "planner" | "resume" |
}
export function serviceHref(service: "resume" | "interview" | "roleplay" | "qscore", missionInstanceId: string, missionId: string, stageId?: string) {
const params = new URLSearchParams({ source: "mission", missionInstanceId, missionId });
if (stageId) params.set("stageId", stageId);
if (service === "interview") return `/agents/interview/setup?${params.toString()}`;
if (service === "roleplay") return `/agents/roleplay/setup?${params.toString()}`;
if (service === "resume") return `/agents/resume?${params.toString()}`;
return `/agents/qscore?${params.toString()}`;
const serviceId = service === "qscore" ? "qscore-service" : `${service}-service`;
const pageId = service === "resume" ? "workspace" : service === "qscore" ? "dashboard" : "setup";
return buildServiceLink(serviceId, pageId, { source: "mission", missionInstanceId, missionId, stageId })
?? missionDetailHref(missionInstanceId);
}
export function missionDetailHref(missionInstanceId: string) {
return `/missions/${encodeURIComponent(missionInstanceId)}`;
}

View File

@@ -1,4 +1,5 @@
import type { MissionSnapshot, MissionStage } from "../actors/missions/types.js";
import { missionDetailHref } from "./reducer-helpers.js";
export type MissionSuggestionType = "action" | "practice" | "review" | "artifact" | "blocked" | "insight";
export type MissionSuggestionUrgency = "now" | "today" | "soon" | "calm";
@@ -103,7 +104,7 @@ function ctaFor(stage: MissionStage, snapshot: MissionSnapshot, context?: Missio
return { label: "Open resume", href: `/agents/resume?${params.toString()}` };
}
if (role === "Q Score") return { label: "View Q Score", href: `/agents/qscore?${params.toString()}` };
return { label: "Continue", href: `/missions/active?${params.toString()}` };
return { label: "Continue", href: `${missionDetailHref(snapshot.instanceId)}?${params.toString()}` };
}
function suggestionId(snapshot: MissionSnapshot, stage: MissionStage, suffix: string) {

View File

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

View File

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

View File

@@ -6,6 +6,7 @@ import { growEvents } from "../db/schema.js";
import { requireUser, type AuthContext } from "../auth/clerk.js";
import { recordGrowEvent } from "../events/record-grow-event.js";
import { routeGrowEventToUserActor } from "../events/route-to-user-actor.js";
import { runCuratorOnboardingLoopForEventSafely } from "../v1/curator/curator-onboarding-loop.js";
function serviceAuthorized(auth: string | undefined) {
const token = (auth ?? "").replace(/^Bearer\s+/i, "").trim();
@@ -20,7 +21,8 @@ async function ingest(body: unknown, userId?: string, source?: string) {
routed: false as const,
reason: err instanceof Error ? err.message : String(err),
}));
return { event, route };
const curatorOnboarding = await runCuratorOnboardingLoopForEventSafely(event);
return { event, route, curatorOnboarding };
}
export function eventRoutes() {
@@ -30,8 +32,8 @@ export function eventRoutes() {
app.post("/ingest", requireUser, async (c) => {
const userId = c.get("userId");
const body = await c.req.json().catch(() => ({}));
const { event, route } = await ingest(body, userId);
return c.json({ eventId: event.id, processingStatus: event.processingStatus, route }, 202);
const { event, route, curatorOnboarding } = await ingest(body, userId);
return c.json({ eventId: event.id, processingStatus: event.processingStatus, route, curatorOnboarding }, 202);
});
// Service-to-service ingress. Services may include userId directly, or we resolve it from session correlation.
@@ -41,8 +43,8 @@ export function eventRoutes() {
}
const body = await c.req.json().catch(() => ({}));
const source = c.req.header("x-growqr-source") ?? undefined;
const { event, route } = await ingest(body, undefined, source);
return c.json({ eventId: event.id, processingStatus: event.processingStatus, route }, 202);
const { event, route, curatorOnboarding } = await ingest(body, undefined, source);
return c.json({ eventId: event.id, processingStatus: event.processingStatus, route, curatorOnboarding }, 202);
});
app.get("/", requireUser, async (c) => {

View File

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

View File

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

View File

@@ -12,6 +12,8 @@ import { buildDeterministicMissionSuggestions } from "../missions/suggestions.js
import { createMissionAction, getMissionAction, listMissionActions, updateMissionActionStatus } from "../missions/actions.js";
import { recordGrowEvent } from "../events/record-grow-event.js";
import { routeGrowEventToUserActor } from "../events/route-to-user-actor.js";
import { getRequestUserPreferences } from "../services/user-context.js";
import { missionDetailHref } from "../missions/reducer-helpers.js";
let _client: Client<Registry> | null = null;
function getClient(): Client<Registry> {
@@ -104,18 +106,6 @@ async function getMissionSnapshot(userId: string, active: GrowActiveMission): Pr
return missionActorFor(userId, active.instanceId, active.actorType).getState();
}
async function getUserPreferences(req: Request): Promise<Record<string, unknown>> {
const target = new URL("/api/v1/users/me", config.userServiceUrl.replace(/\/$/, ""));
const headers = new Headers(req.headers);
headers.delete("host");
headers.delete("cookie");
const res = await fetch(target, { method: "GET", headers });
if (!res.ok) return {};
const user = await res.json().catch(() => null) as Record<string, unknown> | null;
const preferences = user?.preferences;
return preferences && typeof preferences === "object" && !Array.isArray(preferences) ? preferences as Record<string, unknown> : {};
}
export function missionRoutes() {
const app = new Hono<AuthContext>();
app.use("*", requireUser);
@@ -182,7 +172,7 @@ export function missionRoutes() {
const windowEnd = new Date();
const windowStart = new Date(windowEnd.getTime() - 24 * 60 * 60 * 1000);
const preferences = await getUserPreferences(c.req.raw);
const preferences = await getRequestUserPreferences(c.req.raw, userId) ?? {};
const run = await createMissionCoachRunPg({
userId,
missionInstanceId: active.mission.instanceId,
@@ -255,7 +245,7 @@ export function missionRoutes() {
if (active?.mission.actorType) {
await missionActorFor(userId, active.mission.instanceId, active.mission.actorType).runAction({ actionId: existing.id }).catch(() => undefined);
}
const href = typeof existing.payload?.href === "string" ? existing.payload.href : `/missions/active?missionInstanceId=${encodeURIComponent(existing.missionInstanceId)}`;
const href = typeof existing.payload?.href === "string" ? existing.payload.href : missionDetailHref(existing.missionInstanceId);
const action = await updateMissionActionStatus(userId, existing.id, {
status: "done",
result: {

View File

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

View File

@@ -1,12 +1,22 @@
import { Hono } from "hono";
import { requireUser, type AuthContext } from "../auth/clerk.js";
import { db } from "../db/client.js";
import { users, userStacks, type UserStack } from "../db/schema.js";
import { eq } from "drizzle-orm";
import { provisionUserStack } from "../docker/manager.js";
import { growEvents, users, userStacks, type UserStack } from "../db/schema.js";
import { and, desc, eq, inArray } from "drizzle-orm";
import { log } from "../log.js";
import { config } from "../config.js";
import { ensureOnboardingBaselineQscore } from "../events/onboarding-qscore.js";
import {
onboardingCompletedAtFromPreferences,
runCuratorOnboardingLoopSafely,
} from "../v1/curator/curator-onboarding-loop.js";
const ONBOARDING_LEDGER_EVENT_TYPES = [
"onboarding.snapshot.saved",
"onboarding.completed",
"user.onboarding.completed",
"profile.onboarding.completed",
] as const;
function publicStack(stack: UserStack | null | undefined) {
if (!stack) return stack;
@@ -52,6 +62,27 @@ async function ensureUserServiceUser(req: Request) {
return res.json() as Promise<Record<string, unknown>>;
}
async function getOnboardingEventStatus(userId: string) {
const [row] = await db
.select({
id: growEvents.id,
type: growEvents.type,
occurredAt: growEvents.occurredAt,
processingStatus: growEvents.processingStatus,
})
.from(growEvents)
.where(
and(
eq(growEvents.userId, userId),
inArray(growEvents.type, [...ONBOARDING_LEDGER_EVENT_TYPES]),
),
)
.orderBy(desc(growEvents.occurredAt))
.limit(1);
return row ?? null;
}
export function userRoutes() {
const app = new Hono<AuthContext>();
app.use("*", requireUser);
@@ -79,12 +110,6 @@ export function userRoutes() {
where: eq(userStacks.userId, userId),
});
if (!stack || stack.status !== "running") {
void provisionUserStack(userId).catch((err) =>
log.error({ err, userId }, "background provision failed"),
);
}
return c.json({
user: userServiceUser,
backendUser: userRow,
@@ -93,6 +118,25 @@ export function userRoutes() {
});
});
app.get("/onboarding-status", async (c) => {
const userId = c.get("userId");
const event = await getOnboardingEventStatus(userId);
return c.json({
userId,
hasOnboardingEvent: Boolean(event),
onboardingEvent: event
? {
id: event.id,
type: event.type,
occurredAt: event.occurredAt.toISOString(),
processingStatus: event.processingStatus,
}
: null,
needsOnboarding: !event,
});
});
app.get("/me", async (c) => proxyUserService(c.req.raw, "/me"));
app.patch("/me", async (c) => {
const res = await fetchUserService(c.req.raw, "/me");
@@ -102,14 +146,27 @@ export function userRoutes() {
try {
const userProfile = JSON.parse(text) as Record<string, unknown>;
const preferences = userProfile.preferences;
const normalizedPreferences = preferences && typeof preferences === "object" && !Array.isArray(preferences)
? (preferences as Record<string, unknown>)
: undefined;
await ensureOnboardingBaselineQscore(
c.get("userId"),
preferences && typeof preferences === "object" && !Array.isArray(preferences)
? (preferences as Record<string, unknown>)
: undefined,
normalizedPreferences,
);
const completedAt = onboardingCompletedAtFromPreferences(normalizedPreferences);
if (completedAt) {
await runCuratorOnboardingLoopSafely({
userId: c.get("userId"),
completedAt,
source: "user-service-profile",
context: {
preferences: normalizedPreferences,
profile: userProfile,
},
});
}
} catch (err) {
log.warn({ err, userId: c.get("userId") }, "failed to seed onboarding Q Score baseline after user update");
log.warn({ err, userId: c.get("userId") }, "failed to run onboarding side effects after user update");
}
}

View File

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

View File

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

View File

@@ -1,5 +1,6 @@
import { config } from "../config.js";
import { createHash } from "node:crypto";
import { buildServiceSessionPath } from "./service-registry.js";
// Lightweight agent reference (works with both old AgentProfile and new SubAgentModule).
export type ServiceAgentRef = {
@@ -28,32 +29,17 @@ export function buildServiceSessionUrl(
detail: Record<string, unknown> | undefined,
goal?: string,
): string | undefined {
const base = config.workflowsDashboardUrl.replace(/\/$/, "");
const sessionId = detail?.session_id ?? detail?.sessionId;
const params = new URLSearchParams();
if (sessionId && typeof sessionId === "string") params.set("session_id", sessionId);
if (goal) params.set("goal", goal);
if (service === "interview-service") {
if (!sessionId || typeof sessionId !== "string") return undefined;
params.set("role", String(detail?.target_role ?? goal ?? "Interview practice"));
params.set("type", String(detail?.interview_type ?? "behavioral"));
return `${base}/v2/service-sessions/interview?${params.toString()}`;
if (
service !== "interview-service" &&
service !== "roleplay-service" &&
service !== "resume-service"
) {
return undefined;
}
if (service === "roleplay-service") {
if (!sessionId || typeof sessionId !== "string") return undefined;
params.set("role", String(detail?.target_role ?? goal ?? "Roleplay practice"));
params.set("type", String(detail?.roleplay_type ?? "custom"));
return `${base}/v2/service-sessions/roleplay?${params.toString()}`;
}
if (service === "resume-service") {
if (goal) params.set("role", goal);
return `${base}/v2/service-sessions/resume${params.size ? `?${params.toString()}` : ""}`;
}
return undefined;
const path = buildServiceSessionPath(service, detail, goal);
if (!path) return undefined;
return `${config.workflowsDashboardUrl.replace(/\/$/, "")}${path}`;
}
function stableUuid(input: string): string {
@@ -129,7 +115,7 @@ async function runInterviewService(ctx: ServiceAgentContext): Promise<ServiceAge
);
return {
status: "ok",
summary: `Interview Agent created interview session ${detail.session_id ?? "(pending id)"} for ${ctx.goal}.`,
summary: `Mock Interview created interview session ${detail.session_id ?? "(pending id)"} for ${ctx.goal}.`,
detail: {
...detail,
target_role: payload.context.target_role,
@@ -173,7 +159,7 @@ async function runRoleplayService(ctx: ServiceAgentContext): Promise<ServiceAgen
);
return {
status: "ok",
summary: `Roleplay Agent created roleplay session ${detail.session_id ?? "(pending id)"} for ${ctx.goal}.`,
summary: `Mock Roleplay created roleplay session ${detail.session_id ?? "(pending id)"} for ${ctx.goal}.`,
detail: {
...detail,
target_role: payload.metadata.target_role,
@@ -250,18 +236,12 @@ async function runQScoreService(ctx: ServiceAgentContext): Promise<ServiceAgentR
},
);
} catch (err) {
// Graceful fallback: formula store unavailable → use static estimate
const avgSignalScore = Math.round(
signals.reduce((sum, s) => sum + s.score, 0) / signals.length,
);
return {
status: "ok",
summary: `Q Score Agent estimated Q-Score ~${avgSignalScore} (service compute unavailable: formula store may not be seeded). Based on ${signals.length} signals.`,
status: "unavailable",
summary: `Q Score compute failed; no score was generated: ${err instanceof Error ? err.message : String(err)}`,
detail: {
ingest,
estimated_q_score: avgSignalScore,
signal_scores: signals.map(s => ({ id: s.signal_id, score: s.score })),
compute_fallback: true,
compute_error: err instanceof Error ? err.message : String(err),
},
};
@@ -269,12 +249,12 @@ async function runQScoreService(ctx: ServiceAgentContext): Promise<ServiceAgentR
return {
status: "ok",
summary: `Q Score Agent computed Q-Score ${compute.q_score ?? "(unknown)"} for ${ctx.goal}.`,
summary: `Q Score computed Q Score ${compute.q_score ?? "(unknown)"} for ${ctx.goal}.`,
detail: { ingest, compute, qscore_user_id: qscoreUserId },
};
}
// ── Resume Agent (resume-builder service from growqr-app) ──
// ── Resume Building (resume-builder service from growqr-app) ──
async function runResumeAnalyze(ctx: ServiceAgentContext): Promise<ServiceAgentResult> {
// Probe resume state for the user
@@ -289,8 +269,8 @@ async function runResumeAnalyze(ctx: ServiceAgentContext): Promise<ServiceAgentR
return {
status: "ok",
summary: hasResume
? `Resume Agent found ${detail.resume_count} resume(s) at ${completeness}% completeness. Current role: ${detail.current_role ?? "unknown"}.`
: "No existing resume found. Resume Agent is ready to build one from scratch.",
? `Resume Building found ${detail.resume_count} resume(s) at ${completeness}% completeness. Current role: ${detail.current_role ?? "unknown"}.`
: "No existing resume found. Resume Building is ready to build one from scratch.",
detail: {
resume_count: detail.resume_count,
completeness,
@@ -302,7 +282,7 @@ async function runResumeAnalyze(ctx: ServiceAgentContext): Promise<ServiceAgentR
} catch (err) {
return {
status: "unavailable",
summary: `Resume Agent unavailable: ${err instanceof Error ? err.message : String(err)}`,
summary: `Resume Building unavailable: ${err instanceof Error ? err.message : String(err)}`,
};
}
}
@@ -317,7 +297,7 @@ async function runResumeTailor(ctx: ServiceAgentContext): Promise<ServiceAgentRe
// Return summary with optimization guidance
return {
status: "ok",
summary: `Resume Agent analyzed your profile for the role "${ctx.goal}". Skills detected: ${(stateResult.detail as any)?.skills?.slice(0, 5).join(", ") ?? "none"}. Resume ready for optimization.`,
summary: `Resume Building analyzed your profile for the role "${ctx.goal}". Skills detected: ${(stateResult.detail as any)?.skills?.slice(0, 5).join(", ") ?? "none"}. Resume ready for optimization.`,
detail: {
...(stateResult.detail as Record<string, unknown> ?? {}),
goal: ctx.goal,
@@ -382,11 +362,11 @@ export async function runServiceAgentProbe(
case "interview-service":
return ctx
? await runInterviewService(ctx)
: healthCheck(config.interviewServiceUrl, "Interview Agent / interview-service");
: healthCheck(config.interviewServiceUrl, "Mock Interview / interview-service");
case "roleplay-service":
return ctx
? await runRoleplayService(ctx)
: healthCheck(config.roleplayServiceUrl, "Roleplay Agent / roleplay-service");
: healthCheck(config.roleplayServiceUrl, "Mock Roleplay / roleplay-service");
case "qscore-service":
return ctx
? await runQScoreService(ctx)
@@ -394,7 +374,7 @@ export async function runServiceAgentProbe(
case "resume-service":
return ctx
? await runResumeTailor(ctx)
: healthCheck(config.resumeServiceUrl, "Resume Agent / resume-service");
: healthCheck(config.resumeServiceUrl, "Resume Building / resume-service");
case "matchmaking-service":
return ctx
? await runMatchmaking(ctx)

View File

@@ -0,0 +1,875 @@
import { config } from "../config.js";
import type { CuratorServiceId, CuratorTask } from "../v1/curator/curator-types.js";
export type QueryValue = string | number | boolean | undefined | null;
export type QueryState = Record<string, QueryValue>;
export type ServiceId =
| "interview-service"
| "roleplay-service"
| "courses-service"
| "assessment-service"
| "matchmaking-service"
| "resume-service"
| "cover-letter-service"
| "qscore-service"
| "social-branding-service";
export type ServiceCategory = "practice" | "learning" | "opportunity" | "document" | "measurement" | "profile";
export type ServiceEndpoint = {
method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
path: string;
contract: string;
usage: string;
};
export type ServiceFrontendPage = {
path: string;
aliases?: string[];
queryParams: string[];
usage: string;
};
export type ServiceRecord = {
id: ServiceId;
label: string;
description: string;
category: ServiceCategory;
enabled: boolean;
featureId: string;
promptModulePath: string;
aliases?: string[];
backend: {
baseUrl?: string;
publicUrl?: string;
healthPath: string;
endpoints: Record<string, ServiceEndpoint>;
usage: string;
};
frontend: {
baseUrl: string;
pages: Record<string, ServiceFrontendPage>;
usage: string;
};
curator: {
defaultPage: string;
defaultActionLabel: string;
defaultQueryState?: QueryState;
actionLabels?: Record<string, string>;
toolName: string;
completionEvents: string[];
qscoreSignals?: string[];
usage: string;
};
usageDocs: string[];
};
type MissionServiceId = Extract<CuratorServiceId, "interview-service" | "roleplay-service" | "resume-service">;
type MissionRouteInput = {
serviceId: MissionServiceId;
missionInstanceId: string;
missionId: string;
stageId?: string;
goal?: string;
};
type CuratorRouteInput = {
serviceId?: CuratorServiceId;
missionInstanceId?: string;
missionId?: string;
stageId?: string;
taskId?: string;
targetRole?: string;
durationMinutes?: number;
difficulty?: string;
personaId?: string;
requestedMode?: string;
roleplayBrief?: string;
};
function endpoint(
method: ServiceEndpoint["method"],
path: string,
contract: string,
usage: string,
): ServiceEndpoint {
return { method, path, contract, usage };
}
function appendQuery(pathname: string, params: QueryState = {}) {
const search = new URLSearchParams();
for (const [key, value] of Object.entries(params)) {
if (value === undefined || value === null || value === "") continue;
search.set(key, String(value));
}
const query = search.toString();
return query ? `${pathname}?${query}` : pathname;
}
function getString(value: unknown) {
return typeof value === "string" && value.trim() ? value.trim() : undefined;
}
function getSessionId(detail?: Record<string, unknown>) {
return getString(detail?.session_id ?? detail?.sessionId ?? detail?.id);
}
const frontendBaseUrl = config.workflowsDashboardUrl.replace(/\/$/, "");
const serviceRegistry: ServiceRecord[] = [
{
id: "interview-service",
label: "Interview",
description: "Configure, practice, review, and score mock interview sessions.",
category: "practice",
enabled: Boolean(config.interviewServiceUrl),
featureId: "mock-interview",
promptModulePath: "agents/interview.md",
backend: {
baseUrl: config.interviewServiceUrl,
publicUrl: config.interviewPublicUrl,
healthPath: "/health",
endpoints: {
health: endpoint("GET", "/health", "Readiness probe.", "Check service availability before a handoff."),
pageState: endpoint("GET", "/api/v1/interviews/page-state?user_id=:userId", "Returns usage and personalization state.", "Hydrate interview landing/setup pages."),
configure: endpoint("POST", "/api/v1/configure", "Creates an interview plan from user, org, persona, type, duration, and context.", "Use for committed setup requests."),
preview: endpoint("POST", "/api/v1/configure/preview", "Creates a preview plan without starting live practice.", "Use for curator/dashboard previews."),
questions: endpoint("POST", "/api/v1/configure/questions", "Edits generated questions for a session.", "Use after preview edits."),
approve: endpoint("POST", "/api/v1/configure/approve", "Approves a generated session by session_id.", "Use when a user accepts a preview."),
assignments: endpoint("GET", "/api/v1/interviews/assignments", "Lists interview assignments by email/status/limit.", "Render assigned practice work."),
createAssignments: endpoint("POST", "/api/v1/interviews/assignments", "Creates interview assignments for assignee emails.", "Admin or organization handoffs."),
unassign: endpoint("POST", "/api/v1/interviews/assignments/unassign", "Removes interview assignments.", "Admin cleanup."),
resultsBulk: endpoint("POST", "/api/v1/interviews/results:bulk", "Fetches result summaries for multiple sessions.", "Dashboard history and summaries."),
review: endpoint("GET", "/api/v1/review/:sessionId", "Returns review/status for a session.", "Poll or open feedback."),
leaderboard: endpoint("GET", "/api/v1/leaderboard", "Returns interview leaderboard.", "Leaderboard widgets."),
artifact: endpoint("GET", "/api/v1/artifacts/:sessionId/:artifactType", "Returns session artifacts.", "Fetch transcript, report, or media artifacts."),
videoUploadUrl: endpoint("POST", "/api/v1/sessions/:sessionId/video/upload-url", "Returns signed upload instructions.", "Browser upload setup."),
markVideoUploaded: endpoint("POST", "/api/v1/sessions/:sessionId/video/uploaded", "Marks uploaded video as available.", "Complete upload flow."),
},
usage: "Backend callers should use the gateway /services/interview/* routes when user auth, mission correlation, and event recording are required.",
},
frontend: {
baseUrl: frontendBaseUrl,
pages: {
discovery: {
path: "/agents/interview",
queryParams: ["fresh"],
usage: "Entry screen for role selection and resume of in-progress interview work.",
},
setup: {
path: "/agents/interview/setup",
queryParams: ["role", "type", "from_assignment", "source", "missionInstanceId", "missionId", "stageId", "curatorTaskId"],
usage: "Collects interview role, type, duration, persona, media mode, and personalization consent.",
},
preview: {
path: "/agents/interview/preview",
queryParams: ["role", "type", "persona", "duration", "difficulty", "media", "vip", "from_assignment", "personalize", "source", "missionInstanceId", "missionId", "stageId", "curatorTaskId"],
usage: "Curator default handoff. The page configures the session and opens the launch overlay.",
},
feedback: {
path: "/agents/interview/feedback",
queryParams: ["sessionId", "source", "missionInstanceId", "missionId", "stageId", "curatorTaskId"],
usage: "Opens feedback/review for a completed or processing session.",
},
session: {
path: "/agents/interview/preview",
queryParams: ["session_id", "goal", "role", "type"],
usage: "Legacy service-session launcher used by service agent results.",
},
},
usage: "Prefer preview for curator links and setup for mission CTAs that still need user choices.",
},
curator: {
defaultPage: "preview",
defaultActionLabel: "Open interview preview",
actionLabels: {
start: "Start mock",
review: "Review interview",
},
defaultQueryState: {
type: "behavioral",
persona: "payal",
duration: 5,
difficulty: "medium",
media: "video",
},
toolName: "prepare_interview_preview",
completionEvents: ["interview.session.configured", "interview.feedback.generated", "interview.completed"],
qscoreSignals: ["communication.interview", "proof.story_bank", "readiness.practice"],
usage: "Include missionInstanceId, missionId, stageId, curatorTaskId, role, and media when building stateful handoffs.",
},
usageDocs: [
"Call buildServiceLink('interview-service', 'preview', state) for curator handoffs.",
"Call getServiceEndpoint('interview-service', 'configure') for backend contract metadata.",
],
},
{
id: "roleplay-service",
label: "Roleplay",
description: "Practice negotiations, recruiter calls, stakeholder conversations, and workplace scenarios.",
category: "practice",
enabled: Boolean(config.roleplayServiceUrl),
featureId: "mock-roleplay",
promptModulePath: "agents/roleplay.md",
backend: {
baseUrl: config.roleplayServiceUrl,
publicUrl: config.roleplayPublicUrl,
healthPath: "/health",
endpoints: {
health: endpoint("GET", "/health", "Readiness probe.", "Check service availability before a handoff."),
pageState: endpoint("GET", "/api/v1/roleplays/page-state?user_id=:userId", "Returns usage and personalization state.", "Hydrate roleplay landing/setup pages."),
configure: endpoint("POST", "/api/v1/roleplays/configure", "Creates a roleplay scenario from user, org, persona, duration, brief, metadata, qscore, and user_context.", "Use for committed scenario generation."),
preview: endpoint("POST", "/api/v1/roleplays/configure/preview", "Creates a roleplay preview.", "Use for curator/dashboard previews."),
questions: endpoint("POST", "/api/v1/roleplays/configure/questions", "Edits generated roleplay questions or beats.", "Use after preview edits."),
approve: endpoint("POST", "/api/v1/roleplays/configure/approve", "Approves a generated roleplay by session_id.", "Use when a user accepts a preview."),
assignments: endpoint("GET", "/api/v1/roleplays/assignments", "Lists roleplay assignments by email/status/limit.", "Render assigned drills."),
createAssignments: endpoint("POST", "/api/v1/roleplays/assignments", "Creates roleplay assignments.", "Admin or organization handoffs."),
unassign: endpoint("POST", "/api/v1/roleplays/assignments/unassign", "Removes roleplay assignments.", "Admin cleanup."),
resultsBulk: endpoint("POST", "/api/v1/roleplays/results:bulk", "Fetches result summaries for multiple sessions.", "Dashboard history and summaries."),
review: endpoint("GET", "/api/v1/roleplays/review/:sessionId", "Returns review/status for a roleplay session.", "Poll or open feedback."),
leaderboard: endpoint("GET", "/api/v1/roleplays/leaderboard", "Returns roleplay leaderboard.", "Leaderboard widgets."),
artifact: endpoint("GET", "/api/v1/artifacts/:sessionId/:artifactType", "Returns session artifacts.", "Fetch transcript, report, or media artifacts."),
videoUploadUrl: endpoint("POST", "/api/v1/sessions/:sessionId/video/upload-url", "Returns signed upload instructions.", "Browser upload setup."),
markVideoUploaded: endpoint("POST", "/api/v1/sessions/:sessionId/video/uploaded", "Marks uploaded video as available.", "Complete upload flow."),
},
usage: "Backend callers should use the gateway /services/roleplay/* routes when user auth, mission correlation, and event recording are required.",
},
frontend: {
baseUrl: frontendBaseUrl,
pages: {
discovery: {
path: "/agents/roleplay",
queryParams: ["fresh"],
usage: "Entry screen for scenario discovery and resume of in-progress roleplay work.",
},
setup: {
path: "/agents/roleplay/setup",
queryParams: ["scenario", "scenario_text", "scenario_name", "from_assignment", "source", "missionInstanceId", "missionId", "stageId", "curatorTaskId"],
usage: "Collects roleplay scenario details and stores configure parameters for the builder.",
},
builder: {
path: "/agents/roleplay/builder",
queryParams: ["sessionId", "source", "missionInstanceId", "missionId", "stageId", "curatorTaskId", "role", "persona", "duration", "mode", "brief", "vip", "from_assignment_id"],
usage: "Curator default handoff for generating or resuming a roleplay plan.",
},
feedback: {
path: "/agents/roleplay/feedback",
queryParams: ["sessionId", "source", "missionInstanceId", "missionId", "stageId", "curatorTaskId"],
usage: "Opens feedback/review for a completed or processing session.",
},
session: {
path: "/agents/roleplay/builder",
queryParams: ["session_id", "goal", "role", "type"],
usage: "Legacy service-session launcher used by service agent results.",
},
},
usage: "Prefer builder for curator links and setup for mission CTAs that still need user choices.",
},
curator: {
defaultPage: "builder",
defaultActionLabel: "Open roleplay preview",
actionLabels: {
start: "Run drill",
review: "Review roleplay",
},
defaultQueryState: {
role: "Professional",
persona: "emma",
duration: 5,
mode: "video",
},
toolName: "prepare_roleplay_preview",
completionEvents: ["roleplay.scenario.configured", "roleplay.feedback.generated", "roleplay.completed"],
qscoreSignals: ["communication.roleplay", "networking.conversation", "readiness.practice"],
usage: "Include role, brief, mission state, and curatorTaskId when building stateful handoffs.",
},
usageDocs: [
"Call buildServiceLink('roleplay-service', 'builder', state) for curator handoffs.",
"Call getServiceEndpoint('roleplay-service', 'configure') for backend contract metadata.",
],
},
{
id: "resume-service",
label: "Resume",
description: "Build, tailor, analyze, version, and preview resumes.",
category: "document",
enabled: Boolean(config.resumeServiceUrl),
featureId: "resume-building",
promptModulePath: "agents/resume.md",
backend: {
baseUrl: config.resumeServiceUrl,
publicUrl: config.resumePublicUrl,
healthPath: "/health",
endpoints: {
health: endpoint("GET", "/health", "Readiness probe.", "Check service availability before a handoff."),
state: endpoint("GET", "/api/state/:clerkId", "Returns user resume-builder state.", "Hydrate profile and personalization context."),
templates: endpoint("GET", "/api/v1/templates", "Lists resume templates.", "Render template gallery."),
a2aTask: endpoint("POST", "/a2a/tasks", "Runs resume-builder agent actions for a user_id.", "Agent/curator orchestrated work."),
listResumes: endpoint("GET", "/api/v1/resumes?clerk_id=:clerkId", "Lists resumes for a Clerk user.", "Resume hub."),
createResume: endpoint("POST", "/api/v1/resumes", "Creates a resume.", "Resume creation."),
getResume: endpoint("GET", "/api/v1/resumes/:resumeId", "Reads a resume.", "Resume editor."),
updateResume: endpoint("PUT", "/api/v1/resumes/:resumeId", "Updates a resume.", "Resume editor saves."),
analyzeResume: endpoint("POST", "/api/v1/ai/analyze/:resumeId", "Runs AI analysis for a resume.", "Resume score and improvement plan."),
suggestions: endpoint("GET", "/api/v1/ai/suggestions/:resumeId", "Returns AI suggestions.", "Editor improvement rail."),
copilot: endpoint("POST", "/api/v1/ai/copilot", "Runs resume copilot.", "Inline editing assistant."),
versions: endpoint("GET", "/api/v1/resumes/:resumeId/versions", "Lists resume versions.", "Version history."),
preview: endpoint("GET", "/api/v1/export/resumes/:resumeId/preview", "Returns resume preview.", "PDF/preview surface."),
},
usage: "Use gateway /services/resume/* for browser-authenticated requests so Clerk bearer tokens are preserved.",
},
frontend: {
baseUrl: frontendBaseUrl,
pages: {
workspace: {
path: "/agents/resume",
queryParams: ["tab", "section", "source", "missionInstanceId", "missionId", "stageId", "curatorTaskId"],
usage: "Resume hub. Use tab=resumes by default and section to deep-link editor panels.",
},
editor: {
path: "/agents/resume/:resumeId",
queryParams: ["section"],
usage: "Resume editor for a known resume.",
},
templates: {
path: "/agents/resume/templates",
queryParams: [],
usage: "Template gallery.",
},
session: {
path: "/agents/resume",
queryParams: ["goal", "role"],
usage: "Legacy service-session launcher used by service agent results.",
},
},
usage: "Curator links should open the workspace unless a concrete resumeId is known.",
},
curator: {
defaultPage: "workspace",
defaultActionLabel: "Open resume workspace",
actionLabels: {
start: "Open resume",
review: "Review resume",
},
defaultQueryState: {
tab: "resumes",
},
toolName: "prepare_resume_upload",
completionEvents: ["resume.analysis.completed", "resume.parsed", "resume.updated"],
qscoreSignals: ["proof.resume", "readiness.ats", "profile.skills"],
usage: "Include mission state and optional section when linking into resume work.",
},
usageDocs: [
"Call buildServiceLink('resume-service', 'workspace', { tab: 'resumes' }) for curator handoffs.",
"Use the resume gateway proxy for browser calls that need Clerk auth.",
],
},
{
id: "cover-letter-service",
label: "Cover Letter",
description: "Generate, tailor, analyze, version, and preview cover letters.",
category: "document",
enabled: Boolean(config.resumeServiceUrl),
featureId: "cover-letter",
promptModulePath: "agents/cover-letter.md",
aliases: ["coverletter-service"],
backend: {
baseUrl: config.resumeServiceUrl,
publicUrl: config.resumePublicUrl,
healthPath: "/health",
endpoints: {
health: endpoint("GET", "/health", "Readiness probe inherited from resume-builder.", "Check resume-builder availability."),
listCoverLetters: endpoint("GET", "/api/v1/cover-letters", "Lists cover letters.", "Cover-letter hub."),
createCoverLetter: endpoint("POST", "/api/v1/cover-letters", "Creates a cover letter.", "Manual creation."),
getCoverLetter: endpoint("GET", "/api/v1/cover-letters/:coverLetterId", "Reads a cover letter.", "Cover-letter editor."),
updateCoverLetter: endpoint("PUT", "/api/v1/cover-letters/:coverLetterId", "Updates a cover letter.", "Editor saves."),
generate: endpoint("POST", "/api/v1/cover-letters/generate", "Generates a tailored cover letter.", "Job application handoff."),
tailor: endpoint("POST", "/api/v1/cover-letters/:coverLetterId/tailor", "Tailors an existing cover letter.", "Application-specific rewrite."),
analyze: endpoint("POST", "/api/v1/cover-letters/:coverLetterId/analyze", "Analyzes a cover letter.", "Strength and fit scoring."),
copilot: endpoint("POST", "/api/v1/cover-letters/copilot", "Runs cover-letter copilot.", "Inline editing assistant."),
preview: endpoint("GET", "/api/v1/export/cover-letters/:coverLetterId/preview", "Returns cover-letter preview.", "Preview/PDF surface."),
},
usage: "Cover letters currently live behind resume-builder and the /services/resume/* proxy.",
},
frontend: {
baseUrl: frontendBaseUrl,
pages: {
workspace: {
path: "/agents/resume",
queryParams: ["tab", "source", "missionInstanceId", "missionId", "stageId", "curatorTaskId"],
usage: "Open with tab=cover-letters to land in the cover-letter list.",
},
generator: {
path: "/agents/resume",
queryParams: ["tab", "source", "missionInstanceId", "missionId", "stageId", "curatorTaskId"],
usage: "Cover-letter generation currently lands in the shared resume workspace with tab=cover-letters.",
},
editor: {
path: "/agents/resume/cover-letters/:coverLetterId",
queryParams: [],
usage: "Cover-letter editor for a known coverLetterId.",
},
},
usage: "Use workspace with tab=cover-letters for general handoffs.",
},
curator: {
defaultPage: "workspace",
defaultActionLabel: "Open cover letters",
actionLabels: {
start: "Write cover letter",
review: "Review cover letter",
},
defaultQueryState: {
tab: "cover-letters",
},
toolName: "prepare_cover_letter_handoff",
completionEvents: ["cover_letter.generated", "cover_letter.updated", "cover_letter.analysis_completed"],
qscoreSignals: ["proof.cover_letter", "readiness.application"],
usage: "Use for application-specific artifact tasks; share mission/job context in query or payload.",
},
usageDocs: [
"Call buildServiceLink('cover-letter-service', 'workspace', { tab: 'cover-letters' }) for curator handoffs.",
"Backend endpoint metadata maps to resume-builder cover-letter APIs.",
],
},
{
id: "courses-service",
label: "Courses",
description: "Create, list, and open learning courses.",
category: "learning",
enabled: Boolean(config.coursesServiceUrl),
featureId: "courses",
promptModulePath: "agents/courses.md",
aliases: ["course-service"],
backend: {
baseUrl: config.coursesServiceUrl,
publicUrl: config.coursesPublicUrl,
healthPath: "/api/v1/health",
endpoints: {
health: endpoint("GET", "/api/v1/health", "Readiness probe.", "Check service availability."),
createCourse: endpoint("POST", "/api/v1/courses", "Creates a course.", "Admin or generated course creation."),
listCourses: endpoint("GET", "/api/v1/courses", "Lists courses with pagination/query filters.", "Course catalog."),
getCourse: endpoint("GET", "/api/v1/courses/:courseId", "Reads course details.", "Course detail page."),
},
usage: "Use for learning plan/course catalog handoffs; course generation stays in the service.",
},
frontend: {
baseUrl: frontendBaseUrl,
pages: {
catalog: {
path: "/agents/courses",
queryParams: ["source", "missionInstanceId", "missionId", "stageId", "curatorTaskId"],
usage: "Course catalog and learning entry point.",
},
assigned: {
path: "/agents/courses/assigned",
queryParams: ["source", "missionInstanceId", "missionId", "stageId", "curatorTaskId"],
usage: "Assigned course rail.",
},
lesson: {
path: "/agents/courses/assigned/:assignmentCode/lesson/:lessonId",
queryParams: [],
usage: "Assigned course lesson player.",
},
},
usage: "Open catalog for general learning handoffs.",
},
curator: {
defaultPage: "catalog",
defaultActionLabel: "Open courses",
actionLabels: {
start: "Start course",
},
toolName: "prepare_course_handoff",
completionEvents: ["course.started", "course.completed"],
qscoreSignals: ["skills.learning", "readiness.course_progress"],
usage: "Use for skill-gap tasks that should become learning work.",
},
usageDocs: ["Call buildServiceLink('courses-service', 'catalog', state) for course handoffs."],
},
{
id: "assessment-service",
label: "Assessment",
description: "Create, list, read, and submit assessments.",
category: "measurement",
enabled: Boolean(config.assessmentServiceUrl),
featureId: "assessment",
promptModulePath: "agents/assessment.md",
backend: {
baseUrl: config.assessmentServiceUrl,
publicUrl: config.assessmentPublicUrl,
healthPath: "/api/v1/health",
endpoints: {
health: endpoint("GET", "/api/v1/health", "Readiness probe.", "Check service availability."),
createAssessment: endpoint("POST", "/api/v1/assessments", "Creates an assessment.", "Admin or generated assessment creation."),
listAssessments: endpoint("GET", "/api/v1/assessments", "Lists assessments with pagination/query filters.", "Assessment catalog."),
getAssessment: endpoint("GET", "/api/v1/assessments/:assessmentId", "Reads assessment details.", "Assessment page."),
submitAssessment: endpoint("POST", "/api/v1/assessments/:assessmentId/submit", "Submits answers and returns assessment state.", "Completion flow."),
},
usage: "Use for measurement tasks and proof of skill checks.",
},
frontend: {
baseUrl: frontendBaseUrl,
pages: {
assessment: {
path: "/agents/assessments",
queryParams: ["source", "missionInstanceId", "missionId", "stageId", "curatorTaskId"],
usage: "Assessment landing and active assessment surface.",
},
},
usage: "Open assessment for skill/readiness measurement handoffs.",
},
curator: {
defaultPage: "assessment",
defaultActionLabel: "Open assessment",
actionLabels: {
start: "Start assessment",
review: "Review assessment",
},
toolName: "prepare_assessment_handoff",
completionEvents: ["assessment.started", "assessment.submitted", "assessment.completed"],
qscoreSignals: ["skills.assessment", "readiness.measurement"],
usage: "Use when a task needs a scoreable assessment rather than practice.",
},
usageDocs: ["Call buildServiceLink('assessment-service', 'assessment', state) for assessment handoffs."],
},
{
id: "matchmaking-service",
label: "Matchmaking",
description: "Match users to opportunities, employers, mentors, and networking targets.",
category: "opportunity",
enabled: Boolean(config.matchmakingServiceUrl),
featureId: "matchmaking",
promptModulePath: "agents/matchmaking.md",
aliases: ["jobs-service"],
backend: {
baseUrl: config.matchmakingServiceUrl,
publicUrl: config.matchmakingPublicUrl,
healthPath: "/api/v1/health",
endpoints: {
health: endpoint("GET", "/api/v1/health", "Readiness probe.", "Check service availability."),
preferences: endpoint("GET", "/api/v1/preferences/:userId", "Reads matching preferences.", "Hydrate feed filters and personalization."),
writePreferences: endpoint("POST", "/api/v1/preferences", "Writes matching preferences.", "Preference onboarding."),
feed: endpoint("GET", "/api/v1/feed", "Returns matched opportunity feed.", "Job/opportunity feed."),
feedAction: endpoint("POST", "/api/v1/feed/actions", "Records feed actions.", "Save/apply/dismiss tracking."),
opportunity: endpoint("GET", "/api/v1/opportunities/:opportunityId", "Reads opportunity details.", "Opportunity detail panel."),
a2aTask: endpoint("POST", "/a2a/tasks", "Runs matching agent tasks.", "Agent/curator orchestrated work."),
},
usage: "Use for opportunity matching and feed intelligence; keep user-specific actions through authenticated gateway routes when added.",
},
frontend: {
baseUrl: frontendBaseUrl,
pages: {
jobs: {
path: "/agents/matchmaking",
queryParams: ["source", "missionInstanceId", "missionId", "stageId", "curatorTaskId", "query", "role", "location"],
usage: "Job/opportunity matching dashboard.",
},
},
usage: "Use matchmaking for immediate opportunity matching and Scout-style recommendations.",
},
curator: {
defaultPage: "jobs",
defaultActionLabel: "Open matches",
actionLabels: {
start: "View matches",
review: "Review matches",
},
toolName: "prepare_matchmaking_handoff",
completionEvents: ["matchmaking.feed_viewed", "matchmaking.match_saved", "matchmaking.preference_updated"],
qscoreSignals: ["market.matches", "networking.opportunities"],
usage: "Use for immediate opportunity matching or mentor/network suggestions.",
},
usageDocs: [
"Call buildServiceLink('matchmaking-service', 'jobs', state) for job matching.",
"Use the canonical /agents/matchmaking page for Scout-style opportunity review.",
],
},
{
id: "qscore-service",
label: "QScore",
description: "Analyze readiness signals and expose score projections.",
category: "measurement",
enabled: Boolean(config.qscoreServiceUrl),
featureId: "q-score",
promptModulePath: "agents/qscore.md",
aliases: ["q-score-service"],
backend: {
baseUrl: config.qscoreServiceUrl,
publicUrl: config.qscorePublicUrl,
healthPath: "/health",
endpoints: {
health: endpoint("GET", "/health", "Readiness probe.", "Check service availability."),
currentGateway: endpoint("GET", "/services/qscore/current", "Backend-projected current score and latest signals.", "Dashboard QScore panel."),
ingest: endpoint("POST", "/api/v1/signals", "Ingests score signals when available.", "Service-to-service signal updates."),
compute: endpoint("POST", "/api/v1/score/compute", "Computes or refreshes score when available.", "Score recalculation."),
},
usage: "Use backend gateway /services/qscore/current for dashboard-safe reads; direct service APIs vary by QScore deployment.",
},
frontend: {
baseUrl: frontendBaseUrl,
pages: {
dashboard: {
path: "/agents/qscore",
queryParams: ["source", "missionInstanceId", "missionId", "stageId", "curatorTaskId"],
usage: "Dedicated Q Score service page with live readiness signals.",
},
},
usage: "Open the dedicated Q Score service page for score review.",
},
curator: {
defaultPage: "dashboard",
defaultActionLabel: "Review QScore",
actionLabels: {
review: "Review QScore",
},
toolName: "prepare_qscore_review",
completionEvents: ["qscore.updated", "qscore.signal.projected"],
qscoreSignals: ["qscore.updated", "qscore.signal.projected"],
usage: "Use for measurement and projected readiness review tasks.",
},
usageDocs: ["Call buildServiceLink('qscore-service', 'dashboard', state) for QScore handoffs."],
},
{
id: "social-branding-service",
label: "Social Branding",
description: "Build and optimize professional profile, LinkedIn, content, and brand signals.",
category: "profile",
enabled: Boolean(config.socialBrandingServiceUrl),
featureId: "social-branding",
promptModulePath: "agents/social-branding.md",
aliases: ["social-service"],
backend: {
baseUrl: config.socialBrandingServiceUrl,
publicUrl: config.socialBrandingPublicUrl,
healthPath: "/health",
endpoints: {
health: endpoint("GET", "/health", "Readiness probe.", "Check service availability."),
state: endpoint("GET", "/api/state/:clerkId", "Reads social/profile state.", "Hydrate personalization context."),
profile: endpoint("GET", "/api/v1/profile", "Reads profile data when available.", "Social profile page."),
linkedin: endpoint("POST", "/api/v1/linkedin", "Connects or imports LinkedIn data when available.", "LinkedIn onboarding."),
analyze: endpoint("POST", "/api/v1/analyze", "Analyzes profile/social brand when available.", "Brand improvement tasks."),
},
usage: "Use /services/social/* gateway proxy for browser-authenticated profile calls.",
},
frontend: {
baseUrl: frontendBaseUrl,
pages: {
profile: {
path: "/agents/social-branding",
queryParams: ["source", "missionInstanceId", "missionId", "stageId", "curatorTaskId"],
usage: "Social/profile improvement page.",
},
},
usage: "Open profile for branding, LinkedIn, and social proof handoffs.",
},
curator: {
defaultPage: "profile",
defaultActionLabel: "Open social profile flow",
actionLabels: {
start: "Improve profile",
review: "Review profile",
},
toolName: "prepare_social_branding_handoff",
completionEvents: ["social.profile_updated", "social.linkedin_connected", "social.branding_analyzed"],
qscoreSignals: ["profile.linkedin", "proof.visibility", "networking.brand"],
usage: "Use for profile visibility, LinkedIn cleanup, and social proof tasks.",
},
usageDocs: ["Call buildServiceLink('social-branding-service', 'profile', state) for social branding handoffs."],
},
];
const serviceAliases = new Map<string, ServiceId>();
for (const service of serviceRegistry) {
serviceAliases.set(service.id, service.id);
for (const alias of service.aliases ?? []) serviceAliases.set(alias, service.id);
}
export function normalizeServiceId(serviceId?: string | null): ServiceId | undefined {
if (!serviceId) return undefined;
return serviceAliases.get(serviceId);
}
export function listServices() {
return serviceRegistry;
}
export function getService(serviceId?: string | null) {
const normalized = normalizeServiceId(serviceId);
return normalized ? serviceRegistry.find((service) => service.id === normalized) : undefined;
}
export function getServiceBackend(serviceId?: string | null) {
return getService(serviceId)?.backend;
}
export function getServiceFrontend(serviceId?: string | null) {
return getService(serviceId)?.frontend;
}
export function getServiceEndpoint(serviceId: string | undefined, endpointId: string) {
return getService(serviceId)?.backend.endpoints[endpointId];
}
export function getServiceUsageDocs(serviceId?: string | null) {
return getService(serviceId)?.usageDocs ?? [];
}
function resolvePage(service: ServiceRecord, pageId?: string) {
const selectedPageId = pageId || service.curator.defaultPage;
const direct = service.frontend.pages[selectedPageId];
if (direct) return direct;
return Object.values(service.frontend.pages).find((page) => page.aliases?.includes(selectedPageId));
}
export function buildServiceLink(serviceId: string | undefined, pageId?: string, state: QueryState = {}) {
const service = getService(serviceId);
if (!service) return undefined;
const page = resolvePage(service, pageId);
if (!page) return undefined;
const includeDefaultState = !pageId || pageId === service.curator.defaultPage;
return appendQuery(page.path, {
...(includeDefaultState ? service.curator.defaultQueryState : {}),
...state,
});
}
export function listServicesForCatalog() {
return serviceRegistry.map((service) => ({
id: service.id,
label: service.label,
description: service.description,
category: service.category,
enabled: service.enabled,
featureId: service.featureId,
backend: {
publicUrl: service.backend.publicUrl,
healthPath: service.backend.healthPath,
endpoints: service.backend.endpoints,
usage: service.backend.usage,
},
frontend: service.frontend,
curator: service.curator,
usageDocs: service.usageDocs,
}));
}
export function buildServiceSessionPath(
serviceId: MissionServiceId,
detail?: Record<string, unknown>,
goal?: string,
) {
const sessionId = getSessionId(detail);
if (serviceId === "interview-service") {
if (!sessionId) return undefined;
return buildServiceLink(serviceId, "session", {
session_id: sessionId,
goal,
role: getString(detail?.target_role) ?? goal ?? "Interview practice",
type: getString(detail?.interview_type) ?? "behavioral",
});
}
if (serviceId === "roleplay-service") {
if (!sessionId) return undefined;
return buildServiceLink(serviceId, "session", {
session_id: sessionId,
goal,
role: getString(detail?.target_role) ?? goal ?? "Roleplay practice",
type: getString(detail?.roleplay_type) ?? "custom",
});
}
return buildServiceLink(serviceId, "session", {
goal,
role: goal,
});
}
export function buildMissionServiceRoute(input: MissionRouteInput) {
const pageId = input.serviceId === "resume-service" ? "workspace" : "setup";
return buildServiceLink(input.serviceId, pageId, {
source: "mission",
missionInstanceId: input.missionInstanceId,
missionId: input.missionId,
stageId: input.stageId,
goal: input.goal,
role: input.goal,
type: input.serviceId === "interview-service" ? "behavioral" : undefined,
}) ?? appendQuery("/missions/active", { missionInstanceId: input.missionInstanceId });
}
function curatorBaseParams(input: CuratorRouteInput) {
return {
source: "curator-v1",
missionInstanceId: input.missionInstanceId,
missionId: input.missionId,
stageId: input.stageId,
curatorTaskId: input.taskId,
};
}
export function buildCuratorServiceRoute(input: CuratorRouteInput) {
const service = getService(input.serviceId);
if (!service) {
return input.missionInstanceId
? appendQuery("/missions/active", { missionInstanceId: input.missionInstanceId })
: "/missions/active";
}
const state: QueryState = {
...curatorBaseParams(input),
};
if (service.id === "interview-service") {
state.role = input.targetRole?.trim() || "Product Manager";
state.type = "behavioral";
state.persona = input.personaId ?? "payal";
state.duration = input.durationMinutes ?? 5;
state.difficulty = input.difficulty ?? "medium";
state.media = input.requestedMode ?? "video";
}
if (service.id === "roleplay-service") {
state.role = input.targetRole?.trim() || "Professional";
state.persona = input.personaId ?? "emma";
state.duration = input.durationMinutes ?? 5;
state.mode = input.requestedMode ?? "video";
state.brief = input.roleplayBrief;
}
return buildServiceLink(service.id, service.curator.defaultPage, state)
?? appendQuery("/missions/active", { missionInstanceId: input.missionInstanceId });
}
export function getServiceDisplayName(serviceId?: string, fallback = "Mission planner") {
return getService(serviceId)?.label ?? fallback;
}
export function getServiceToolName(serviceId?: string) {
return getService(serviceId)?.curator.toolName ?? "prepare_mission_step";
}
export function getCompletionEvents(serviceId?: string) {
return getService(serviceId)?.curator.completionEvents ?? ["curator.task.completed"];
}
export const getServiceCompletionEvents = getCompletionEvents;
export function getServiceActionLabel(serviceId?: string, actionId?: string): string;
export function getServiceActionLabel(task: Pick<CuratorTask, "serviceId" | "cta">): string;
export function getServiceActionLabel(
input?: string | Pick<CuratorTask, "serviceId" | "cta">,
actionId?: string,
) {
if (typeof input === "object") {
const service = getService(input.serviceId);
if (actionId && service?.curator.actionLabels?.[actionId]) return service.curator.actionLabels[actionId];
return service?.curator.defaultActionLabel ?? input.cta ?? "Open";
}
const service = getService(input);
if (actionId && service?.curator.actionLabels?.[actionId]) return service.curator.actionLabels[actionId];
return service?.curator.defaultActionLabel ?? "Open";
}

View File

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

View File

@@ -1,13 +1,13 @@
import { generateText, tool } from "ai";
import { z } from "zod";
import { desc, eq } from "drizzle-orm";
import { desc, eq, gte } from "drizzle-orm";
import { createClient, type Client } from "rivetkit/client";
import { config } from "../../config.js";
import type { Registry } from "../../actors/registry.js";
import { getConversationModel } from "../../actors/conversation/agent.js";
import { db } from "../../db/client.js";
import { growConversationMessages, growEvents, users } from "../../db/schema.js";
import { curatorActor } from "../curator/curator-actor.js";
import { growConversationMessages, growEvents } from "../../db/schema.js";
import { curatorService } from "../curator/curator-actor.js";
import { curatorImprovementSignalSchema } from "../curator/curator-types.js";
let _client: Client<Registry> | null = null;
@@ -57,7 +57,7 @@ export const analyticsTools = {
apply_improvement_to_curator: tool({
description: "Apply generated improvement signals to the curator.",
inputSchema: z.object({ userId: z.string(), date: z.string(), signals: z.array(curatorImprovementSignalSchema) }),
execute: async ({ userId, date, signals }) => curatorActor.applyImprovementSignals({ userId, date, signals }),
execute: async ({ userId, date, signals }) => curatorService.applyImprovementSignals({ userId, date, signals }),
}),
};
@@ -103,15 +103,20 @@ export const v1AnalyticsActor = {
},
async applyImprovementSignals(input: { userId: string; date: string; signals: z.infer<typeof curatorImprovementSignalSchema>[] }) {
return curatorActor.applyImprovementSignals(input);
return curatorService.applyImprovementSignals(input);
},
async runNightly(input: { date: string; userId?: string }) {
const userRows = input.userId
? [{ id: input.userId }]
: await db.select({ id: users.id }).from(users).limit(500);
: await db
.selectDistinct({ id: growEvents.userId })
.from(growEvents)
.where(gte(growEvents.occurredAt, new Date(Date.now() - 7 * 86400000)))
.limit(200);
let improvementSignalsCreated = 0;
for (const user of userRows) {
if (!user.id) continue;
const signals = await this.generateImprovementSignals({ userId: user.id, date: input.date });
improvementSignalsCreated += signals.length;
await this.applyImprovementSignals({ userId: user.id, date: input.date, signals });

View File

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

View File

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

View File

@@ -1,15 +1,46 @@
import { buildCuratorPlan, buildCuratorStreak, buildCuratorTasks, todayIsoDate } from "./curator-store.js";
import { curatorPlanSchema, type CuratorImprovementSignal } from "./curator-types.js";
import { actor } from "rivetkit";
import { buildCuratorPlan, buildCuratorSprint, buildCuratorStreak, buildCuratorTasks, buildServiceCurationPreview, todayIsoDate } from "./curator-store.js";
import { curatorPlanSchema, curatorSprintResponseSchema, type CuratorImprovementSignal } from "./curator-types.js";
import { emitCuratorEvent } from "./curator-events.js";
import { runCuratorChat } from "./curator-agent.js";
import { prepareHandoffForTask } from "./curator-tools.js";
import type { CuratorIcpId } from "./curator-icp-playbooks.js";
import { runCuratorOnboardingLoop } from "./curator-onboarding-loop.js";
export const curatorActor = {
type CuratorActorState = {
userId: string;
planGenerations: number;
sprintReads: number;
taskCompletions: number;
lastActionAt?: string;
lastEventId?: string;
};
function touch(c: { state: CuratorActorState }, input: { userId: string }) {
if (c.state.userId && c.state.userId !== input.userId) throw new Error("curatorActor initialized for a different user");
c.state.userId = input.userId;
c.state.lastActionAt = new Date().toISOString();
}
export const curatorService = {
async generatePlanRange(input: { userId: string; startDate?: string; endDate?: string; goals?: string[]; forceRegenerate?: boolean }) {
const startDate = input.startDate ?? todayIsoDate();
const endDate = input.endDate ?? startDate;
const plan = curatorPlanSchema.parse(await buildCuratorPlan(input.userId, { startDate, endDate, goals: input.goals }));
await emitCuratorEvent({ userId: input.userId, type: "curator.plan.generated", payload: { startDate, endDate, goals: input.goals ?? [] } });
await emitCuratorEvent({
userId: input.userId,
type: "curator.plan.generated",
payload: {
startDate,
endDate,
planId: plan.id,
durationDays: plan.durationDays,
goals: input.goals ?? plan.goals,
weekCount: plan.weeks.length,
dayCount: plan.days.length,
plan,
},
});
return { plan };
},
@@ -17,22 +48,41 @@ export const curatorActor = {
return this.generatePlanRange(input);
},
async previewCuration(input: { userId: string; startDate?: string; icpId?: CuratorIcpId; goals?: string[]; userContext?: Record<string, unknown> }) {
return buildServiceCurationPreview(input);
},
async runOnboardingLoop(input: { userId: string; completedAt?: string }) {
return runCuratorOnboardingLoop({
userId: input.userId,
completedAt: input.completedAt,
source: "curator-api",
});
},
async getToday(input: { userId: string; date?: string }) {
const date = input.date ?? todayIsoDate();
const plan = curatorPlanSchema.parse(await buildCuratorPlan(input.userId, { startDate: date, endDate: date }));
const tasks = plan.days[0]?.tasks ?? await buildCuratorTasks(input.userId, date);
const sprint = curatorSprintResponseSchema.parse(await buildCuratorSprint(input.userId, date));
await emitCuratorEvent({ userId: input.userId, type: "curator.day.opened", payload: { date } });
return {
date,
plan,
tasks,
streak: plan.streak,
completedCount: tasks.filter((task) => task.status === "completed").length,
totalCount: tasks.length,
plan: sprint.plan,
tasks: sprint.todayTasks,
streak: sprint.streak,
completedCount: sprint.completedCount,
totalCount: sprint.totalCount,
sprint,
source: "curator-v1" as const,
};
},
async getSprint(input: { userId: string; date?: string }) {
const date = input.date ?? todayIsoDate();
const sprint = curatorSprintResponseSchema.parse(await buildCuratorSprint(input.userId, date));
await emitCuratorEvent({ userId: input.userId, type: "curator.day.opened", payload: { date, sprintId: sprint.sprintId } });
return sprint;
},
async chat(input: { userId: string; conversationId?: string; date?: string; taskId?: string; subtaskIndex?: number; subtask?: string; messages: Array<{ role: "user" | "assistant"; content: string }> }) {
return runCuratorChat(input);
},
@@ -62,32 +112,110 @@ export const curatorActor = {
const date = input.date ?? todayIsoDate();
const task = (await buildCuratorTasks(input.userId, date)).find((item) => item.id === input.taskId);
if (!task) throw new Error("curator_task_not_found");
if (task.serviceId) {
const reason = input.reason ?? "subtasks_completed";
const allowDirectServiceCompletion = task.serviceId === "qscore-service" && reason === "qscore_review_opened";
if (task.serviceId && !allowDirectServiceCompletion) {
throw new Error("curator_service_task_requires_service_event");
}
const event = await emitCuratorEvent({
userId: input.userId,
type: "curator.task.completed",
mission: { missionId: task.missionId, missionInstanceId: task.missionInstanceId, stageId: task.stageId },
payload: { taskId: task.id, date, reason: input.reason ?? "subtasks_completed" },
payload: { taskId: task.id, date, reason },
});
return { task: { ...task, status: "completed" as const }, eventId: event.id };
},
async recordServiceImpact(input: { userId: string; eventId: string }) {
const streak = await buildCuratorStreak(input.userId);
return { matched: true, completedTasks: await buildCuratorTasks(input.userId, todayIsoDate()), streak };
const sprint = await buildCuratorSprint(input.userId, todayIsoDate());
return { matched: true, completedTasks: sprint.todayTasks, streak, sprint };
},
async applyImprovementSignals(input: { userId: string; date: string; signals: CuratorImprovementSignal[] }) {
for (const signal of input.signals) {
await emitCuratorEvent({ userId: input.userId, type: "curator.improvement_signal.applied", payload: { signal } });
}
const plan = await buildCuratorPlan(input.userId, { startDate: input.date, endDate: input.date });
return { applied: input.signals.length, plan };
const sprint = await buildCuratorSprint(input.userId, input.date);
return { applied: input.signals.length, plan: sprint.plan, sprint };
},
async getState(input: { userId: string }) {
return { tasks: await buildCuratorTasks(input.userId, todayIsoDate()), streak: await buildCuratorStreak(input.userId) };
const sprint = await buildCuratorSprint(input.userId, todayIsoDate());
return { tasks: sprint.todayTasks, streak: sprint.streak, sprint };
},
};
export const curatorActor = actor({
options: { name: "Curator Actor", icon: "sparkles", noSleep: true, actionTimeout: 300_000 },
state: {
userId: "",
planGenerations: 0,
sprintReads: 0,
taskCompletions: 0,
} as CuratorActorState,
actions: {
generatePlanRange: async (c, input: Parameters<typeof curatorService.generatePlanRange>[0]) => {
touch(c, input);
c.state.planGenerations += 1;
return curatorService.generatePlanRange(input);
},
getPlan: async (c, input: Parameters<typeof curatorService.getPlan>[0]) => {
touch(c, input);
return curatorService.getPlan(input);
},
previewCuration: async (c, input: Parameters<typeof curatorService.previewCuration>[0]) => {
touch(c, input);
return curatorService.previewCuration(input);
},
runOnboardingLoop: async (c, input: Parameters<typeof curatorService.runOnboardingLoop>[0]) => {
touch(c, input);
return curatorService.runOnboardingLoop(input);
},
getToday: async (c, input: Parameters<typeof curatorService.getToday>[0]) => {
touch(c, input);
c.state.sprintReads += 1;
return curatorService.getToday(input);
},
getSprint: async (c, input: Parameters<typeof curatorService.getSprint>[0]) => {
touch(c, input);
c.state.sprintReads += 1;
return curatorService.getSprint(input);
},
chat: async (c, input: Parameters<typeof curatorService.chat>[0]) => {
touch(c, input);
return curatorService.chat(input);
},
startTask: async (c, input: Parameters<typeof curatorService.startTask>[0]) => {
touch(c, input);
const result = await curatorService.startTask(input);
c.state.lastEventId = result.eventId;
return result;
},
prepareTaskHandoff: async (c, input: Parameters<typeof curatorService.prepareTaskHandoff>[0]) => {
touch(c, input);
return curatorService.prepareTaskHandoff(input);
},
completeTask: async (c, input: Parameters<typeof curatorService.completeTask>[0]) => {
touch(c, input);
const result = await curatorService.completeTask(input);
c.state.taskCompletions += 1;
c.state.lastEventId = result.eventId;
return result;
},
recordServiceImpact: async (c, input: Parameters<typeof curatorService.recordServiceImpact>[0]) => {
touch(c, input);
c.state.lastEventId = input.eventId;
return curatorService.recordServiceImpact(input);
},
applyImprovementSignals: async (c, input: Parameters<typeof curatorService.applyImprovementSignals>[0]) => {
touch(c, input);
return curatorService.applyImprovementSignals(input);
},
getState: async (c, input: Parameters<typeof curatorService.getState>[0]) => {
touch(c, input);
const state = await curatorService.getState(input);
return { ...state, actorState: c.state };
},
},
});

View File

@@ -176,7 +176,25 @@ function targetRoleState(messages: CuratorMessage[], latest: string) {
};
}
function curatorSystemAddendum(input: {
const CURATOR_PROMPT_FILE = path.resolve(process.cwd(), "prompts", "curator-v1.md");
const DEFAULT_CURATOR_PROMPT = `You are currently speaking as the GrowQR V1 Curator through the Conversation Actor.
Own 30 day direction, streak continuity, and service handoff decisions.
Do not ask the same question twice.
Use captured task memory and keep the user on the focused subtask.
When the user has answered enough, summarize what was captured and stop.
If more detail is needed, ask exactly one follow-up question.
For service work, prepare preview-oriented handoffs once enough context exists.`;
async function loadCuratorPromptTemplate() {
try {
return await readFile(CURATOR_PROMPT_FILE, "utf8");
} catch {
return DEFAULT_CURATOR_PROMPT;
}
}
async function curatorSystemAddendum(input: {
date: string;
taskId?: string;
subtaskIndex?: number;
@@ -186,21 +204,14 @@ function curatorSystemAddendum(input: {
promptText: string;
targetRole?: string;
}) {
const template = await loadCuratorPromptTemplate();
const lines = [
input.promptText,
"",
"You are currently speaking as the GrowQR V1 Curator through the Conversation Actor.",
"The V1 Curator owns 30 day direction, streak continuity, and service handoff decisions.",
"Carry state from the conversation history. If the user gives a short answer like a role name, accept it and ask for the next missing slot.",
"Do not ask the same question twice. Do not output checklist items as separate baked chat messages.",
"For target-role tasks, collect target role, current background, constraints, then offer a resume or interview handoff.",
"For service work, use Conversation Actor tools to prepare handoffs only after the focused subtask has enough context.",
"Never say: What should I capture next. Ask a concrete conversational question tied to the task.",
"If a curator subtask is provided, focus on that subtask only. Do not answer as if another subtask was clicked.",
"Do not ask about another subtask, another mission, another service, or a later checklist item from this modal.",
"When the user has answered the focused subtask enough, summarize what was captured and stop. Do not ask the next subtask question.",
"If more detail is needed, ask exactly one follow-up question for the focused subtask only.",
"Use captured task memory from previous subtasks as context. Do not ask the user to repeat details already captured there.",
...template
.split(/\r?\n/)
.map((line) => line.trimEnd())
.filter(Boolean),
];
pushField(lines, "Known target role", input.targetRole);
pushField(lines, "Date", input.date);
@@ -268,6 +279,77 @@ function servicePreviewSummary(task: Awaited<ReturnType<typeof buildCuratorTasks
return `${task.serviceName} handoff is ready.`;
}
function fallbackCuratorReply(input: {
latest: string;
task?: Awaited<ReturnType<typeof buildCuratorTasks>>[number];
subtask?: string;
targetRole?: string;
}) {
const latest = input.latest.trim();
const lowerTitle = input.task?.title.toLowerCase() ?? "";
const lowerSubtask = input.subtask?.toLowerCase() ?? "";
const role = fallbackCuratorRole(input.targetRole);
if ((input.task?.serviceId === "interview-service" || input.task?.serviceId === "roleplay-service") && !input.targetRole) {
return "What role are you targeting?";
}
if (/^start$/i.test(latest)) {
if (input.task?.serviceId === "qscore-service") {
return "Open your current Q Score and tell me which readiness signal looks weakest today.";
}
if (input.task?.serviceId === "resume-service") {
return "Upload your current resume or paste three recent wins so I can anchor this proof task.";
}
if (input.task?.serviceId === "interview-service" || input.task?.serviceId === "roleplay-service") {
return `I have your target role as ${role}. Say start when you want the preview opened.`;
}
if (lowerTitle.includes("role direction") || lowerSubtask.includes("role direction")) {
return "Which role family do you want this sprint to optimize toward?";
}
if (input.task?.taskType === "measurement") {
return "Open the current view and tell me the one gap or signal that stands out most.";
}
if (input.task?.taskType === "proof") {
return "Share the strongest proof you already have so we can build from something real.";
}
return "What is the single outcome you want from this task today?";
}
if (input.task?.serviceId === "interview-service" || input.task?.serviceId === "roleplay-service") {
if (isExplicitHandoffRequest(latest)) {
return servicePreviewSummary(input.task, input.targetRole);
}
return `Captured ${role} as the target role. Say start when you want the preview opened.`;
}
if (lowerTitle.includes("role direction") || lowerSubtask.includes("role direction")) {
return `Captured ${latest}. I will use that as the role direction for this sprint.`;
}
if (input.task?.serviceId === "resume-service") {
return "Captured. Open the resume flow when you are ready to turn this into proof.";
}
if (input.task?.serviceId === "qscore-service") {
return "Captured. Open the Q Score view and save the main readiness gap you want to work on.";
}
if (input.task?.taskType === "measurement") {
return "Captured the baseline signal for today.";
}
if (input.task?.taskType === "proof") {
return "Captured the proof point for today.";
}
if (input.task?.taskType === "practice") {
return "Captured the practice focus for today.";
}
return "Captured. We can use this to move the task forward.";
}
async function evaluateSubtaskStatus(input: {
task?: Awaited<ReturnType<typeof buildCuratorTasks>>[number];
subtask?: string;
@@ -451,6 +533,7 @@ export async function runCuratorChat(input: {
}
let reply = "";
let usedFallbackReply = false;
try {
try {
const extract = await generateText({
@@ -499,7 +582,7 @@ export async function runCuratorChat(input: {
missionId: task?.missionId,
stageId: task?.stageId,
source: "curator-v1",
systemAddendum: curatorSystemAddendum({ date, taskId: input.taskId, subtaskIndex: input.subtaskIndex, subtask: input.subtask, task, taskMemory, promptText, targetRole }),
systemAddendum: await curatorSystemAddendum({ date, taskId: input.taskId, subtaskIndex: input.subtaskIndex, subtask: input.subtask, task, taskMemory, promptText, targetRole }),
});
reply = sanitize(result.text);
if (/what should i capture next/i.test(reply) || !reply) {
@@ -512,7 +595,23 @@ export async function runCuratorChat(input: {
subtask: input.subtask,
error: error instanceof Error ? error.message : String(error),
});
throw error;
reply = sanitize(fallbackCuratorReply({
latest,
task,
subtask: input.subtask,
targetRole,
}));
usedFallbackReply = true;
}
if (!reply) {
reply = sanitize(fallbackCuratorReply({
latest,
task,
subtask: input.subtask,
targetRole,
}));
usedFallbackReply = true;
}
let statusUpdate = await evaluateSubtaskStatus({
@@ -530,6 +629,12 @@ export async function runCuratorChat(input: {
confidence: Math.max(statusUpdate.confidence, 0.9),
};
}
if (usedFallbackReply && statusUpdate.status === "needs_more_context" && !statusUpdate.nextMissingInfo) {
statusUpdate = {
...statusUpdate,
summary: reply,
};
}
if (isPreviewHandoffService(task) && !isInitialOpen && usefulUserMessages(conversationHistory).length >= 1) {
statusUpdate = {
status: "handoff_ready",

View File

@@ -1,5 +1,22 @@
import { recordGrowEvent } from "../../events/record-grow-event.js";
function curatorDedupeKey(input: {
userId: string;
type: string;
payload?: Record<string, unknown>;
}) {
const payload = input.payload ?? {};
const stableId =
payload.taskId ??
payload.sprintId ??
payload.startDate ??
payload.sourceEventId ??
payload.eventId ??
payload.date;
return `${input.userId}:${input.type}:${stableId ?? Date.now()}`;
}
export async function emitCuratorEvent(input: {
userId: string;
type: string;
@@ -14,6 +31,6 @@ export async function emitCuratorEvent(input: {
occurredAt: new Date().toISOString(),
mission: input.mission,
payload: input.payload ?? {},
dedupeKey: `${input.userId}:${input.type}:${input.payload?.taskId ?? input.payload?.date ?? Date.now()}`,
dedupeKey: curatorDedupeKey(input),
}, { userId: input.userId, source: "curator-v1" });
}

View File

@@ -0,0 +1,103 @@
import type { CuratorServiceId, CuratorTaskType } from "./curator-types.js";
export type CuratorIcpId =
| "student_recent_grad"
| "intern"
| "fresher_early_professional"
| "experienced_professional";
export type CuratorPlaybookAction = {
taskType: CuratorTaskType;
serviceId: CuratorServiceId;
goal: string;
action: string;
deepLinkIntent: string;
expectedSignals: string[];
};
export type CuratorIcpPlaybook = {
id: CuratorIcpId;
label: string;
sprintTheme: string;
goal: string;
stageLabels: [string, string, string, string, string];
serviceActions: CuratorPlaybookAction[];
};
export const CURATOR_ICP_PLAYBOOKS: Record<CuratorIcpId, CuratorIcpPlaybook> = {
student_recent_grad: {
id: "student_recent_grad",
label: "Student / Recent Grad",
sprintTheme: "First Role Readiness Sprint",
goal: "Have a credible resume, practiced interviews, visible proof, and a clear target role by the end of the sprint.",
stageLabels: ["Baseline + First Proof", "Fix Obvious Gaps", "Build Proof Momentum", "Market-Ready Practice", "Closeout + Next Sprint"],
serviceActions: [
play("measurement", "qscore-service", "baseline", "Establish readiness baseline and weakest drivers.", "analytics", ["qscore baseline", "weakest driver"]),
play("proof", "resume-service", "first proof", "Import resume and convert projects into proof bullets.", "resume workspace", ["resume import", "project proof"]),
play("proof", "social-branding-service", "visible credibility", "Turn proof into public-safe profile and post artifacts.", "social profile flow", ["headline", "public proof"]),
play("practice", "interview-service", "interview confidence", "Run behavioral and project interview reps.", "interview preview", ["mock interview", "feedback"]),
play("practice", "matchmaking-service", "role direction", "Shortlist realistic first-role opportunities.", "pathways", ["target roles", "opportunity shortlist"]),
],
},
intern: {
id: "intern",
label: "Intern",
sprintTheme: "Intern-to-Offer Sprint",
goal: "Convert internship work into stronger impact proof, return-offer readiness, and external backup options.",
stageLabels: ["Baseline + First Proof", "Fix Obvious Gaps", "Build Proof Momentum", "Market-Ready Practice", "Closeout + Next Sprint"],
serviceActions: [
play("measurement", "qscore-service", "return-offer baseline", "Measure return-offer proof gaps and readiness.", "analytics", ["return offer", "readiness"]),
play("proof", "resume-service", "internship proof", "Document project decisions, metrics, and impact bullets.", "resume workspace", ["internship proof", "impact log"]),
play("proof", "social-branding-service", "manager visibility", "Prepare manager updates, feedback asks, and visibility notes.", "social profile flow", ["manager update", "feedback ask"]),
play("practice", "roleplay-service", "conversion conversations", "Practice mentor, manager, and return-offer asks.", "roleplay builder", ["conversion ask", "stakeholder conversation"]),
play("practice", "matchmaking-service", "backup options", "Maintain credible external backup opportunities.", "pathways", ["backup roles", "pipeline"]),
],
},
fresher_early_professional: {
id: "fresher_early_professional",
label: "Fresher / Early Professional",
sprintTheme: "Callback-to-Offer Sprint",
goal: "Improve callback conversion, sharpen proof, and build stronger interview confidence across the sprint.",
stageLabels: ["Baseline + First Proof", "Fix Obvious Gaps", "Build Proof Momentum", "Market-Ready Practice", "Closeout + Next Sprint"],
serviceActions: [
play("measurement", "qscore-service", "readiness baseline", "Anchor the sprint in current QScore and missing signals.", "analytics", ["qscore", "readiness"]),
play("proof", "resume-service", "role-fit proof", "Tailor resume proof to target roles and outcomes.", "resume workspace", ["resume proof", "role fit"]),
play("proof", "social-branding-service", "credibility signal", "Create visible credibility updates from real work.", "social profile flow", ["credibility", "visibility"]),
play("practice", "interview-service", "callback conversion", "Run focused interview reps for weak question types.", "interview preview", ["interview practice", "callback"]),
play("practice", "roleplay-service", "confidence conversations", "Practice recruiter intros, objections, and pitch clarity.", "roleplay builder", ["recruiter intro", "confidence"]),
],
},
experienced_professional: {
id: "experienced_professional",
label: "Experienced Professional",
sprintTheme: "Leadership Readiness Sprint",
goal: "Strengthen leadership proof, senior interview readiness, and authority positioning for the next move.",
stageLabels: ["Leadership Baseline + Strategic Proof", "Strategic Positioning + Authority", "Negotiation + Market Action", "Conversion + Closeout", "Momentum + Carry Forward"],
serviceActions: [
play("measurement", "qscore-service", "senior readiness baseline", "Identify leadership readiness and authority gaps.", "analytics", ["leadership baseline", "authority"]),
play("proof", "resume-service", "leadership proof", "Translate execution into scope, team, and business impact.", "resume workspace", ["leadership proof", "business impact"]),
play("proof", "social-branding-service", "authority positioning", "Turn strategic lessons into public-safe authority signals.", "social profile flow", ["authority post", "positioning"]),
play("practice", "interview-service", "senior interviews", "Practice stakeholder, strategy, and leadership interview reps.", "interview preview", ["senior interview", "strategy"]),
play("practice", "roleplay-service", "negotiation and pushback", "Practice compensation, scope, promotion, and objection conversations.", "roleplay builder", ["negotiation", "pushback"]),
],
},
};
export function isCuratorIcpId(value: string): value is CuratorIcpId {
return value in CURATOR_ICP_PLAYBOOKS;
}
export function curatorPlaybookFor(id: CuratorIcpId) {
return CURATOR_ICP_PLAYBOOKS[id] ?? CURATOR_ICP_PLAYBOOKS.fresher_early_professional;
}
function play(
taskType: CuratorTaskType,
serviceId: CuratorServiceId,
goal: string,
action: string,
deepLinkIntent: string,
expectedSignals: string[],
): CuratorPlaybookAction {
return { taskType, serviceId, goal, action, deepLinkIntent, expectedSignals };
}

View File

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

View File

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

View File

@@ -1,7 +1,18 @@
import { Hono } from "hono";
import { z } from "zod";
import { createClient, type Client } from "rivetkit/client";
import { requireUser, type AuthContext } from "../../auth/clerk.js";
import { curatorActor } from "./curator-actor.js";
import { config } from "../../config.js";
import type { Registry } from "../../actors/registry.js";
let _client: Client<Registry> | null = null;
function getClient(): Client<Registry> {
return (_client ??= createClient<Registry>(config.rivetClientEndpoint));
}
function getCuratorActor(userId: string) {
return getClient().curatorActor.getOrCreate(["user", userId]);
}
const chatSchema = z.object({
conversationId: z.string().optional(),
@@ -12,6 +23,13 @@ const chatSchema = z.object({
messages: z.array(z.object({ role: z.enum(["user", "assistant"]), content: z.string() })).min(1).max(50),
});
const curationPreviewSchema = z.object({
startDate: z.string().optional(),
icpId: z.enum(["student_recent_grad", "intern", "fresher_early_professional", "experienced_professional"]).optional(),
goals: z.array(z.string()).optional(),
userContext: z.record(z.unknown()).optional(),
});
export function v1CuratorRoutes() {
const app = new Hono<AuthContext>();
app.use("*", requireUser);
@@ -24,12 +42,12 @@ export function v1CuratorRoutes() {
goals: z.array(z.string()).optional(),
forceRegenerate: z.boolean().optional(),
}).parse(await c.req.json().catch(() => ({})));
return c.json(await curatorActor.generatePlanRange({ userId, ...body }));
return c.json(await getCuratorActor(userId).generatePlanRange({ userId, ...body }));
});
app.get("/plan", async (c) => {
const userId = c.get("userId");
return c.json(await curatorActor.getPlan({
return c.json(await getCuratorActor(userId).getPlan({
userId,
startDate: c.req.query("startDate"),
endDate: c.req.query("endDate"),
@@ -38,40 +56,59 @@ export function v1CuratorRoutes() {
app.get("/today", async (c) => {
const userId = c.get("userId");
return c.json(await curatorActor.getToday({ userId, date: c.req.query("date") }));
return c.json(await getCuratorActor(userId).getToday({ userId, date: c.req.query("date") }));
});
app.get("/sprint", async (c) => {
const userId = c.get("userId");
return c.json(await getCuratorActor(userId).getSprint({ userId, date: c.req.query("date") }));
});
app.post("/curation/preview", async (c) => {
const userId = c.get("userId");
const body = curationPreviewSchema.parse(await c.req.json().catch(() => ({})));
return c.json(await getCuratorActor(userId).previewCuration({ userId, ...body }));
});
app.post("/onboarding/run", async (c) => {
const userId = c.get("userId");
const body = z.object({
completedAt: z.string().optional(),
}).parse(await c.req.json().catch(() => ({})));
return c.json(await getCuratorActor(userId).runOnboardingLoop({ userId, ...body }));
});
app.post("/chat", async (c) => {
const userId = c.get("userId");
const body = chatSchema.parse(await c.req.json());
return c.json(await curatorActor.chat({ userId, ...body }));
return c.json(await getCuratorActor(userId).chat({ userId, ...body }));
});
app.post("/tasks/:taskId/start", async (c) => {
const userId = c.get("userId");
return c.json(await curatorActor.startTask({ userId, taskId: c.req.param("taskId"), date: c.req.query("date") }));
return c.json(await getCuratorActor(userId).startTask({ userId, taskId: c.req.param("taskId"), date: c.req.query("date") }));
});
app.post("/tasks/:taskId/handoff", async (c) => {
const userId = c.get("userId");
return c.json(await curatorActor.prepareTaskHandoff({ userId, taskId: c.req.param("taskId"), date: c.req.query("date") }));
return c.json(await getCuratorActor(userId).prepareTaskHandoff({ userId, taskId: c.req.param("taskId"), date: c.req.query("date") }));
});
app.post("/tasks/:taskId/complete", async (c) => {
const userId = c.get("userId");
const body = z.object({ reason: z.string().optional() }).parse(await c.req.json().catch(() => ({})));
return c.json(await curatorActor.completeTask({ userId, taskId: c.req.param("taskId"), date: c.req.query("date"), reason: body.reason }));
return c.json(await getCuratorActor(userId).completeTask({ userId, taskId: c.req.param("taskId"), date: c.req.query("date"), reason: body.reason }));
});
app.post("/events/service-impact", async (c) => {
const userId = c.get("userId");
const body = z.object({ eventId: z.string() }).parse(await c.req.json());
return c.json(await curatorActor.recordServiceImpact({ userId, eventId: body.eventId }));
return c.json(await getCuratorActor(userId).recordServiceImpact({ userId, eventId: body.eventId }));
});
app.get("/state", async (c) => {
const userId = c.get("userId");
return c.json(await curatorActor.getState({ userId }));
return c.json(await getCuratorActor(userId).getState({ userId }));
});
return app;

View File

@@ -1,74 +1,53 @@
import {
buildCuratorServiceRoute,
getServiceActionLabel,
getServiceCompletionEvents,
getServiceDisplayName,
getServiceToolName,
} from "../../services/service-registry.js";
import type { CuratorServiceId, CuratorTask } from "./curator-types.js";
export function serviceRoute(input: {
type ServiceRouteInput = {
serviceId?: CuratorServiceId;
missionInstanceId?: string;
missionId?: string;
stageId?: string;
taskId?: string;
targetRole?: string;
roleplayScenario?: string;
}) {
const params = new URLSearchParams({ source: "curator-v1" });
if (input.missionInstanceId) params.set("missionInstanceId", input.missionInstanceId);
if (input.missionId) params.set("missionId", input.missionId);
if (input.stageId) params.set("stageId", input.stageId);
if (input.taskId) params.set("curatorTaskId", input.taskId);
durationMinutes?: number;
difficulty?: string;
personaId?: string;
requestedMode?: string;
roleplayBrief?: string;
};
if (input.serviceId === "interview-service") {
params.set("role", input.targetRole?.trim() || "Product Manager");
params.set("type", "behavioral");
params.set("difficulty", "medium");
params.set("duration", "5");
return `/agents/interview/preview?${params.toString()}`;
}
if (input.serviceId === "roleplay-service") {
params.set("role", input.targetRole?.trim() || "Product Manager");
params.set("type", "custom");
params.set("difficulty", "medium");
params.set("duration", "5");
if (input.roleplayScenario?.trim()) params.set("scenario_name", input.roleplayScenario.trim());
return `/agents/roleplay/preview?${params.toString()}`;
}
export function serviceRoute(input: ServiceRouteInput) {
return buildCuratorServiceRoute(input);
}
const suffix = params.toString();
if (input.serviceId === "resume-service") return `/agents/resume?${suffix}`;
if (input.serviceId === "qscore-service") return `/agents/qscore?${suffix}`;
if (input.serviceId === "social-branding-service") return `/social?${suffix}`;
if (input.serviceId === "matchmaking-service") return `/pathways?${suffix}`;
return `/missions/active${input.missionInstanceId ? `?missionInstanceId=${encodeURIComponent(input.missionInstanceId)}` : ""}`;
export function buildCuratorTaskDeepLink(task: Pick<CuratorTask, "serviceId" | "missionId" | "missionInstanceId" | "stageId" | "id">, targetRole?: string) {
return buildCuratorServiceRoute({
serviceId: task.serviceId,
missionId: task.missionId,
missionInstanceId: task.missionInstanceId,
stageId: task.stageId,
taskId: task.id,
targetRole,
});
}
export function serviceName(serviceId?: CuratorServiceId, fallback = "Mission planner") {
if (serviceId === "interview-service") return "Interview service";
if (serviceId === "roleplay-service") return "Roleplay service";
if (serviceId === "resume-service") return "Resume service";
if (serviceId === "qscore-service") return "Q Score service";
if (serviceId === "social-branding-service") return "Social branding service";
if (serviceId === "matchmaking-service") return "Pathways service";
return fallback;
return getServiceDisplayName(serviceId, fallback);
}
export function serviceToolName(serviceId?: CuratorServiceId) {
if (serviceId === "interview-service") return "prepare_interview_preview";
if (serviceId === "roleplay-service") return "prepare_roleplay_preview";
if (serviceId === "resume-service") return "prepare_resume_upload";
if (serviceId === "qscore-service") return "prepare_qscore_review";
return "prepare_mission_step";
return getServiceToolName(serviceId);
}
export function completionEventsForService(serviceId?: CuratorServiceId) {
if (serviceId === "interview-service") return ["interview.configured", "interview.review_completed", "interview.completed"];
if (serviceId === "roleplay-service") return ["roleplay.configured", "roleplay.review_completed", "roleplay.completed"];
if (serviceId === "resume-service") return ["resume.analysis_completed", "resume.parsed", "resume.updated"];
if (serviceId === "qscore-service") return ["qscore.updated", "qscore.signal_projected"];
return ["curator.task.completed"];
return getServiceCompletionEvents(serviceId);
}
export function actionLabel(task: CuratorTask) {
if (task.serviceId === "interview-service") return "Open interview preview";
if (task.serviceId === "roleplay-service") return "Open roleplay preview";
if (task.serviceId === "resume-service") return "Open resume workspace";
if (task.serviceId === "qscore-service") return "Review Q Score";
return task.cta || "Open";
return getServiceActionLabel(task);
}

File diff suppressed because it is too large Load Diff

View File

@@ -4,6 +4,7 @@ import { eq, desc, and, inArray } from "drizzle-orm";
import { db } from "../../db/client.js";
import { growEvents, growQscoreLatest, growQscoreProjectionState } from "../../db/schema.js";
import { interviewService, resumeService, roleplayService } from "../../services/product-service-clients.js";
import { listServices } from "../../services/service-registry.js";
import { createMissionAction, listMissionActions } from "../../missions/actions.js";
import { listActiveMissionsPg, listMessagesPg } from "../../grow/persistence.js";
import { buildCuratorStreak, buildCuratorTasks, listCuratorRegistryCapabilities } from "./curator-store.js";
@@ -17,9 +18,124 @@ async function findTask(userId: string, taskId: string, date: string) {
return tasks.find((task) => task.id === taskId) ?? null;
}
export async function prepareHandoffForTask(userId: string, task: CuratorTask, serviceId = task.serviceId, targetRoleOverride?: string): Promise<CuratorServiceHandoff> {
function conciseRoleHint(value: string | undefined) {
const trimmed = value?.trim();
if (!trimmed) return undefined;
return trimmed.length > 80 ? `${trimmed.slice(0, 77).trimEnd()}...` : trimmed;
}
function buildRoleplayBrief(task: CuratorTask, targetRole: string) {
return `Practice a realistic ${task.title.toLowerCase()} conversation for ${targetRole}. Include one pushback moment, concise answers, and a clear next step.`;
}
async function missionGoalHint(userId: string, task: CuratorTask) {
if (!task.missionInstanceId) return undefined;
const active = await listActiveMissionsPg(userId);
const match = active.find((item) => item.mission.instanceId === task.missionInstanceId);
const goal = typeof match?.mission.goal === "string" ? match.mission.goal : undefined;
return conciseRoleHint(goal);
}
function asText(value: unknown): string | undefined {
if (typeof value === "string") {
const trimmed = value.trim();
return trimmed ? trimmed : undefined;
}
if (typeof value === "number" || typeof value === "boolean") {
return String(value);
}
return undefined;
}
function reviewField(payload: Record<string, unknown>, keys: string[]) {
for (const key of keys) {
const direct = asText(payload[key]);
if (direct) return direct;
}
const review = payload.review && typeof payload.review === "object" ? payload.review as Record<string, unknown> : undefined;
if (!review) return undefined;
for (const key of keys) {
const nested = asText(review[key]);
if (nested) return nested;
}
return undefined;
}
async function latestInterviewResumeEvidence(userId: string) {
const rows = await db.select({
id: growEvents.id,
type: growEvents.type,
source: growEvents.source,
payload: growEvents.payload,
occurredAt: growEvents.occurredAt,
}).from(growEvents)
.where(and(
eq(growEvents.userId, userId),
inArray(growEvents.type as any, [
"interview.feedback.generated",
"interview.completed",
"roleplay.feedback.generated",
"roleplay.completed",
]),
))
.orderBy(desc(growEvents.occurredAt))
.limit(5);
const latest = rows[0];
if (!latest) return null;
const payload = latest.payload ?? {};
const strongestAnswer = reviewField(payload, [
"strongest_answer",
"strongestAnswer",
"best_answer",
"bestAnswer",
"top_answer",
"topAnswer",
]);
const improvementArea = reviewField(payload, [
"improvement_area",
"improvementArea",
"biggest_gap",
"biggestGap",
"coaching_note",
"coachingNote",
]);
const summary = reviewField(payload, [
"summary",
"feedback_summary",
"feedbackSummary",
"overall_feedback",
"overallFeedback",
]);
const carryForward = [
summary ? `Review summary: ${summary}` : undefined,
strongestAnswer ? `Strongest answer to convert into proof: ${strongestAnswer}` : undefined,
improvementArea ? `Weakest area to repair in resume positioning: ${improvementArea}` : undefined,
].filter((item): item is string => Boolean(item));
return {
eventId: latest.id,
source: latest.source,
type: latest.type,
occurredAt: latest.occurredAt,
carryForward,
};
}
export async function prepareHandoffForTask(
userId: string,
task: CuratorTask,
serviceId = task.serviceId,
targetRoleOverride?: string,
): Promise<CuratorServiceHandoff> {
if (!serviceId) throw new Error("Task has no service handoff.");
const targetRole = fallbackCuratorRole(targetRoleOverride ?? await resolveCuratorTargetRole({ userId, task }));
const resolvedTargetRole =
targetRoleOverride ??
(await missionGoalHint(userId, task)) ??
(await resolveCuratorTargetRole({ userId, task }));
const targetRole = fallbackCuratorRole(resolvedTargetRole);
const route = serviceRoute({
serviceId,
missionId: task.missionId,
@@ -27,11 +143,15 @@ export async function prepareHandoffForTask(userId: string, task: CuratorTask, s
stageId: task.stageId,
taskId: task.id,
targetRole,
roleplayScenario: task.title,
durationMinutes: 5,
difficulty: "medium",
personaId: serviceId === "roleplay-service" ? "emma" : "payal",
requestedMode: "video",
roleplayBrief: serviceId === "roleplay-service" ? buildRoleplayBrief(task, targetRole) : undefined,
});
let actionId: string | undefined;
if (task.missionInstanceId) {
if (task.missionInstanceId && task.missionId !== "curator-sprint") {
const action = await createMissionAction({
userId,
missionInstanceId: task.missionInstanceId,
@@ -57,9 +177,16 @@ export async function prepareHandoffForTask(userId: string, task: CuratorTask, s
await emitCuratorEvent({
userId,
type: "curator.service_handoff.opened",
type: "task.opened",
mission: { missionId: task.missionId, missionInstanceId: task.missionInstanceId, stageId: task.stageId },
payload: { taskId: task.id, serviceId, route, actionId },
payload: {
taskId: task.id,
curatorTaskId: task.id,
serviceId,
route,
actionId,
expectedCompletionEvents: task.completionEvents,
},
});
return {
@@ -68,7 +195,7 @@ export async function prepareHandoffForTask(userId: string, task: CuratorTask, s
route,
actionId,
actionRoute: route,
actionLabel: actionLabel(task),
actionLabel: actionLabel({ ...task, serviceId }),
status: "prepared",
};
}
@@ -150,9 +277,9 @@ export function buildCuratorTools(ctx: { userId: string; date: string; conversat
execute: async ({ taskId }) => {
const task = taskId ? await findTask(ctx.userId, taskId, ctx.date) : null;
return {
routes: ["interview-service", "resume-service", "roleplay-service", "qscore-service"].map((serviceId) => ({
serviceId,
route: serviceRoute({ serviceId: serviceId as any, missionId: task?.missionId, missionInstanceId: task?.missionInstanceId, stageId: task?.stageId, taskId: task?.id }),
routes: listServices().map((service) => ({
serviceId: service.id,
route: serviceRoute({ serviceId: service.id, missionId: task?.missionId, missionInstanceId: task?.missionInstanceId, stageId: task?.stageId, taskId: task?.id }),
})),
};
},
@@ -272,6 +399,35 @@ export function buildCuratorTools(ctx: { userId: string; date: string; conversat
},
}),
prepare_resume_from_interview_evidence: tool({
description: "Prepare a resume handoff that carries forward recent interview or roleplay review evidence into the proof task.",
inputSchema: z.object({ taskId: z.string().optional() }),
execute: async ({ taskId }) => {
const task = await findTask(ctx.userId, taskId ?? ctx.taskId ?? "", ctx.date);
if (!task) return { error: "task_not_found" };
const handoff = await prepareHandoffForTask(ctx.userId, task, "resume-service");
const evidence = await latestInterviewResumeEvidence(ctx.userId);
if (!evidence?.carryForward?.length) {
const interviewFallback = await prepareHandoffForTask(ctx.userId, task, "interview-service");
return {
handoff,
carryForward: [],
requiresInterviewEvidence: true,
recommendedNextAction: "No recent interview or roleplay review evidence is available yet. Run an interview rep first so the resume proof can be generated from real conversation evidence.",
fallbackHandoff: interviewFallback,
};
}
return {
handoff,
carryForward: evidence?.carryForward ?? [],
sourceEventId: evidence?.eventId,
sourceEventType: evidence?.type,
sourceService: evidence?.source,
sourceOccurredAt: evidence?.occurredAt,
};
},
}),
prepare_roleplay_setup: tool({
description: "Prepare roleplay setup handoff.",
inputSchema: z.object({ taskId: z.string().optional() }),
@@ -341,13 +497,9 @@ export function buildCuratorTools(ctx: { userId: string; date: string; conversat
}),
prepare_qscore_review: tool({
description: "Prepare a Q-score review handoff.",
description: "Disabled for curator task handoffs; QScore is read-only for dashboard scoring and should not be assigned as a curator task.",
inputSchema: z.object({ taskId: z.string().optional() }),
execute: async ({ taskId }) => {
const task = await findTask(ctx.userId, taskId ?? ctx.taskId ?? "", ctx.date);
if (!task) return { error: "task_not_found" };
return prepareHandoffForTask(ctx.userId, task, "qscore-service");
},
execute: async () => ({ error: "qscore_curator_handoff_disabled", replacementServices: ["assessment-service", "interview-service", "roleplay-service", "resume-service", "matchmaking-service", "courses-service", "social-branding-service"] }),
}),
emit_curator_event: tool({

View File

@@ -3,7 +3,10 @@ import { z } from "zod";
export const curatorServiceIdSchema = z.enum([
"interview-service",
"resume-service",
"cover-letter-service",
"roleplay-service",
"courses-service",
"assessment-service",
"qscore-service",
"social-branding-service",
"matchmaking-service",
@@ -11,17 +14,30 @@ export const curatorServiceIdSchema = z.enum([
export type CuratorServiceId = z.infer<typeof curatorServiceIdSchema>;
export const curatorTaskTypeSchema = z.enum(["measurement", "proof", "practice", "recovery"]);
export type CuratorTaskType = z.infer<typeof curatorTaskTypeSchema>;
export const curatorTaskStatusSchema = z.enum([
"ready",
"started",
"handoff_prepared",
"completed",
"blocked",
"partial",
"skipped",
"abandoned",
]);
export const curatorWeekLifecycleSchema = z.enum(["done", "active", "upcoming"]);
export const curatorWeekPerformanceSchema = z.enum(["Missed", "Okayish", "Avg", "Excelling"]);
export const curatorTaskSchema = z.object({
id: z.string(),
date: z.string(),
dayIndex: z.number().int().min(1).max(30),
dayIndexInWeek: z.number().int().min(1).max(7),
weekIndex: z.number().int().min(1).max(6),
taskType: curatorTaskTypeSchema,
title: z.string(),
subtitle: z.string(),
missionId: z.string(),
@@ -39,7 +55,7 @@ export const curatorTaskSchema = z.object({
cta: z.string(),
context: z.array(z.object({ label: z.string(), value: z.string() })),
contextNarrative: z.string(),
subtasks: z.array(z.string()).min(1),
subtasks: z.array(z.string()).length(3),
signals: z.array(z.string()),
completionEvents: z.array(z.string()),
source: z.enum(["curator-v1", "mission-registry", "service-registry"]),
@@ -51,6 +67,38 @@ export const curatorStreakSchema = z.object({
lastCompletedDate: z.string().nullable(),
});
export const curatorPlanDaySchema = z.object({
date: z.string(),
dayIndex: z.number().int().min(1).max(30),
dayIndexInWeek: z.number().int().min(1).max(7),
weekIndex: z.number().int().min(1).max(6),
weekTheme: z.string(),
weekSummary: z.string(),
focus: z.string().optional(),
plannedServices: z.array(curatorServiceIdSchema).max(4).default([]),
generationStatus: z.enum(["seeded", "generated", "adapted"]).default("seeded"),
adaptationReason: z.string().optional(),
completedCount: z.number().int().min(0),
totalCount: z.number().int().min(0),
unlockState: z.enum(["completed", "active", "upcoming"]),
tasks: z.array(curatorTaskSchema),
});
export const curatorWeekSchema = z.object({
weekIndex: z.number().int().min(1).max(6),
title: z.string(),
theme: z.string(),
summary: z.string(),
startDayIndex: z.number().int().min(1).max(30),
endDayIndex: z.number().int().min(1).max(30),
lifecycle: curatorWeekLifecycleSchema,
performance: curatorWeekPerformanceSchema,
completedTaskCount: z.number().int().min(0),
totalTaskCount: z.number().int().min(0),
completionPercent: z.number().min(0).max(100),
days: z.array(curatorPlanDaySchema).min(1).max(7),
});
export const curatorPlanSchema = z.object({
id: z.string(),
userId: z.string(),
@@ -58,12 +106,9 @@ export const curatorPlanSchema = z.object({
endDate: z.string(),
goals: z.array(z.string()),
generatedAt: z.string(),
days: z.array(z.object({
date: z.string(),
dayIndex: z.number().int().min(1),
theme: z.string(),
tasks: z.array(curatorTaskSchema),
})),
durationDays: z.literal(30),
weeks: z.array(curatorWeekSchema).min(5).max(6),
days: z.array(curatorPlanDaySchema).length(30),
streak: curatorStreakSchema,
source: z.literal("curator-v1"),
});
@@ -81,10 +126,29 @@ export const curatorImprovementSignalSchema = z.object({
status: z.enum(["created", "applied", "skipped"]).default("created"),
});
export const curatorSprintResponseSchema = z.object({
date: z.string(),
sprintId: z.string(),
plan: curatorPlanSchema,
activeWeek: curatorWeekSchema,
activeWeekIndex: z.number().int().min(1).max(6),
activeDay: curatorPlanDaySchema,
activeDayIndex: z.number().int().min(1).max(30),
todayTasks: z.array(curatorTaskSchema).min(3).max(4),
streak: curatorStreakSchema,
completedCount: z.number().int().min(0),
totalCount: z.number().int().min(0),
overallProgressPercent: z.number().min(0).max(100),
source: z.literal("curator-v1"),
});
export type CuratorTask = z.infer<typeof curatorTaskSchema>;
export type CuratorPlanDay = z.infer<typeof curatorPlanDaySchema>;
export type CuratorWeek = z.infer<typeof curatorWeekSchema>;
export type CuratorPlan = z.infer<typeof curatorPlanSchema>;
export type CuratorStreak = z.infer<typeof curatorStreakSchema>;
export type CuratorImprovementSignal = z.infer<typeof curatorImprovementSignalSchema>;
export type CuratorSprintResponse = z.infer<typeof curatorSprintResponseSchema>;
export type CuratorTodayResponse = {
date: string;

View File

@@ -1,6 +1,6 @@
import { and, desc, eq } from "drizzle-orm";
import { db } from "../../db/client.js";
import { growEvents } from "../../db/schema.js";
import { growEvents, growQscoreProjectionState } from "../../db/schema.js";
import { asRecord, getString } from "../../events/envelope.js";
import type { CuratorTask } from "./curator-types.js";
@@ -12,6 +12,29 @@ function stringArray(value: unknown): string[] {
: [];
}
export type CuratorUserContext = {
userId: string;
targetRole: string;
experienceLevel: "student" | "intern" | "early" | "experienced" | "unknown";
resume: {
available: boolean;
latestSummary?: string;
skills: string[];
};
goals: string[];
pastActivity: {
recentEventCount: number;
serviceSources: string[];
latestEvents: Array<{ type: string; source: string; occurredAt: string; summary?: string }>;
};
qscore: {
score: number | null;
signalCount: number;
summary: string | null;
dimensions: Record<string, unknown> | null;
};
};
function firstRoleFromValue(value: unknown): string | undefined {
const direct = getString(value);
if (direct) return direct;
@@ -71,9 +94,27 @@ function roleFromTask(task?: CuratorTask) {
export function inferRoleFromText(text?: string) {
const value = text?.trim();
if (!value) return undefined;
if (value.length <= 80 && ROLE_PATTERN.test(value)) return value.replace(/[.?!]+$/, "").trim();
const explicit = value.match(/(?:targeting|for|as|role is|role:)\s+([A-Za-z][A-Za-z0-9 +/&.-]{2,60})/i)?.[1];
return explicit?.replace(/[.?!]+$/, "").trim();
const cleanRole = (raw?: string) => {
if (!raw) return undefined;
const normalized = raw
.replace(/^(?:i am |i'm |im )/i, "")
.replace(/^(?:targeting|aiming for|looking for)\s+/i, "")
.replace(/[.?!,]+$/, "")
.replace(/\broles?\b/gi, "")
.replace(/\b(this|next)\s+(week|month|quarter|year)\b/gi, "")
.replace(/\b(right now|currently)\b/gi, "")
.replace(/\s+/g, " ")
.trim();
if (!normalized) return undefined;
if (/^pm$/i.test(normalized)) return "Product Manager";
if (/^swe$/i.test(normalized)) return "Software Engineer";
return normalized;
};
const explicit = value.match(/(?:targeting|for|as|toward|towards|role is|role:)\s+([A-Za-z][A-Za-z0-9 +/&.-]{2,60}?)(?=\s+roles?\b|\s+(?:this|next)\s+(?:week|month|quarter|year)\b|[.?!,]|$)/i)?.[1];
if (explicit) return cleanRole(explicit);
if (value.length <= 80 && ROLE_PATTERN.test(value)) return cleanRole(value);
return undefined;
}
export async function resolveCuratorTargetRole(input: {
@@ -112,3 +153,103 @@ export async function resolveCuratorTargetRole(input: {
export function fallbackCuratorRole(role?: string) {
return role?.trim() || "Product Manager";
}
export async function buildCuratorUserContext(userId: string): Promise<CuratorUserContext> {
const rows = await db
.select({ type: growEvents.type, source: growEvents.source, payload: growEvents.payload, occurredAt: growEvents.occurredAt })
.from(growEvents)
.where(eq(growEvents.userId, userId))
.orderBy(desc(growEvents.occurredAt))
.limit(80);
const [qscore] = await db
.select({
score: growQscoreProjectionState.score,
signalCount: growQscoreProjectionState.signalCount,
summary: growQscoreProjectionState.summary,
dimensions: growQscoreProjectionState.dimensions,
})
.from(growQscoreProjectionState)
.where(eq(growQscoreProjectionState.userId, userId))
.limit(1);
const targetRole = fallbackCuratorRole(await resolveCuratorTargetRole({ userId }));
const corpus = rows.map((row) => `${row.type} ${row.source} ${payloadText(row.payload)}`).join(" ").toLowerCase();
const goals = uniqueStrings(rows.flatMap((row) => goalsFromPayload(row.payload)));
const skills = uniqueStrings(rows.flatMap((row) => stringArray(asRecord(row.payload).skills))).slice(0, 12);
const latestResume = rows
.map((row) => resumeSummaryFromPayload(row.payload))
.find(Boolean);
return {
userId,
targetRole,
experienceLevel: inferExperienceLevel(corpus),
resume: {
available: Boolean(latestResume || /\bresume|cv|linkedin\b/i.test(corpus)),
latestSummary: latestResume,
skills,
},
goals: goals.length ? goals.slice(0, 8) : [targetRole],
pastActivity: {
recentEventCount: rows.length,
serviceSources: uniqueStrings(rows.map((row) => row.source)).slice(0, 12),
latestEvents: rows.slice(0, 12).map((row) => ({
type: row.type,
source: row.source,
occurredAt: row.occurredAt.toISOString(),
summary: eventSummary(row.payload),
})),
},
qscore: {
score: typeof qscore?.score === "number" ? qscore.score : null,
signalCount: typeof qscore?.signalCount === "number" ? qscore.signalCount : 0,
summary: qscore?.summary ?? null,
dimensions: qscore?.dimensions ?? null,
},
};
}
function goalsFromPayload(payload: Record<string, unknown>) {
const preferences = asRecord(payload.preferences);
return [
...stringArray(preferences.target_roles ?? preferences.targetRoles),
...stringArray(payload.goals),
getString(payload.goal),
getString(payload.userGoal),
getString(payload.target_role ?? payload.targetRole),
].filter((item): item is string => Boolean(item));
}
function resumeSummaryFromPayload(payload: Record<string, unknown>) {
return getString(
payload.resumeSummary ??
payload.summary ??
payload.resume_text ??
payload.resumeText ??
asRecord(payload.resume).summary,
)?.slice(0, 900);
}
function eventSummary(payload: Record<string, unknown>) {
return getString(payload.summary ?? payload.title ?? payload.goal ?? payload.serviceIntent)?.slice(0, 220);
}
function payloadText(value: unknown): string {
if (typeof value === "string") return value;
if (Array.isArray(value)) return value.map(payloadText).join(" ");
if (value && typeof value === "object") return Object.values(value as Record<string, unknown>).map(payloadText).join(" ");
return "";
}
function inferExperienceLevel(corpus: string): CuratorUserContext["experienceLevel"] {
if (/\b(intern|internship|return offer|return-offer)\b/.test(corpus)) return "intern";
if (/\b(student|recent grad|recent graduate|campus|college|university)\b/.test(corpus)) return "student";
if (/\b(staff|principal|director|vp|head of|leadership|executive|10\+ years|5\+ years)\b/.test(corpus)) return "experienced";
if (/\b(fresher|junior|entry level|entry-level|early career|0-2 years|1 year|2 years)\b/.test(corpus)) return "early";
return "unknown";
}
function uniqueStrings(values: Array<string | undefined>) {
return [...new Set(values.map((value) => value?.trim()).filter((value): value is string => Boolean(value)))];
}

View File

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

View File

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

View File

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

View File

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

View File

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