From c48c28fdb3c63aa939477a97169f3bce174638b2 Mon Sep 17 00:00:00 2001 From: Sai-karthik Date: Mon, 22 Jun 2026 21:25:38 +0000 Subject: [PATCH 01/15] Implement canonical service registry --- Dockerfile | 7 +- docker-compose.yml | 4 + src/config.ts | 18 + src/features/registry.ts | 102 +-- src/home/home-feed.ts | 43 +- src/missions/actions.ts | 29 +- src/missions/reducer-helpers.ts | 11 +- src/routes/services.ts | 2 +- src/services/service-registry.ts | 930 +++++++++++++++++++++++--- src/v1/curator/curator-types.ts | 4 + src/workflows/service-capabilities.ts | 45 +- 11 files changed, 980 insertions(+), 215 deletions(-) diff --git a/Dockerfile b/Dockerfile index a459bd8..f19d3bf 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml index 548dd05..794cac7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -116,6 +116,10 @@ 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} + 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: diff --git a/src/config.ts b/src/config.ts index b8a1eb6..573d20e 100644 --- a/src/config.ts +++ b/src/config.ts @@ -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 ?? diff --git a/src/features/registry.ts b/src/features/registry.ts index 7cae275..3a9958b 100644 --- a/src/features/registry.ts +++ b/src/features/registry.ts @@ -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) { diff --git a/src/home/home-feed.ts b/src/home/home-feed.ts index 6b2ab8b..fce5315 100644 --- a/src/home/home-feed.ts +++ b/src/home/home-feed.ts @@ -15,6 +15,7 @@ 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 { listAvailableMissionDefinitions } from "../missions/registry.js"; @@ -35,13 +36,13 @@ 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") ?? "/opportunities/resume", + interview: buildServiceLink("interview-service", "discovery") ?? "/upskilling/interview", + roleplay: buildServiceLink("roleplay-service", "discovery") ?? "/upskilling/roleplay", + qscore: buildServiceLink("qscore-service", "dashboard") ?? "/home", mission: "/missions/active", - social: "/social", - pathways: "/pathways", + social: buildServiceLink("social-branding-service", "profile") ?? "/opportunities/social-media", + pathways: buildServiceLink("pathways-service", "dashboard") ?? "/career-pathways", rewards: "/rewards", suggestions: "/suggestions", productivity: "/productivity", @@ -101,20 +102,22 @@ function profileFromPreferences(preferences: Record) { 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 { diff --git a/src/missions/actions.ts b/src/missions/actions.ts index 1e5a02b..d159921 100644 --- a/src/missions/actions.ts +++ b/src/missions/actions.ts @@ -4,7 +4,8 @@ import { missionActions, missionSuggestions } from "../db/schema.js"; import type { GrowActiveMission } from "../actors/missions/types.js"; import type { MissionActionPatch } from "./reducer-types.js"; import { defaultMissionActionStatus, type MissionActionDto, type MissionActionRow, type MissionActionStatus, type NewMissionActionInput } from "./action-types.js"; -import { missionDetailHref, serviceHref } from "./reducer-helpers.js"; +import { missionDetailHref } from "./reducer-helpers.js"; +import { buildServiceLink, getService, getServiceActionLabel } from "../services/service-registry.js"; const OPEN_STATUSES: MissionActionStatus[] = ["queued", "running", "waiting_approval", "waiting_user_input", "failed"]; const DONE_STATUSES: MissionActionStatus[] = ["done", "dismissed", "snoozed"]; @@ -48,26 +49,30 @@ export function actionToDto(row: MissionActionRow): MissionActionDto { function ctaForAction(action: MissionActionRow | NewMissionActionInput) { const payload = action.payload && typeof action.payload === "object" && !Array.isArray(action.payload) ? action.payload as Record : {}; const hrefFromPayload = typeof payload.href === "string" ? payload.href : undefined; - const serviceId = action.serviceId ?? ""; const missionHref = missionDetailHref(action.missionInstanceId); - const href = hrefFromPayload ?? - (serviceId.includes("interview") ? serviceHref("interview", action.missionInstanceId, action.missionId, action.stageId ?? undefined) : - serviceId.includes("roleplay") ? serviceHref("roleplay", action.missionInstanceId, action.missionId, action.stageId ?? undefined) : - serviceId.includes("resume") ? serviceHref("resume", action.missionInstanceId, action.missionId, action.stageId ?? undefined) : missionHref); + const service = getService(action.serviceId); + const href = hrefFromPayload ?? ( + service + ? buildServiceLink(service.id, service.curator.defaultPage, { + source: "mission", + missionInstanceId: action.missionInstanceId, + missionId: action.missionId, + stageId: action.stageId ?? undefined, + }) ?? missionHref + : missionHref + ); if (action.mode === "approval_required") return { ctaLabel: "Review", ctaHref: missionHref }; if (action.mode === "user_input_required") return { ctaLabel: "Answer", ctaHref: missionHref }; - if (serviceId.includes("interview")) return { ctaLabel: "Start mock", ctaHref: href }; - if (serviceId.includes("roleplay")) return { ctaLabel: "Run drill", ctaHref: href }; - if (serviceId.includes("resume")) return { ctaLabel: "Open resume", ctaHref: href }; - return { ctaLabel: "Open", ctaHref: href }; + return { ctaLabel: service ? getServiceActionLabel(service.id, "start") : "Open", ctaHref: href }; } function suggestionTypeForAction(action: MissionActionRow | NewMissionActionInput) { if (action.mode === "user_input_required") return "blocked" as const; if (action.mode === "approval_required") return "review" as const; - if ((action.serviceId ?? "").includes("interview") || (action.serviceId ?? "").includes("roleplay")) return "practice" as const; - if ((action.serviceId ?? "").includes("resume")) return "artifact" as const; + const category = getService(action.serviceId)?.category; + if (category === "practice") return "practice" as const; + if (category === "document") return "artifact" as const; return "action" as const; } diff --git a/src/missions/reducer-helpers.ts b/src/missions/reducer-helpers.ts index 313c3e5..4bc1548 100644 --- a/src/missions/reducer-helpers.ts +++ b/src/missions/reducer-helpers.ts @@ -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,12 +135,10 @@ 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) { diff --git a/src/routes/services.ts b/src/routes/services.ts index 9b5fbf3..24040cf 100644 --- a/src/routes/services.ts +++ b/src/routes/services.ts @@ -442,7 +442,7 @@ export function serviceRoutes() { const app = new Hono(); 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"); diff --git a/src/services/service-registry.ts b/src/services/service-registry.ts index 518296a..3c0486d 100644 --- a/src/services/service-registry.ts +++ b/src/services/service-registry.ts @@ -1,6 +1,70 @@ +import { config } from "../config.js"; import type { CuratorServiceId, CuratorTask } from "../v1/curator/curator-types.js"; -type QueryValue = string | number | undefined | null; +export type QueryValue = string | number | boolean | undefined | null; +export type QueryState = Record; + +export type ServiceId = + | "interview-service" + | "roleplay-service" + | "courses-service" + | "assessment-service" + | "matchmaking-service" + | "pathways-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; + usage: string; + }; + frontend: { + baseUrl: string; + pages: Record; + usage: string; + }; + curator: { + defaultPage: string; + defaultActionLabel: string; + defaultQueryState?: QueryState; + actionLabels?: Record; + toolName: string; + completionEvents: string[]; + qscoreSignals?: string[]; + usage: string; + }; + usageDocs: string[]; +}; type MissionServiceId = Extract; @@ -26,10 +90,16 @@ type CuratorRouteInput = { roleplayBrief?: string; }; -function appendQuery( - pathname: string, - params: Record, -) { +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; @@ -47,6 +117,720 @@ function getSessionId(detail?: Record) { 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: "/upskilling/interview", + aliases: ["/agents/interview"], + queryParams: ["fresh"], + usage: "Entry screen for role selection and resume of in-progress interview work.", + }, + setup: { + path: "/upskilling/interview/setup", + aliases: ["/agents/interview/setup"], + queryParams: ["role", "type", "from_assignment"], + usage: "Collects interview role, type, duration, persona, media mode, and personalization consent.", + }, + preview: { + path: "/upskilling/interview/preview", + aliases: ["/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: "/upskilling/interview/feedback", + queryParams: ["sessionId"], + usage: "Opens feedback/review for a completed or processing session.", + }, + session: { + path: "/v2/service-sessions/interview", + 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.configured", "interview.review_completed", "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: "/upskilling/roleplay", + aliases: ["/agents/roleplay"], + queryParams: ["fresh"], + usage: "Entry screen for scenario discovery and resume of in-progress roleplay work.", + }, + setup: { + path: "/upskilling/roleplay/setup", + aliases: ["/agents/roleplay/setup"], + queryParams: ["scenario", "scenario_text", "scenario_name", "from_assignment"], + usage: "Collects roleplay scenario details and stores configure parameters for the builder.", + }, + builder: { + path: "/upskilling/roleplay/builder", + aliases: ["/agents/roleplay/builder"], + queryParams: ["sessionId", "source", "missionInstanceId", "missionId", "stageId", "curatorTaskId", "role", "persona", "duration", "mode", "brief"], + usage: "Curator default handoff for generating or resuming a roleplay plan.", + }, + feedback: { + path: "/upskilling/roleplay/feedback", + queryParams: ["sessionId"], + usage: "Opens feedback/review for a completed or processing session.", + }, + session: { + path: "/v2/service-sessions/roleplay", + 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.configured", "roleplay.review_completed", "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: "/opportunities/resume", + aliases: ["/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: "/opportunities/resume/:resumeId", + queryParams: ["section"], + usage: "Resume editor for a known resume.", + }, + templates: { + path: "/opportunities/resume/templates", + queryParams: [], + usage: "Template gallery.", + }, + session: { + path: "/v2/service-sessions/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: "/opportunities/resume", + queryParams: ["tab", "source", "missionInstanceId", "missionId", "stageId", "curatorTaskId"], + usage: "Open with tab=cover-letters to land in the cover-letter list.", + }, + generator: { + path: "/opportunities/cover-letter", + queryParams: [], + usage: "Standalone generation entry point.", + }, + editor: { + path: "/opportunities/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 upskilling 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: "/upskilling/course", + queryParams: ["source", "missionInstanceId", "missionId", "stageId", "curatorTaskId"], + usage: "Course catalog and upskilling entry point.", + }, + }, + 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.upskilling"], + 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: "/upskilling/assessment", + 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: "/opportunities/job-matching", + aliases: ["/pathways"], + queryParams: ["source", "missionInstanceId", "missionId", "stageId", "curatorTaskId", "query", "role", "location"], + usage: "Job/opportunity matching dashboard.", + }, + pathways: { + path: "/career-pathways", + queryParams: ["source", "missionInstanceId", "missionId", "stageId", "curatorTaskId"], + usage: "Career pathways dashboard and pathway list.", + }, + }, + usage: "Use jobs for immediate matching and pathways for career-plan handoffs.", + }, + 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 pathways-service for generated career pathway plans.", + ], + }, + { + id: "pathways-service", + label: "Pathways", + description: "Generate, activate, and report on personalized career pathways.", + category: "opportunity", + enabled: Boolean(config.pathwaysServiceUrl), + featureId: "pathways", + promptModulePath: "agents/pathways.md", + aliases: ["career-pathways-service"], + backend: { + baseUrl: config.pathwaysServiceUrl, + publicUrl: config.pathwaysPublicUrl, + healthPath: "/api/v1/health", + endpoints: { + health: endpoint("GET", "/api/v1/health", "Readiness probe.", "Check service availability."), + state: endpoint("GET", "/api/state/:clerkId", "Reads pathway state.", "Hydrate pathway dashboard."), + profileIngest: endpoint("POST", "/api/v1/profiles/ingest", "Ingests a profile for pathway generation.", "Profile setup."), + questionnaire: endpoint("GET", "/api/v1/questionnaires/:userId", "Reads pathway questionnaire.", "Questionnaire resume."), + createQuestionnaire: endpoint("POST", "/api/v1/questionnaires", "Creates questionnaire answers.", "Pathway onboarding."), + generatePathway: endpoint("POST", "/api/v1/pathways/generate", "Generates a pathway.", "Career-plan generation."), + activatePathway: endpoint("POST", "/api/v1/pathways/:pathwayId/activate", "Activates a pathway.", "Commit chosen pathway."), + getPathway: endpoint("GET", "/api/v1/pathways/:pathwayId", "Reads pathway details.", "Pathway detail."), + weeklyPlan: endpoint("GET", "/api/v1/pathways/:pathwayId/weekly-plan", "Reads weekly plan.", "Planner UI."), + report: endpoint("GET", "/api/v1/pathways/:pathwayId/report", "Reads pathway report.", "Progress report."), + opportunities: endpoint("GET", "/api/v1/pathways/:pathwayId/opportunities", "Reads pathway opportunities.", "Pathway opportunity recommendations."), + a2aTask: endpoint("POST", "/a2a/tasks", "Runs pathway agent tasks.", "Agent/curator orchestrated work."), + }, + usage: "Use for generated pathway plans and recommendation context. The live container is currently healthchecked separately from matchmaking.", + }, + frontend: { + baseUrl: frontendBaseUrl, + pages: { + dashboard: { + path: "/career-pathways", + aliases: ["/pathways"], + queryParams: ["source", "missionInstanceId", "missionId", "stageId", "curatorTaskId"], + usage: "Pathway list/dashboard.", + }, + generate: { + path: "/career-pathways/generate", + queryParams: ["source", "missionInstanceId", "missionId", "stageId", "curatorTaskId"], + usage: "Pathway generation flow.", + }, + questionnaire: { + path: "/career-pathways/questionnaire", + queryParams: ["source", "missionInstanceId", "missionId", "stageId", "curatorTaskId"], + usage: "Pathway questionnaire flow.", + }, + detail: { + path: "/career-pathways/dashboard/:pathwayId", + queryParams: [], + usage: "Pathway detail dashboard.", + }, + }, + usage: "Use dashboard for general pathway handoffs and generate/questionnaire for guided setup.", + }, + curator: { + defaultPage: "dashboard", + defaultActionLabel: "Open pathways", + actionLabels: { + start: "Build pathway", + review: "Review pathway", + }, + toolName: "prepare_pathways_handoff", + completionEvents: ["pathway.generated", "pathway.activated", "pathway.report_viewed"], + qscoreSignals: ["readiness.pathway", "market.plan"], + usage: "Use for strategic career pathway planning rather than immediate job matching.", + }, + usageDocs: ["Call buildServiceLink('pathways-service', 'dashboard', state) for pathway handoffs."], + }, + { + 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: "/home", + aliases: ["/analytics"], + queryParams: ["source", "missionInstanceId", "missionId", "stageId", "curatorTaskId"], + usage: "Home dashboard with QScore panel.", + }, + }, + usage: "Open home dashboard for QScore review until a dedicated analytics route exists.", + }, + 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: "/opportunities/social-media", + aliases: ["/social"], + 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(); +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, @@ -56,7 +840,7 @@ export function buildServiceSessionPath( if (serviceId === "interview-service") { if (!sessionId) return undefined; - return appendQuery("/v2/service-sessions/interview", { + return buildServiceLink(serviceId, "session", { session_id: sessionId, goal, role: getString(detail?.target_role) ?? goal ?? "Interview practice", @@ -66,7 +850,7 @@ export function buildServiceSessionPath( if (serviceId === "roleplay-service") { if (!sessionId) return undefined; - return appendQuery("/v2/service-sessions/roleplay", { + return buildServiceLink(serviceId, "session", { session_id: sessionId, goal, role: getString(detail?.target_role) ?? goal ?? "Roleplay practice", @@ -74,30 +858,23 @@ export function buildServiceSessionPath( }); } - return appendQuery("/v2/service-sessions/resume", { + return buildServiceLink(serviceId, "session", { goal, role: goal, }); } export function buildMissionServiceRoute(input: MissionRouteInput) { - const baseParams = { + 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, - }; - - if (input.serviceId === "interview-service") { - return appendQuery("/agents/interview/setup", baseParams); - } - - if (input.serviceId === "roleplay-service") { - return appendQuery("/agents/roleplay/setup", baseParams); - } - - return appendQuery("/agents/resume", baseParams); + role: input.goal, + type: input.serviceId === "interview-service" ? "behavioral" : undefined, + }) ?? appendQuery("/missions/active", { missionInstanceId: input.missionInstanceId }); } function curatorBaseParams(input: CuratorRouteInput) { @@ -111,85 +888,64 @@ function curatorBaseParams(input: CuratorRouteInput) { } export function buildCuratorServiceRoute(input: CuratorRouteInput) { - if (input.serviceId === "interview-service") { - return appendQuery("/agents/interview/preview", { - ...curatorBaseParams(input), - role: input.targetRole?.trim() || "Product Manager", - type: "behavioral", - persona: input.personaId ?? "payal", - duration: input.durationMinutes ?? 5, - difficulty: input.difficulty ?? "medium", - media: input.requestedMode ?? "video", - }); + const service = getService(input.serviceId); + if (!service) { + return input.missionInstanceId + ? appendQuery("/missions/active", { missionInstanceId: input.missionInstanceId }) + : "/missions/active"; } - if (input.serviceId === "roleplay-service") { - return appendQuery("/agents/roleplay/builder", { - ...curatorBaseParams(input), - role: input.targetRole?.trim() || "Professional", - persona: input.personaId ?? "emma", - duration: input.durationMinutes ?? 5, - mode: input.requestedMode ?? "video", - brief: input.roleplayBrief, - }); + 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 (input.serviceId === "resume-service") { - return appendQuery("/agents/resume", curatorBaseParams(input)); - } - if (input.serviceId === "qscore-service") { - return appendQuery("/analytics", curatorBaseParams(input)); - } - if (input.serviceId === "social-branding-service") { - return appendQuery("/social", curatorBaseParams(input)); - } - if (input.serviceId === "matchmaking-service") { - return appendQuery("/pathways", curatorBaseParams(input)); + 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 input.missionInstanceId - ? appendQuery("/missions/active", { missionInstanceId: input.missionInstanceId }) - : "/missions/active"; + return buildServiceLink(service.id, service.curator.defaultPage, state) + ?? appendQuery("/missions/active", { missionInstanceId: input.missionInstanceId }); } -export function getServiceDisplayName(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; +export function getServiceDisplayName(serviceId?: string, fallback = "Mission planner") { + return getService(serviceId)?.label ?? fallback; } -export function getServiceToolName(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"; +export function getServiceToolName(serviceId?: string) { + return getService(serviceId)?.curator.toolName ?? "prepare_mission_step"; } -export function getServiceCompletionEvents(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"]; +export function getCompletionEvents(serviceId?: string) { + return getService(serviceId)?.curator.completionEvents ?? ["curator.task.completed"]; } -export function getServiceActionLabel(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"; +export const getServiceCompletionEvents = getCompletionEvents; + +export function getServiceActionLabel(serviceId?: string, actionId?: string): string; +export function getServiceActionLabel(task: Pick): string; +export function getServiceActionLabel( + input?: string | Pick, + 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"; } diff --git a/src/v1/curator/curator-types.ts b/src/v1/curator/curator-types.ts index 58ebcbe..c051b4b 100644 --- a/src/v1/curator/curator-types.ts +++ b/src/v1/curator/curator-types.ts @@ -3,10 +3,14 @@ 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", + "pathways-service", ]); export type CuratorServiceId = z.infer; diff --git a/src/workflows/service-capabilities.ts b/src/workflows/service-capabilities.ts index 47ba439..da0b440 100644 --- a/src/workflows/service-capabilities.ts +++ b/src/workflows/service-capabilities.ts @@ -1,27 +1,50 @@ -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[] { 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, + ...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, })), ...internalWorkflowModules.map((module) => ({ id: module.id, -- 2.49.1 From 6a77bb5d2ef284f04a0afd38ada8f3025d2c81f9 Mon Sep 17 00:00:00 2001 From: Sai-karthik Date: Mon, 22 Jun 2026 21:31:58 +0000 Subject: [PATCH 02/15] Enrich service preview gateway payloads --- src/routes/services.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/routes/services.ts b/src/routes/services.ts index 24040cf..601aa03 100644 --- a/src/routes/services.ts +++ b/src/routes/services.ts @@ -586,7 +586,11 @@ export function serviceRoutes() { }).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()))); + app.post("/interview/preview", async (c) => { + const body = await c.req.json(); + 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 }>(); @@ -647,7 +651,11 @@ export function serviceRoutes() { }).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()))); + app.post("/roleplay/preview", async (c) => { + const body = await c.req.json(); + 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 }>(); -- 2.49.1 From fe62662cb626ed676979eee2c874c19fe5bb670c Mon Sep 17 00:00:00 2001 From: Sai-karthik Date: Mon, 22 Jun 2026 21:46:06 +0000 Subject: [PATCH 03/15] Harden service gateway smoke coverage --- scripts/service-registry-smoke.mjs | 208 ++++++++++++++++++++++++ src/routes/services.ts | 16 +- src/services/product-service-clients.ts | 82 +++++++--- 3 files changed, 276 insertions(+), 30 deletions(-) create mode 100644 scripts/service-registry-smoke.mjs diff --git a/scripts/service-registry-smoke.mjs b/scripts/service-registry-smoke.mjs new file mode 100644 index 0000000..9c67a39 --- /dev/null +++ b/scripts/service-registry-smoke.mjs @@ -0,0 +1,208 @@ +#!/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 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", + "pathways-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 }; + } 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), + }, 90000); + 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), + }, 90000); + assertGeneratedPreview(`${prefix} roleplay preview generation`, roleplayPreview.json); + + return { + iteration, + catalogCount: services.length, + interviewSession: interviewPreview.json.session_id, + roleplaySession: roleplayPreview.json.session_id, + }; +} + +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 })); diff --git a/src/routes/services.ts b/src/routes/services.ts index 601aa03..a7ad7f6 100644 --- a/src/routes/services.ts +++ b/src/routes/services.ts @@ -603,7 +603,7 @@ export function serviceRoutes() { app.get("/interview/review/:sessionId", async (c) => { const userId = c.get("userId"); const sessionId = c.req.param("sessionId"); - const result = await interviewService.review(sessionId); + const result = await interviewService.review(sessionId, userId); const resultObj = result as Record; await recordGatewayEvent({ userId, @@ -615,9 +615,9 @@ export function serviceRoutes() { return c.json(result); }); app.get("/interview/leaderboard", async (c) => c.json(await interviewService.leaderboard())); - app.get("/interview/artifacts/:sessionId/:artifactType", async (c) => c.json(await interviewService.artifact(c.req.param("sessionId"), c.req.param("artifactType")))); - app.post("/interview/sessions/:sessionId/video/upload-url", async (c) => c.json(await interviewService.createVideoUploadUrl(c.req.param("sessionId")))); - app.post("/interview/sessions/:sessionId/video/uploaded", async (c) => c.json(await interviewService.markVideoUploaded(c.req.param("sessionId")))); + app.get("/interview/artifacts/:sessionId/:artifactType", async (c) => c.json(await interviewService.artifact(c.req.param("sessionId"), c.req.param("artifactType"), c.get("userId")))); + app.post("/interview/sessions/:sessionId/video/upload-url", async (c) => c.json(await interviewService.createVideoUploadUrl(c.req.param("sessionId"), c.get("userId")))); + app.post("/interview/sessions/:sessionId/video/uploaded", async (c) => c.json(await interviewService.markVideoUploaded(c.req.param("sessionId"), c.get("userId")))); app.get("/roleplay/page-state", async (c) => { const userId = c.get("userId"); @@ -668,7 +668,7 @@ export function serviceRoutes() { app.get("/roleplay/review/:sessionId", async (c) => { const userId = c.get("userId"); const sessionId = c.req.param("sessionId"); - const result = await roleplayService.review(sessionId); + const result = await roleplayService.review(sessionId, userId); const resultObj = result as Record; await recordGatewayEvent({ userId, @@ -680,9 +680,9 @@ export function serviceRoutes() { return c.json(result); }); app.get("/roleplay/leaderboard", async (c) => c.json(await roleplayService.leaderboard())); - app.get("/roleplay/artifacts/:sessionId/:artifactType", async (c) => c.json(await roleplayService.artifact(c.req.param("sessionId"), c.req.param("artifactType")))); - app.post("/roleplay/sessions/:sessionId/video/upload-url", async (c) => c.json(await roleplayService.createVideoUploadUrl(c.req.param("sessionId")))); - app.post("/roleplay/sessions/:sessionId/video/uploaded", async (c) => c.json(await roleplayService.markVideoUploaded(c.req.param("sessionId")))); + app.get("/roleplay/artifacts/:sessionId/:artifactType", async (c) => c.json(await roleplayService.artifact(c.req.param("sessionId"), c.req.param("artifactType"), c.get("userId")))); + app.post("/roleplay/sessions/:sessionId/video/upload-url", async (c) => c.json(await roleplayService.createVideoUploadUrl(c.req.param("sessionId"), c.get("userId")))); + app.post("/roleplay/sessions/:sessionId/video/uploaded", async (c) => c.json(await roleplayService.markVideoUploaded(c.req.param("sessionId"), c.get("userId")))); app.get("/resume/state/:clerkId", async (c) => c.json(await resumeService.state(c.req.param("clerkId")))); app.post("/resume/tasks", async (c) => { diff --git a/src/services/product-service-clients.ts b/src/services/product-service-clients.ts index 28d882a..bf6113f 100644 --- a/src/services/product-service-clients.ts +++ b/src/services/product-service-clients.ts @@ -12,6 +12,16 @@ export type ServiceCallOptions = { 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 | 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( baseUrl: string, path: string, @@ -34,7 +44,10 @@ async function serviceJson( 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 }) => @@ -49,25 +62,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 }) => @@ -82,20 +109,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 = { -- 2.49.1 From d493ce8f3396fefcb07956f4d4a40e009fa4d859 Mon Sep 17 00:00:00 2001 From: Sai-karthik Date: Mon, 22 Jun 2026 22:09:38 +0000 Subject: [PATCH 04/15] Wire gateway user context through user service --- docker-compose.yml | 1 + src/routes/home.ts | 23 ++------ src/routes/missions.ts | 15 +----- src/routes/services.ts | 24 ++------- src/services/user-context.ts | 102 +++++++++++++++++++++++++++++++++++ 5 files changed, 113 insertions(+), 52 deletions(-) create mode 100644 src/services/user-context.ts diff --git a/docker-compose.yml b/docker-compose.yml index 794cac7..8496c72 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -116,6 +116,7 @@ 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} diff --git a/src/routes/home.ts b/src/routes/home.ts index 7c8be95..fa13d05 100644 --- a/src/routes/home.ts +++ b/src/routes/home.ts @@ -3,38 +3,25 @@ import { config } from "../config.js"; import { requireUser, type AuthContext } from "../auth/clerk.js"; import { dismissHomeNotification, getHomeFeed, getHomeFeedDebugCounts } from "../home/home-feed.js"; import { seedDemoHome } from "../home/seed-demo-home.js"; +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; preferences?: Record }> { - 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 | null; - const preferences = userProfile?.preferences; - return { - userProfile: userProfile ?? undefined, - preferences: preferences && typeof preferences === "object" && !Array.isArray(preferences) ? preferences as Record : {}, - }; -} - export function homeRoutes() { const app = new Hono(); 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 })); + return c.json(await getHomeFeed(userId, { refresh, ...profile })); }); app.post("/notifications/:id/dismiss", async (c) => { diff --git a/src/routes/missions.ts b/src/routes/missions.ts index cfe3f67..59c10a3 100644 --- a/src/routes/missions.ts +++ b/src/routes/missions.ts @@ -12,6 +12,7 @@ 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 | null = null; @@ -105,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> { - 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 | null; - const preferences = user?.preferences; - return preferences && typeof preferences === "object" && !Array.isArray(preferences) ? preferences as Record : {}; -} - export function missionRoutes() { const app = new Hono(); app.use("*", requireUser); @@ -183,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, diff --git a/src/routes/services.ts b/src/routes/services.ts index a7ad7f6..7b11040 100644 --- a/src/routes/services.ts +++ b/src/routes/services.ts @@ -9,6 +9,7 @@ import { events, growQscoreLatest, growQscoreProjectionState } from "../db/schem 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 = [ @@ -208,25 +209,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; preferences?: Record }> { - 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 | null; - const preferences = userProfile?.preferences; - return { - userProfile: userProfile ?? undefined, - preferences: isRecord(preferences) ? preferences : {}, - }; -} - -async function getUserServicePreferences(req: Request): Promise | undefined> { - return (await getUserServiceProfile(req)).preferences; -} - async function getServiceState(baseUrl: string, path: string): Promise | undefined> { const target = new URL(path, baseUrl.replace(/\/$/, "")); const res = await fetch(target, { @@ -253,7 +235,7 @@ function mergeUniqueSkills(existing: unknown, incoming: unknown): string[] { } async function resolveGrowUserContext(req: Request, userId: string): Promise> { - const { userProfile } = await getUserServiceProfile(req); + const { userProfile } = await getRequestUserProfile(req, userId); const userContext: Record = { ...(userProfile ?? {}) }; userContext.clerk_id = String(userContext.clerk_id ?? userId); @@ -491,7 +473,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"); } diff --git a/src/services/user-context.ts b/src/services/user-context.ts new file mode 100644 index 0000000..c61893a --- /dev/null +++ b/src/services/user-context.ts @@ -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; + preferences?: Record; +}; + +function isRecord(value: unknown): value is Record { + 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 | null | undefined, + userId: string, +): UserProfileContext { + const userProfile: Record = { ...(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 { + 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 | 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 | null> { + const headers = new Headers(); + headers.set("authorization", `Bearer ${config.a2aAllowedKey}`); + return fetchUserServiceJson(`/api/state/${encodeURIComponent(userId)}`, headers); +} + +async function clerkUserProfile(req: Request): Promise | 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 { + 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 | undefined> { + return (await getRequestUserProfile(req, userId)).preferences; +} -- 2.49.1 From a3a84faae77176f677a48f0f01d5d4742590fda4 Mon Sep 17 00:00:00 2001 From: Sai-karthik Date: Mon, 22 Jun 2026 22:19:03 +0000 Subject: [PATCH 05/15] Add service gateway write-flow smoke --- scripts/service-registry-write-flow.mjs | 211 ++++++++++++++++++++++++ 1 file changed, 211 insertions(+) create mode 100755 scripts/service-registry-write-flow.mjs diff --git a/scripts/service-registry-write-flow.mjs b/scripts/service-registry-write-flow.mjs new file mode 100755 index 0000000..03085d2 --- /dev/null +++ b/scripts/service-registry-write-flow.mjs @@ -0,0 +1,211 @@ +#!/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 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 }; + } 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), + }); + 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" }; +} + +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), + }); + 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" }; +} + +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 })); -- 2.49.1 From 610975561f3a93df52a83843362d41e68de7a219 Mon Sep 17 00:00:00 2001 From: Sai-karthik Date: Mon, 22 Jun 2026 22:29:32 +0000 Subject: [PATCH 06/15] Harden home feed agent generation --- docker-compose.yml | 2 + src/home/home-feed-agent.ts | 126 +++++++++++++++++++++++++++--------- src/routes/home.ts | 17 ++++- 3 files changed, 115 insertions(+), 30 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 8496c72..5975a42 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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} diff --git a/src/home/home-feed-agent.ts b/src/home/home-feed-agent.ts index c38502e..ad85988 100644 --- a/src/home/home-feed-agent.ts +++ b/src/home/home-feed-agent.ts @@ -16,14 +16,31 @@ const notificationSchema = z.object({ reason: z.string().max(160).optional(), }); +const rawNotificationSchema = notificationSchema.extend({ + moduleId: z.enum(MODULE_IDS as [HomeModuleId, ...HomeModuleId[]]).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; +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. @@ -37,6 +54,9 @@ Every notification must point to one of these real dashboard routes: - /pathways for locked/coming-soon pathways - /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.`; @@ -54,6 +74,44 @@ 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("/social")) return "social"; + if (href.startsWith("/pathways")) return "pathways"; + if (href.startsWith("/rewards")) return "rewards"; + return "system"; +} + +function moduleFromSource(source: NonNullable): 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 normalizeAgentNotification( + raw: z.infer, + seeds: Array & { moduleId: HomeModuleId }>, +): AgentHomeNotification { + const seed = seeds.find((item) => item.href === raw.href) + ?? seeds.find((item) => item.title.toLowerCase() === raw.title.toLowerCase()); + const href = sanitizeHref(raw.href, raw.moduleId ?? seed?.moduleId ?? "suggestions"); + const source = raw.source ?? seed?.source ?? sourceFromHref(href); + const moduleId = raw.moduleId ?? seed?.moduleId ?? moduleFromSource(source); + return notificationSchema.parse({ + ...raw, + href, + source, + moduleId, + }); +} + function parseJsonObject(text: string) { const cleaned = text.trim().replace(/^```(?:json)?/i, "").replace(/```$/i, "").trim(); try { @@ -71,36 +129,46 @@ export async function refineHomeNotificationsWithAgent(input: { context: Record; seeds: Array & { moduleId: HomeModuleId }>; }): Promise> { + 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 = feedSchema.parse({ + notifications: parsed.notifications.map((item) => normalizeAgentNotification(item, input.seeds)), + }).notifications; + 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.warn({ err, userId: input.userId, attempt, attempts: HOME_FEED_AGENT_ATTEMPTS }, "home feed agent attempt failed"); + } } + + throw new HomeFeedAgentError("home_feed_agent_generation_failed", lastError); } diff --git a/src/routes/home.ts b/src/routes/home.ts index fa13d05..7df8fee 100644 --- a/src/routes/home.ts +++ b/src/routes/home.ts @@ -2,6 +2,7 @@ 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"; @@ -21,7 +22,21 @@ export function homeRoutes() { log.warn({ err, userId }, "home feed continuing without user-service profile"); return {}; }); - return c.json(await getHomeFeed(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) => { -- 2.49.1 From 459832a2a3ba4a759c2f45d3c145b794e4ae0028 Mon Sep 17 00:00:00 2001 From: Sai-karthik Date: Mon, 22 Jun 2026 22:35:06 +0000 Subject: [PATCH 07/15] Add service registry acceptance probe --- scripts/service-registry-acceptance.mjs | 139 ++++++++++++++++++++++++ 1 file changed, 139 insertions(+) create mode 100755 scripts/service-registry-acceptance.mjs diff --git a/scripts/service-registry-acceptance.mjs b/scripts/service-registry-acceptance.mjs new file mode 100755 index 0000000..22448da --- /dev/null +++ b/scripts/service-registry-acceptance.mjs @@ -0,0 +1,139 @@ +#!/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", + "pathways-service", + "resume-service", + "cover-letter-service", + "qscore-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("career-pathways-service")?.id === "pathways-service", "pathways 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 capabilityServices = capabilities.listServiceCapabilities({ public: true }).filter((service) => requiredServices.includes(service.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); +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); + 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); +} + +console.log(JSON.stringify({ ok: true, services: services.length, requiredServices: requiredServices.length, liveCatalog: Boolean(baseUrl) })); -- 2.49.1 From cad24ea089bda48fff38454d97e7e00284100f3e Mon Sep 17 00:00:00 2001 From: Sai-karthik Date: Mon, 22 Jun 2026 22:48:30 +0000 Subject: [PATCH 08/15] Keep public service catalog registry-only --- scripts/service-registry-acceptance.mjs | 7 +++++- scripts/service-registry-smoke.mjs | 13 ++++++++-- scripts/service-registry-write-flow.mjs | 33 ++++++++++++++++++++++--- src/workflows/service-capabilities.ts | 10 +++++--- 4 files changed, 53 insertions(+), 10 deletions(-) diff --git a/scripts/service-registry-acceptance.mjs b/scripts/service-registry-acceptance.mjs index 22448da..b5c00b9 100755 --- a/scripts/service-registry-acceptance.mjs +++ b/scripts/service-registry-acceptance.mjs @@ -104,9 +104,12 @@ 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 capabilityServices = capabilities.listServiceCapabilities({ public: true }).filter((service) => requiredServices.includes(service.id)); +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}`); @@ -130,10 +133,12 @@ if (baseUrl) { 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) })); diff --git a/scripts/service-registry-smoke.mjs b/scripts/service-registry-smoke.mjs index 9c67a39..bc450e3 100644 --- a/scripts/service-registry-smoke.mjs +++ b/scripts/service-registry-smoke.mjs @@ -12,6 +12,7 @@ for (let i = 2; i < process.argv.length; 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) { @@ -73,6 +74,12 @@ async function request(name, url, init = {}, timeoutMs = 15000) { 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); } @@ -166,7 +173,7 @@ async function runIteration(iteration) { method: "POST", headers: authHeaders({ "content-type": "application/json" }), body: JSON.stringify(interviewPayload), - }, 90000); + }, previewTimeoutMs); assertGeneratedPreview(`${prefix} interview preview generation`, interviewPreview.json); const roleplayPayload = { @@ -187,14 +194,16 @@ async function runIteration(iteration) { method: "POST", headers: authHeaders({ "content-type": "application/json" }), body: JSON.stringify(roleplayPayload), - }, 90000); + }, 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, }; } diff --git a/scripts/service-registry-write-flow.mjs b/scripts/service-registry-write-flow.mjs index 03085d2..25a7ea0 100755 --- a/scripts/service-registry-write-flow.mjs +++ b/scripts/service-registry-write-flow.mjs @@ -12,6 +12,7 @@ for (let i = 2; i < process.argv.length; 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) { @@ -48,6 +49,12 @@ async function request(name, path, init = {}, timeoutMs = 90000) { 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); } @@ -113,7 +120,7 @@ async function runInterviewFlow(iteration) { method: "POST", headers: authHeaders({ "content-type": "application/json" }), body: JSON.stringify(previewPayload), - }); + }, previewTimeoutMs); assertDraftPreview(`${prefix} preview`, preview.json); const questions = asInterviewQuestions(preview.json, iteration); @@ -143,7 +150,16 @@ async function runInterviewFlow(iteration) { 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" }; + 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) { @@ -166,7 +182,7 @@ async function runRoleplayFlow(iteration) { method: "POST", headers: authHeaders({ "content-type": "application/json" }), body: JSON.stringify(previewPayload), - }); + }, previewTimeoutMs); assertDraftPreview(`${prefix} preview`, preview.json); const questions = asRoleplayPrompts(preview.json, iteration); @@ -196,7 +212,16 @@ async function runRoleplayFlow(iteration) { 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" }; + 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 = []; diff --git a/src/workflows/service-capabilities.ts b/src/workflows/service-capabilities.ts index da0b440..1e4f4f5 100644 --- a/src/workflows/service-capabilities.ts +++ b/src/workflows/service-capabilities.ts @@ -21,8 +21,7 @@ export type ServiceCapability = { }; export function listServiceCapabilities(opts: { public?: boolean } = {}): ServiceCapability[] { - return [ - ...listServices().map((service) => ({ + const services = listServices().map((service) => ({ id: service.id, name: service.label, label: service.label, @@ -45,7 +44,12 @@ export function listServiceCapabilities(opts: { public?: boolean } = {}): Servic frontend: service.frontend, curator: service.curator, usageDocs: service.usageDocs, - })), + })); + + if (opts.public) return services; + + return [ + ...services, ...internalWorkflowModules.map((module) => ({ id: module.id, name: module.title, -- 2.49.1 From bff336baa75fbd8d5973ae14c6e611b0a8491a65 Mon Sep 17 00:00:00 2001 From: Sai-karthik Date: Mon, 22 Jun 2026 23:16:52 +0000 Subject: [PATCH 09/15] Add generated content quality smoke --- scripts/service-registry-content-quality.mjs | 148 +++++++++++++++++++ 1 file changed, 148 insertions(+) create mode 100755 scripts/service-registry-content-quality.mjs diff --git a/scripts/service-registry-content-quality.mjs b/scripts/service-registry-content-quality.mjs new file mode 100755 index 0000000..2f1f7ef --- /dev/null +++ b/scripts/service-registry-content-quality.mjs @@ -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 })); -- 2.49.1 From 1cbd3e1a84dda0772118be2826d4598e3c9b13b5 Mon Sep 17 00:00:00 2001 From: Sai-karthik Date: Mon, 22 Jun 2026 23:41:00 +0000 Subject: [PATCH 10/15] Repair missing home feed agent tags --- src/home/home-feed-agent.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/home/home-feed-agent.ts b/src/home/home-feed-agent.ts index ad85988..0c90995 100644 --- a/src/home/home-feed-agent.ts +++ b/src/home/home-feed-agent.ts @@ -18,6 +18,7 @@ const notificationSchema = z.object({ const rawNotificationSchema = notificationSchema.extend({ moduleId: z.enum(MODULE_IDS as [HomeModuleId, ...HomeModuleId[]]).optional(), + tag: z.string().min(2).max(14).optional(), source: z.enum(["resume", "interview", "roleplay", "qscore", "mission", "social", "pathways", "rewards", "system"]).optional(), }); @@ -95,6 +96,18 @@ function moduleFromSource(source: NonNullable): return "suggestions"; } +function tagFromSource(source: NonNullable) { + 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 normalizeAgentNotification( raw: z.infer, seeds: Array & { moduleId: HomeModuleId }>, @@ -106,6 +119,7 @@ function normalizeAgentNotification( const moduleId = raw.moduleId ?? seed?.moduleId ?? moduleFromSource(source); return notificationSchema.parse({ ...raw, + tag: raw.tag ?? seed?.tag ?? tagFromSource(source), href, source, moduleId, -- 2.49.1 From f888a6fc0d29026f64c2d0b4a942064004bee131 Mon Sep 17 00:00:00 2001 From: Sai-karthik Date: Tue, 23 Jun 2026 05:04:55 +0000 Subject: [PATCH 11/15] Remove QScore estimate fallback --- src/routes/workflows.ts | 2 +- src/services/service-agents.ts | 10 ++-------- src/workflows/module-runner.ts | 2 +- 3 files changed, 4 insertions(+), 10 deletions(-) diff --git a/src/routes/workflows.ts b/src/routes/workflows.ts index c0977d3..14785c8 100644 --- a/src/routes/workflows.ts +++ b/src/routes/workflows.ts @@ -249,7 +249,7 @@ async function runModulesUntilGate(input: { } function extractQScore(output: Record): 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 | undefined; if (typeof compute?.q_score === "number") return Math.round(compute.q_score); diff --git a/src/services/service-agents.ts b/src/services/service-agents.ts index e80c9a5..781e075 100644 --- a/src/services/service-agents.ts +++ b/src/services/service-agents.ts @@ -236,18 +236,12 @@ async function runQScoreService(ctx: ServiceAgentContext): Promise sum + s.score, 0) / signals.length, - ); return { - status: "ok", - summary: `Q Score estimated Q Score ~${avgSignalScore} (service compute unavailable: formula store may not be seeded). Based on ${signals.length} signals.`, + status: "unavailable", + summary: `Q Score compute failed; no score was generated: ${err instanceof Error ? err.message : String(err)}`, detail: { ingest, - estimated_q_score: avgSignalScore, signal_scores: signals.map(s => ({ id: s.signal_id, score: s.score })), - compute_fallback: true, compute_error: err instanceof Error ? err.message : String(err), }, }; diff --git a/src/workflows/module-runner.ts b/src/workflows/module-runner.ts index 4de27b0..aa5af49 100644 --- a/src/workflows/module-runner.ts +++ b/src/workflows/module-runner.ts @@ -81,7 +81,7 @@ export async function updateRunProgress(runId: string) { } function extractQScore(output: Record): 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 | undefined; if (typeof compute?.q_score === "number") return Math.round(compute.q_score); -- 2.49.1 From 7bad0a46c2bcb1d64850825ea83507d765b57a33 Mon Sep 17 00:00:00 2001 From: Sai-karthik Date: Tue, 23 Jun 2026 05:37:47 +0000 Subject: [PATCH 12/15] Repair missing home feed hrefs --- src/home/home-feed-agent.ts | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/src/home/home-feed-agent.ts b/src/home/home-feed-agent.ts index 0c90995..829fa51 100644 --- a/src/home/home-feed-agent.ts +++ b/src/home/home-feed-agent.ts @@ -19,6 +19,7 @@ const notificationSchema = z.object({ 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(), }); @@ -108,15 +109,29 @@ function tagFromSource(source: NonNullable) { return "Update"; } +function defaultHrefForSource(source: NonNullable, 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 "/social"; + if (source === "pathways") return "/pathways"; + if (source === "rewards") return "/rewards"; + return moduleId === "productivity" ? "/agents" : `/${moduleId}`; +} + function normalizeAgentNotification( raw: z.infer, seeds: Array & { moduleId: HomeModuleId }>, ): AgentHomeNotification { - const seed = seeds.find((item) => item.href === raw.href) + const seed = (raw.href ? seeds.find((item) => item.href === raw.href) : undefined) ?? seeds.find((item) => item.title.toLowerCase() === raw.title.toLowerCase()); - const href = sanitizeHref(raw.href, raw.moduleId ?? seed?.moduleId ?? "suggestions"); + 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); - const moduleId = raw.moduleId ?? seed?.moduleId ?? moduleFromSource(source); return notificationSchema.parse({ ...raw, tag: raw.tag ?? seed?.tag ?? tagFromSource(source), -- 2.49.1 From 29ed0a15cdd78bf348dc1384e9c5cd12ca0c5e98 Mon Sep 17 00:00:00 2001 From: Sai-karthik Date: Tue, 23 Jun 2026 06:23:56 +0000 Subject: [PATCH 13/15] Throttle user stack provisioning retries --- src/docker/manager.ts | 8 ++++++++ src/routes/users.ts | 8 +++++++- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/src/docker/manager.ts b/src/docker/manager.ts index a6a203b..93a4546 100644 --- a/src/docker/manager.ts +++ b/src/docker/manager.ts @@ -333,6 +333,12 @@ export async function provisionUserStack(userId: string): Promise { 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 { 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)"); } } diff --git a/src/routes/users.ts b/src/routes/users.ts index 72b1f79..36402fd 100644 --- a/src/routes/users.ts +++ b/src/routes/users.ts @@ -14,6 +14,12 @@ function publicStack(stack: UserStack | null | undefined) { return safe; } +function shouldStartProvisioning(stack: UserStack | null | undefined) { + if (!stack || stack.status === "error" || stack.status === "stopped") return true; + if (stack.status !== "provisioning") return false; + return Date.now() - stack.updatedAt.getTime() >= 5 * 60_000; +} + function userServiceTarget(path: string, search = "") { return new URL(`/api/v1/users${path}${search}`, config.userServiceUrl.replace(/\/$/, "")); } @@ -79,7 +85,7 @@ export function userRoutes() { where: eq(userStacks.userId, userId), }); - if (!stack || stack.status !== "running") { + if (shouldStartProvisioning(stack)) { void provisionUserStack(userId).catch((err) => log.error({ err, userId }, "background provision failed"), ); -- 2.49.1 From 40920256932249a905d6c9ae6281d4876a4d6de7 Mon Sep 17 00:00:00 2001 From: Sai-karthik Date: Tue, 23 Jun 2026 08:41:55 +0000 Subject: [PATCH 14/15] Backfill short home feed agent responses --- src/home/home-feed-agent.ts | 34 +++++++++++++++++++++++++++++++--- 1 file changed, 31 insertions(+), 3 deletions(-) diff --git a/src/home/home-feed-agent.ts b/src/home/home-feed-agent.ts index 829fa51..dae223d 100644 --- a/src/home/home-feed-agent.ts +++ b/src/home/home-feed-agent.ts @@ -141,6 +141,33 @@ function normalizeAgentNotification( }); } +function notificationKey(notification: AgentHomeNotification) { + return [ + notification.moduleId, + notification.href, + notification.title.trim().toLowerCase(), + ].join(":"); +} + +function completeNotificationsWithSeeds( + notifications: AgentHomeNotification[], + seeds: Array & { 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 { @@ -183,9 +210,10 @@ export async function refineHomeNotificationsWithAgent(input: { }); const parsed = rawFeedSchema.parse(parseJsonObject(result.text)); - const notifications = feedSchema.parse({ - notifications: parsed.notifications.map((item) => normalizeAgentNotification(item, input.seeds)), - }).notifications; + const notifications = completeNotificationsWithSeeds( + parsed.notifications.map((item) => normalizeAgentNotification(item, input.seeds)), + input.seeds, + ); const now = new Date().toISOString(); return notifications.map((n, index) => ({ ...n, -- 2.49.1 From dbc984ed7fdc7fdc7f709a1282f649952a36eff4 Mon Sep 17 00:00:00 2001 From: Sai-karthik Date: Tue, 23 Jun 2026 08:54:37 +0000 Subject: [PATCH 15/15] Keep home feed available when agent generation slows --- src/home/home-feed-agent.ts | 2 +- src/home/home-feed.ts | 43 +++++++++++++++++++++---------------- 2 files changed, 26 insertions(+), 19 deletions(-) diff --git a/src/home/home-feed-agent.ts b/src/home/home-feed-agent.ts index dae223d..99bc039 100644 --- a/src/home/home-feed-agent.ts +++ b/src/home/home-feed-agent.ts @@ -223,7 +223,7 @@ export async function refineHomeNotificationsWithAgent(input: { })); } catch (err) { lastError = err; - log.warn({ err, userId: input.userId, attempt, attempts: HOME_FEED_AGENT_ATTEMPTS }, "home feed agent attempt failed"); + log.debug({ err, userId: input.userId, attempt, attempts: HOME_FEED_AGENT_ATTEMPTS }, "home feed agent attempt failed"); } } diff --git a/src/home/home-feed.ts b/src/home/home-feed.ts index fce5315..50f8b5b 100644 --- a/src/home/home-feed.ts +++ b/src/home/home-feed.ts @@ -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, @@ -17,7 +18,7 @@ import { 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 { @@ -634,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> = []; + 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 -- 2.49.1