[PRM-63] Backend service registry issue solved #9

Merged
dv merged 16 commits from prm-63-service-registry into staging 2026-06-23 18:49:19 +00:00
25 changed files with 2136 additions and 358 deletions

View File

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

View File

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

View File

@@ -0,0 +1,144 @@
#!/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 publicCapabilities = capabilities.listServiceCapabilities({ public: true });
const capabilityServices = publicCapabilities.filter((service) => requiredServices.includes(service.id));
assert(publicCapabilities.length === services.length, "public capabilities should only expose canonical registry services", publicCapabilities.map((s) => s.id));
assert(capabilityServices.length === requiredServices.length, "public capabilities missing required services", capabilityServices.map((s) => s.id));
assert(!capabilityServices.some((service) => service.internalUrl || service.backend?.baseUrl), "public capabilities leak internal URL", capabilityServices);
assert(!publicCapabilities.some((service) => service.id === "mission-planning"), "public capabilities leak internal mission-planning module", publicCapabilities);
for (const service of capabilityServices) {
const record = registry.getService(service.id);
assert(record, `capability references unknown registry service ${service.id}`);
assert(JSON.stringify(service.operations) === JSON.stringify(Object.keys(record.backend.endpoints)), `${service.id} operations not derived from endpoints`, {
operations: service.operations,
endpoints: Object.keys(record.backend.endpoints),
});
}
const baseUrl = args.get("base-url") || process.env.BACKEND_BASE_URL;
const serviceToken = process.env.SERVICE_TOKEN;
if (baseUrl) {
assert(serviceToken, "SERVICE_TOKEN is required when --base-url/BACKEND_BASE_URL is provided");
const response = await fetch(`${baseUrl.replace(/\/$/, "")}/services/catalog`, {
headers: {
authorization: `Bearer ${serviceToken}`,
"x-growqr-user": "registry-acceptance",
},
});
const text = await response.text();
assert(response.ok, `live /services/catalog returned HTTP ${response.status}`, text);
const live = JSON.parse(text);
assert(Array.isArray(live.services), "live catalog missing services", live);
assert(live.services.length === services.length, "live catalog should only expose canonical registry services", live.services.map((service) => service.id));
for (const id of requiredServices) {
assert(live.services.some((service) => service.id === id), `live catalog missing ${id}`, live);
}
assert(!live.services.some((service) => service.backend?.baseUrl), "live catalog leaks backend.baseUrl", live);
assert(!live.services.some((service) => service.id === "mission-planning"), "live catalog leaks internal mission-planning module", live);
}
console.log(JSON.stringify({ ok: true, services: services.length, requiredServices: requiredServices.length, liveCatalog: Boolean(baseUrl) }));

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,6 @@
import { and, desc, eq, gt, inArray, isNull, or, sql } from "drizzle-orm";
import { db } from "../db/client.js";
import { log } from "../log.js";
import {
growActiveMissions,
growEvents,
@@ -15,8 +16,9 @@ import {
type NewGrowHomeNotification,
} from "../db/schema.js";
import { interviewService, resumeService, roleplayService } from "../services/product-service-clients.js";
import { buildServiceLink } from "../services/service-registry.js";
import { ensureOnboardingBaselineQscore } from "../events/onboarding-qscore.js";
import { refineHomeNotificationsWithAgent } from "./home-feed-agent.js";
import { HomeFeedAgentError, refineHomeNotificationsWithAgent } from "./home-feed-agent.js";
import { listAvailableMissionDefinitions } from "../missions/registry.js";
import { listServiceCapabilities } from "../workflows/service-capabilities.js";
import {
@@ -35,13 +37,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 +103,22 @@ function profileFromPreferences(preferences: Record<string, unknown>) {
function serviceHref(service: "resume" | "interview" | "roleplay" | "qscore", ctx: HomeContext, mission?: { instanceId?: string; missionId?: string; stageId?: string | null }) {
const profile = profileFromPreferences(ctx.preferences);
const params = new URLSearchParams({ source: "home" });
if (mission?.instanceId) params.set("missionInstanceId", mission.instanceId);
if (mission?.missionId) params.set("missionId", mission.missionId);
if (mission?.stageId) params.set("stageId", mission.stageId);
params.set("targetRole", profile.targetRole);
if (profile.targetCompany !== "target company") params.set("targetCompany", profile.targetCompany);
if (profile.industry) params.set("industry", profile.industry);
if (profile.focusAreas.length) params.set("focusAreas", profile.focusAreas.slice(0, 4).join(","));
if (profile.weakSpots.length) params.set("weakSpots", profile.weakSpots.slice(0, 3).join(","));
if (profile.jobDescription) params.set("jobDescription", profile.jobDescription.slice(0, 900));
if (service === "interview") return `/agents/interview/setup?${params.toString()}`;
if (service === "roleplay") return `/agents/roleplay/setup?${params.toString()}`;
if (service === "resume") return `/agents/resume?${params.toString()}`;
return `/agents/qscore?${params.toString()}`;
const serviceId = service === "qscore" ? "qscore-service" : `${service}-service`;
const pageId = service === "resume" ? "workspace" : service === "qscore" ? "dashboard" : "setup";
return buildServiceLink(serviceId, pageId, {
source: "home",
missionInstanceId: mission?.instanceId,
missionId: mission?.missionId,
stageId: mission?.stageId ?? undefined,
targetRole: profile.targetRole,
role: profile.targetRole,
targetCompany: profile.targetCompany !== "target company" ? profile.targetCompany : undefined,
industry: profile.industry,
focusAreas: profile.focusAreas.length ? profile.focusAreas.slice(0, 4).join(",") : undefined,
weakSpots: profile.weakSpots.length ? profile.weakSpots.slice(0, 3).join(",") : undefined,
jobDescription: profile.jobDescription?.slice(0, 900),
type: service === "interview" ? "behavioral" : undefined,
}) ?? SERVICE_HREFS[service];
}
function sourceFromSuggestionRole(role: string): HomeSource {
@@ -631,23 +635,29 @@ export async function getHomeFeed(userId: string, opts: { refresh?: boolean; use
const dayOneSeeds = buildDayOneSeeds();
const deterministic = hasAnyRealActivity(ctx) ? buildDynamicSeeds(ctx) : dayOneSeeds;
const agentNotifications = await refineHomeNotificationsWithAgent({
userId,
context: {
qscore: ctx.qscore,
qscoreSignals: ctx.qscoreSignals,
activeMissions: ctx.activeMissions,
sessions: ctx.sessions,
artifacts: ctx.artifacts,
recentEvents: ctx.events,
serviceStates: ctx.serviceStates,
missionSuggestions: ctx.missionSuggestions,
userProfile: ctx.userProfile,
preferences: ctx.preferences,
routeRules: SERVICE_HREFS,
},
seeds: deterministic,
});
let agentNotifications: Awaited<ReturnType<typeof refineHomeNotificationsWithAgent>> = [];
try {
agentNotifications = await refineHomeNotificationsWithAgent({
userId,
context: {
qscore: ctx.qscore,
qscoreSignals: ctx.qscoreSignals,
activeMissions: ctx.activeMissions,
sessions: ctx.sessions,
artifacts: ctx.artifacts,
recentEvents: ctx.events,
serviceStates: ctx.serviceStates,
missionSuggestions: ctx.missionSuggestions,
userProfile: ctx.userProfile,
preferences: ctx.preferences,
routeRules: SERVICE_HREFS,
},
seeds: deterministic,
});
} catch (err) {
if (!(err instanceof HomeFeedAgentError)) throw err;
log.info({ userId }, "home feed agent unavailable, using deterministic notifications");
}
const generatedBy = agentNotifications.length ? "agent" : "deterministic";
const generatedSeeds: SeedNotification[] = agentNotifications.length

View File

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

View File

@@ -1,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) {

View File

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

View File

@@ -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<Registry> | 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<Record<string, unknown>> {
const target = new URL("/api/v1/users/me", config.userServiceUrl.replace(/\/$/, ""));
const headers = new Headers(req.headers);
headers.delete("host");
headers.delete("cookie");
const res = await fetch(target, { method: "GET", headers });
if (!res.ok) return {};
const user = await res.json().catch(() => null) as Record<string, unknown> | null;
const preferences = user?.preferences;
return preferences && typeof preferences === "object" && !Array.isArray(preferences) ? preferences as Record<string, unknown> : {};
}
export function missionRoutes() {
const app = new Hono<AuthContext>();
app.use("*", requireUser);
@@ -183,7 +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,

View File

@@ -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<string, unknown>; preferences?: Record<string, unknown> }> {
const target = new URL("/api/v1/users/me", config.userServiceUrl.replace(/\/$/, ""));
const headers = new Headers(req.headers);
headers.delete("host");
headers.delete("cookie");
const res = await fetch(target, { method: "GET", headers });
if (!res.ok) return {};
const userProfile = await res.json().catch(() => null) as Record<string, unknown> | null;
const preferences = userProfile?.preferences;
return {
userProfile: userProfile ?? undefined,
preferences: isRecord(preferences) ? preferences : {},
};
}
async function getUserServicePreferences(req: Request): Promise<Record<string, unknown> | undefined> {
return (await getUserServiceProfile(req)).preferences;
}
async function getServiceState(baseUrl: string, path: string): Promise<Record<string, unknown> | undefined> {
const target = new URL(path, baseUrl.replace(/\/$/, ""));
const res = await fetch(target, {
@@ -253,7 +235,7 @@ function mergeUniqueSkills(existing: unknown, incoming: unknown): string[] {
}
async function resolveGrowUserContext(req: Request, userId: string): Promise<Record<string, unknown>> {
const { userProfile } = await getUserServiceProfile(req);
const { userProfile } = await getRequestUserProfile(req, userId);
const userContext: Record<string, unknown> = { ...(userProfile ?? {}) };
userContext.clerk_id = String(userContext.clerk_id ?? userId);
@@ -442,7 +424,7 @@ export function serviceRoutes() {
const app = new Hono<AuthContext>();
app.use("*", requireUser);
app.get("/catalog", (c) => c.json({ services: listServiceCapabilities() }));
app.get("/catalog", (c) => c.json({ services: listServiceCapabilities({ public: true }) }));
app.get("/agents", async (c) => {
const userId = c.get("userId");
@@ -491,7 +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");
}
@@ -586,7 +568,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<JsonObject>())));
app.post("/interview/preview", async (c) => {
const body = await c.req.json<JsonObject>();
const payload = await buildPersonalizedConfigurePayload(c.req.raw, body, c.get("userId"));
return c.json(await interviewService.preview(payload));
});
app.post("/interview/questions", async (c) => c.json(await interviewService.editQuestions(await c.req.json())));
app.post("/interview/approve", async (c) => {
const body = await c.req.json<{ session_id: string }>();
@@ -599,7 +585,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<string, unknown>;
await recordGatewayEvent({
userId,
@@ -611,9 +597,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");
@@ -647,7 +633,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<JsonObject>())));
app.post("/roleplay/preview", async (c) => {
const body = await c.req.json<JsonObject>();
const payload = await buildPersonalizedRoleplayConfigurePayload(c.req.raw, body, c.get("userId"));
return c.json(await roleplayService.preview(payload));
});
app.post("/roleplay/questions", async (c) => c.json(await roleplayService.editQuestions(await c.req.json())));
app.post("/roleplay/approve", async (c) => {
const body = await c.req.json<{ session_id: string }>();
@@ -660,7 +650,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<string, unknown>;
await recordGatewayEvent({
userId,
@@ -672,9 +662,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) => {

View File

@@ -18,6 +18,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(/\/$/, ""));
}
@@ -83,7 +89,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"),
);

View File

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

View File

@@ -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<string, string> | undefined {
return userId ? { "x-growqr-user": userId } : undefined;
}
function resolveUserPayload(userIdOrPayload?: string | JsonObject, payload?: JsonObject) {
return typeof userIdOrPayload === "string"
? { userId: userIdOrPayload, payload }
: { userId: undefined, payload: userIdOrPayload };
}
async function serviceJson<T = JsonObject>(
baseUrl: string,
path: string,
@@ -34,7 +44,10 @@ async function serviceJson<T = JsonObject>(
export const interviewService = {
health: () => serviceJson(config.interviewServiceUrl, "/health"),
pageState: (userId: string) => serviceJson(config.interviewServiceUrl, `/api/v1/interviews/page-state?${new URLSearchParams({ user_id: userId })}`),
pageState: (userId: string) =>
serviceJson(config.interviewServiceUrl, `/api/v1/interviews/page-state?${new URLSearchParams({ user_id: userId })}`, {
headers: userHeader(userId),
}),
configure: (payload: JsonObject) => serviceJson(config.interviewServiceUrl, "/api/v1/configure", { body: payload, timeoutMs: INTERACTIVE_SERVICE_TIMEOUT_MS }),
preview: (payload: JsonObject) => serviceJson(config.interviewServiceUrl, "/api/v1/configure/preview", { body: payload, timeoutMs: INTERACTIVE_SERVICE_TIMEOUT_MS }),
editQuestions: (payload: { session_id: string; questions: Array<JsonObject | string> }) =>
@@ -49,25 +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<JsonObject | string> }) =>
@@ -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 = {

View File

@@ -236,18 +236,12 @@ async function runQScoreService(ctx: ServiceAgentContext): Promise<ServiceAgentR
},
);
} catch (err) {
// Graceful fallback: formula store unavailable → use static estimate
const avgSignalScore = Math.round(
signals.reduce((sum, s) => sum + s.score, 0) / signals.length,
);
return {
status: "ok",
summary: `Q Score estimated Q Score ~${avgSignalScore} (service compute unavailable: formula store may not be seeded). Based on ${signals.length} signals.`,
status: "unavailable",
summary: `Q Score compute failed; no score was generated: ${err instanceof Error ? err.message : String(err)}`,
detail: {
ingest,
estimated_q_score: avgSignalScore,
signal_scores: signals.map(s => ({ id: s.signal_id, score: s.score })),
compute_fallback: true,
compute_error: err instanceof Error ? err.message : String(err),
},
};

View File

@@ -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<string, QueryValue>;
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<string, ServiceEndpoint>;
usage: string;
};
frontend: {
baseUrl: string;
pages: Record<string, ServiceFrontendPage>;
usage: string;
};
curator: {
defaultPage: string;
defaultActionLabel: string;
defaultQueryState?: QueryState;
actionLabels?: Record<string, string>;
toolName: string;
completionEvents: string[];
qscoreSignals?: string[];
usage: string;
};
usageDocs: string[];
};
type MissionServiceId = Extract<CuratorServiceId, "interview-service" | "roleplay-service" | "resume-service">;
@@ -26,10 +90,16 @@ type CuratorRouteInput = {
roleplayBrief?: string;
};
function appendQuery(
pathname: string,
params: Record<string, QueryValue>,
) {
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<string, unknown>) {
return getString(detail?.session_id ?? detail?.sessionId ?? detail?.id);
}
const frontendBaseUrl = config.workflowsDashboardUrl.replace(/\/$/, "");
const serviceRegistry: ServiceRecord[] = [
{
id: "interview-service",
label: "Interview",
description: "Configure, practice, review, and score mock interview sessions.",
category: "practice",
enabled: Boolean(config.interviewServiceUrl),
featureId: "mock-interview",
promptModulePath: "agents/interview.md",
backend: {
baseUrl: config.interviewServiceUrl,
publicUrl: config.interviewPublicUrl,
healthPath: "/health",
endpoints: {
health: endpoint("GET", "/health", "Readiness probe.", "Check service availability before a handoff."),
pageState: endpoint("GET", "/api/v1/interviews/page-state?user_id=:userId", "Returns usage and personalization state.", "Hydrate interview landing/setup pages."),
configure: endpoint("POST", "/api/v1/configure", "Creates an interview plan from user, org, persona, type, duration, and context.", "Use for committed setup requests."),
preview: endpoint("POST", "/api/v1/configure/preview", "Creates a preview plan without starting live practice.", "Use for curator/dashboard previews."),
questions: endpoint("POST", "/api/v1/configure/questions", "Edits generated questions for a session.", "Use after preview edits."),
approve: endpoint("POST", "/api/v1/configure/approve", "Approves a generated session by session_id.", "Use when a user accepts a preview."),
assignments: endpoint("GET", "/api/v1/interviews/assignments", "Lists interview assignments by email/status/limit.", "Render assigned practice work."),
createAssignments: endpoint("POST", "/api/v1/interviews/assignments", "Creates interview assignments for assignee emails.", "Admin or organization handoffs."),
unassign: endpoint("POST", "/api/v1/interviews/assignments/unassign", "Removes interview assignments.", "Admin cleanup."),
resultsBulk: endpoint("POST", "/api/v1/interviews/results:bulk", "Fetches result summaries for multiple sessions.", "Dashboard history and summaries."),
review: endpoint("GET", "/api/v1/review/:sessionId", "Returns review/status for a session.", "Poll or open feedback."),
leaderboard: endpoint("GET", "/api/v1/leaderboard", "Returns interview leaderboard.", "Leaderboard widgets."),
artifact: endpoint("GET", "/api/v1/artifacts/:sessionId/:artifactType", "Returns session artifacts.", "Fetch transcript, report, or media artifacts."),
videoUploadUrl: endpoint("POST", "/api/v1/sessions/:sessionId/video/upload-url", "Returns signed upload instructions.", "Browser upload setup."),
markVideoUploaded: endpoint("POST", "/api/v1/sessions/:sessionId/video/uploaded", "Marks uploaded video as available.", "Complete upload flow."),
},
usage: "Backend callers should use the gateway /services/interview/* routes when user auth, mission correlation, and event recording are required.",
},
frontend: {
baseUrl: frontendBaseUrl,
pages: {
discovery: {
path: "/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<string, ServiceId>();
for (const service of serviceRegistry) {
serviceAliases.set(service.id, service.id);
for (const alias of service.aliases ?? []) serviceAliases.set(alias, service.id);
}
export function normalizeServiceId(serviceId?: string | null): ServiceId | undefined {
if (!serviceId) return undefined;
return serviceAliases.get(serviceId);
}
export function listServices() {
return serviceRegistry;
}
export function getService(serviceId?: string | null) {
const normalized = normalizeServiceId(serviceId);
return normalized ? serviceRegistry.find((service) => service.id === normalized) : undefined;
}
export function getServiceBackend(serviceId?: string | null) {
return getService(serviceId)?.backend;
}
export function getServiceFrontend(serviceId?: string | null) {
return getService(serviceId)?.frontend;
}
export function getServiceEndpoint(serviceId: string | undefined, endpointId: string) {
return getService(serviceId)?.backend.endpoints[endpointId];
}
export function getServiceUsageDocs(serviceId?: string | null) {
return getService(serviceId)?.usageDocs ?? [];
}
function resolvePage(service: ServiceRecord, pageId?: string) {
const selectedPageId = pageId || service.curator.defaultPage;
const direct = service.frontend.pages[selectedPageId];
if (direct) return direct;
return Object.values(service.frontend.pages).find((page) => page.aliases?.includes(selectedPageId));
}
export function buildServiceLink(serviceId: string | undefined, pageId?: string, state: QueryState = {}) {
const service = getService(serviceId);
if (!service) return undefined;
const page = resolvePage(service, pageId);
if (!page) return undefined;
const includeDefaultState = !pageId || pageId === service.curator.defaultPage;
return appendQuery(page.path, {
...(includeDefaultState ? service.curator.defaultQueryState : {}),
...state,
});
}
export function listServicesForCatalog() {
return serviceRegistry.map((service) => ({
id: service.id,
label: service.label,
description: service.description,
category: service.category,
enabled: service.enabled,
featureId: service.featureId,
backend: {
publicUrl: service.backend.publicUrl,
healthPath: service.backend.healthPath,
endpoints: service.backend.endpoints,
usage: service.backend.usage,
},
frontend: service.frontend,
curator: service.curator,
usageDocs: service.usageDocs,
}));
}
export function buildServiceSessionPath(
serviceId: MissionServiceId,
detail?: Record<string, unknown>,
@@ -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<CuratorTask, "serviceId" | "cta">): string;
export function getServiceActionLabel(
input?: string | Pick<CuratorTask, "serviceId" | "cta">,
actionId?: string,
) {
if (typeof input === "object") {
const service = getService(input.serviceId);
if (actionId && service?.curator.actionLabels?.[actionId]) return service.curator.actionLabels[actionId];
return service?.curator.defaultActionLabel ?? input.cta ?? "Open";
}
const service = getService(input);
if (actionId && service?.curator.actionLabels?.[actionId]) return service.curator.actionLabels[actionId];
return service?.curator.defaultActionLabel ?? "Open";
}

View File

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

View File

@@ -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<typeof curatorServiceIdSchema>;

View File

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

View File

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