[PRM-63] Backend service registry issue solved #9
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
144
scripts/service-registry-acceptance.mjs
Executable file
144
scripts/service-registry-acceptance.mjs
Executable 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) }));
|
||||
148
scripts/service-registry-content-quality.mjs
Executable file
148
scripts/service-registry-content-quality.mjs
Executable file
@@ -0,0 +1,148 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const args = new Map();
|
||||
for (let i = 2; i < process.argv.length; i += 1) {
|
||||
const key = process.argv[i];
|
||||
if (!key.startsWith("--")) continue;
|
||||
const next = process.argv[i + 1];
|
||||
args.set(key.slice(2), next && !next.startsWith("--") ? next : "true");
|
||||
if (next && !next.startsWith("--")) i += 1;
|
||||
}
|
||||
|
||||
const baseUrl = (args.get("base-url") || process.env.BACKEND_BASE_URL || "http://127.0.0.1:4000").replace(/\/$/, "");
|
||||
const userId = args.get("user-id") || process.env.SMOKE_USER_ID || "registry-content-quality";
|
||||
const iterations = Number(args.get("iterations") || process.env.SMOKE_ITERATIONS || 1);
|
||||
const previewTimeoutMs = Number(args.get("preview-timeout-ms") || process.env.SMOKE_PREVIEW_TIMEOUT_MS || 180000);
|
||||
const serviceToken = process.env.SERVICE_TOKEN;
|
||||
|
||||
if (!serviceToken) {
|
||||
throw new Error("SERVICE_TOKEN is required for authenticated content-quality probes.");
|
||||
}
|
||||
|
||||
const badMarkers = [/placeholder/i, /dummy/i, /not implemented/i, /fallback/i, /lorem/i, /todo/i, /undefined/i];
|
||||
|
||||
function assert(condition, message, detail) {
|
||||
if (condition) return;
|
||||
const suffix = detail === undefined ? "" : `\n${JSON.stringify(detail, null, 2).slice(0, 3000)}`;
|
||||
throw new Error(`${message}${suffix}`);
|
||||
}
|
||||
|
||||
function outlineOf(json) {
|
||||
return Array.isArray(json?.question_outline) ? json.question_outline : json?.prompt_outline;
|
||||
}
|
||||
|
||||
function walk(value, path = "$", strings = [], nulls = []) {
|
||||
if (value === null) nulls.push(path);
|
||||
else if (typeof value === "string") strings.push(value);
|
||||
else if (Array.isArray(value)) value.forEach((item, index) => walk(item, `${path}[${index}]`, strings, nulls));
|
||||
else if (value && typeof value === "object") Object.entries(value).forEach(([key, item]) => walk(item, `${path}.${key}`, strings, nulls));
|
||||
return { strings, nulls };
|
||||
}
|
||||
|
||||
async function post(name, path, payload) {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), previewTimeoutMs);
|
||||
const started = Date.now();
|
||||
try {
|
||||
const response = await fetch(`${baseUrl}${path}`, {
|
||||
method: "POST",
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
authorization: `Bearer ${serviceToken}`,
|
||||
"x-growqr-user": userId,
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const text = await response.text();
|
||||
const durationMs = Date.now() - started;
|
||||
assert(response.ok, `${name} returned HTTP ${response.status}`, { text, durationMs });
|
||||
return { json: JSON.parse(text), durationMs };
|
||||
} catch (error) {
|
||||
if (error?.name === "AbortError") {
|
||||
throw new Error(`${name} timed out after ${Date.now() - started}ms`, { cause: error });
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
function validatePreview(name, json) {
|
||||
const outline = outlineOf(json);
|
||||
assert(Array.isArray(outline) && outline.length >= 3, `${name} needs at least 3 outline items`, outline);
|
||||
|
||||
const { strings, nulls } = walk(json);
|
||||
assert(nulls.length === 0, `${name} contains null fields`, nulls.slice(0, 30));
|
||||
|
||||
const cleanStrings = strings.map((item) => item.trim()).filter(Boolean);
|
||||
for (const marker of badMarkers) {
|
||||
assert(!cleanStrings.some((item) => marker.test(item)), `${name} contains marker ${marker}`, cleanStrings.filter((item) => marker.test(item)).slice(0, 10));
|
||||
}
|
||||
|
||||
const prompts = outline
|
||||
.map((item) => String(item.question || item.prompt || item.text || "").replace(/\s+/g, " ").trim())
|
||||
.filter(Boolean);
|
||||
assert(prompts.length >= 3, `${name} outline prompts are missing text`, outline);
|
||||
assert(prompts.every((prompt) => prompt.length >= 35), `${name} outline prompts are too shallow`, prompts);
|
||||
assert(new Set(prompts.map((prompt) => prompt.toLowerCase())).size === prompts.length, `${name} outline prompts duplicate`, prompts);
|
||||
assert(String(json.opening_prompt || "").trim().length >= 35, `${name} opening prompt too short`, json.opening_prompt);
|
||||
|
||||
const briefText = walk(json.candidate_brief).strings.join(" ").replace(/\s+/g, " ").trim();
|
||||
assert(briefText.length >= 300, `${name} candidate brief too thin`, briefText);
|
||||
}
|
||||
|
||||
async function runIteration(iteration) {
|
||||
const user = `${userId}-${iteration}`;
|
||||
const interview = await post(`[content ${iteration}] interview preview`, "/services/interview/preview", {
|
||||
user_id: user,
|
||||
org_id: "growqr",
|
||||
persona_id: "emma",
|
||||
interview_type: "behavioral",
|
||||
duration_minutes: 5,
|
||||
context: {
|
||||
target_role: "Product Manager",
|
||||
company_name: "GrowQR Quality",
|
||||
difficulty: "medium",
|
||||
source: "registry-content-quality",
|
||||
personalize: false,
|
||||
},
|
||||
});
|
||||
validatePreview(`[content ${iteration}] interview preview`, interview.json);
|
||||
|
||||
const roleplay = await post(`[content ${iteration}] roleplay preview`, "/services/roleplay/preview", {
|
||||
user_id: user,
|
||||
org_id: "growqr",
|
||||
persona_id: "emma",
|
||||
duration_minutes: 5,
|
||||
roleplay_type: "custom",
|
||||
brief: "Practice a concise salary negotiation opening for a product manager offer.",
|
||||
metadata: {
|
||||
target_role: "Product Manager",
|
||||
candidate_role: "Product Manager",
|
||||
difficulty: "medium",
|
||||
source: "registry-content-quality",
|
||||
personalize: false,
|
||||
},
|
||||
});
|
||||
validatePreview(`[content ${iteration}] roleplay preview`, roleplay.json);
|
||||
assert(roleplay.json.scenario?.candidate_role === "Product Manager", `[content ${iteration}] roleplay did not expose explicit candidate_role`, roleplay.json.scenario);
|
||||
assert(typeof roleplay.json.scenario?.persona_role === "string" && roleplay.json.scenario.persona_role.length > 0, `[content ${iteration}] roleplay did not expose persona_role`, roleplay.json.scenario);
|
||||
|
||||
return {
|
||||
iteration,
|
||||
interviewSession: interview.json.session_id,
|
||||
interviewPreviewMs: interview.durationMs,
|
||||
roleplaySession: roleplay.json.session_id,
|
||||
roleplayPreviewMs: roleplay.durationMs,
|
||||
};
|
||||
}
|
||||
|
||||
const results = [];
|
||||
for (let i = 1; i <= iterations; i += 1) {
|
||||
const result = await runIteration(i);
|
||||
results.push(result);
|
||||
console.log(JSON.stringify(result));
|
||||
}
|
||||
|
||||
console.log(JSON.stringify({ ok: true, iterations, results }));
|
||||
217
scripts/service-registry-smoke.mjs
Normal file
217
scripts/service-registry-smoke.mjs
Normal 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 }));
|
||||
236
scripts/service-registry-write-flow.mjs
Executable file
236
scripts/service-registry-write-flow.mjs
Executable file
@@ -0,0 +1,236 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const args = new Map();
|
||||
for (let i = 2; i < process.argv.length; i += 1) {
|
||||
const key = process.argv[i];
|
||||
if (!key.startsWith("--")) continue;
|
||||
const next = process.argv[i + 1];
|
||||
args.set(key.slice(2), next && !next.startsWith("--") ? next : "true");
|
||||
if (next && !next.startsWith("--")) i += 1;
|
||||
}
|
||||
|
||||
const baseUrl = (args.get("base-url") || process.env.BACKEND_BASE_URL || "http://127.0.0.1:4000").replace(/\/$/, "");
|
||||
const userId = args.get("user-id") || process.env.SMOKE_USER_ID || "registry-write-smoke";
|
||||
const iterations = Number(args.get("iterations") || process.env.SMOKE_ITERATIONS || 1);
|
||||
const previewTimeoutMs = Number(args.get("preview-timeout-ms") || process.env.SMOKE_PREVIEW_TIMEOUT_MS || 180000);
|
||||
const serviceToken = process.env.SERVICE_TOKEN;
|
||||
|
||||
if (!serviceToken) {
|
||||
throw new Error("SERVICE_TOKEN is required for authenticated backend write-flow probes.");
|
||||
}
|
||||
|
||||
function assert(condition, message, detail) {
|
||||
if (condition) return;
|
||||
const suffix = detail === undefined ? "" : `\n${JSON.stringify(detail, null, 2).slice(0, 3000)}`;
|
||||
throw new Error(`${message}${suffix}`);
|
||||
}
|
||||
|
||||
function authHeaders(extra = {}) {
|
||||
return {
|
||||
authorization: `Bearer ${serviceToken}`,
|
||||
"x-growqr-user": userId,
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
async function request(name, path, init = {}, timeoutMs = 90000) {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
const started = Date.now();
|
||||
try {
|
||||
const res = await fetch(`${baseUrl}${path}`, { ...init, signal: controller.signal });
|
||||
const text = await res.text();
|
||||
let json;
|
||||
try {
|
||||
json = text ? JSON.parse(text) : {};
|
||||
} catch {
|
||||
json = undefined;
|
||||
}
|
||||
const durationMs = Date.now() - started;
|
||||
assert(res.ok, `${name} returned HTTP ${res.status}`, { text, durationMs });
|
||||
return { json, text, durationMs };
|
||||
} catch (error) {
|
||||
if (error?.name === "AbortError") {
|
||||
const durationMs = Date.now() - started;
|
||||
throw new Error(`${name} timed out after ${durationMs}ms`, { cause: error });
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
function rejectFallbackLike(name, value) {
|
||||
if (value && typeof value === "object") {
|
||||
assert(!("error" in value), `${name} contains error field`, value);
|
||||
assert(!("detail" in value && /internal|fallback|not implemented/i.test(String(value.detail))), `${name} contains error detail`, value);
|
||||
}
|
||||
const text = JSON.stringify(value).toLowerCase();
|
||||
const bad = ["placeholder", "dummy", "not implemented", "fallback"];
|
||||
const found = bad.find((needle) => text.includes(needle));
|
||||
assert(!found, `${name} contains fallback/error-like marker: ${found}`, value);
|
||||
}
|
||||
|
||||
function outlineOf(json) {
|
||||
return Array.isArray(json?.question_outline) ? json.question_outline : json?.prompt_outline;
|
||||
}
|
||||
|
||||
function assertDraftPreview(name, json) {
|
||||
rejectFallbackLike(name, json);
|
||||
assert(typeof json.session_id === "string" && json.session_id.length > 12, `${name} missing session_id`, json);
|
||||
assert(json.status === "draft", `${name} should create draft`, json);
|
||||
assert(json.needs_approval === true, `${name} should require approval`, json);
|
||||
assert(Array.isArray(outlineOf(json)) && outlineOf(json).length >= 2, `${name} missing generated outline`, json);
|
||||
assert(Boolean(json.opening_prompt), `${name} missing opening_prompt`, json);
|
||||
assert(Boolean(json.candidate_brief), `${name} missing candidate_brief`, json);
|
||||
}
|
||||
|
||||
function asInterviewQuestions(preview, iteration) {
|
||||
return outlineOf(preview).slice(0, 3).map((item, index) => ({
|
||||
text: `${String(item.question || item.text || "").replace(/\s+/g, " ").trim()} [write-flow ${iteration}.${index + 1}]`,
|
||||
topic: String(item.topic || `Smoke interview ${index + 1}`),
|
||||
expected_framework: String(item.expected_framework || "none"),
|
||||
}));
|
||||
}
|
||||
|
||||
function asRoleplayPrompts(preview, iteration) {
|
||||
return outlineOf(preview).slice(0, 3).map((item, index) => ({
|
||||
text: `${String(item.prompt || item.question || item.text || "").replace(/\s+/g, " ").trim()} [write-flow ${iteration}.${index + 1}]`,
|
||||
topic: String(item.topic || `Smoke roleplay ${index + 1}`),
|
||||
}));
|
||||
}
|
||||
|
||||
async function runInterviewFlow(iteration) {
|
||||
const prefix = `[write ${iteration}] interview`;
|
||||
const previewPayload = {
|
||||
user_id: userId,
|
||||
org_id: "growqr",
|
||||
persona_id: "emma",
|
||||
interview_type: "behavioral",
|
||||
duration_minutes: 5,
|
||||
context: {
|
||||
target_role: "Product Manager",
|
||||
company_name: "GrowQR Write Flow",
|
||||
difficulty: "medium",
|
||||
source: "registry-write-flow",
|
||||
personalize: false,
|
||||
},
|
||||
};
|
||||
const preview = await request(`${prefix} preview`, "/services/interview/preview", {
|
||||
method: "POST",
|
||||
headers: authHeaders({ "content-type": "application/json" }),
|
||||
body: JSON.stringify(previewPayload),
|
||||
}, previewTimeoutMs);
|
||||
assertDraftPreview(`${prefix} preview`, preview.json);
|
||||
|
||||
const questions = asInterviewQuestions(preview.json, iteration);
|
||||
assert(questions.every((item) => item.text.includes("[write-flow")), `${prefix} question edit payload invalid`, questions);
|
||||
const edited = await request(`${prefix} questions edit`, "/services/interview/questions", {
|
||||
method: "POST",
|
||||
headers: authHeaders({ "content-type": "application/json" }),
|
||||
body: JSON.stringify({ session_id: preview.json.session_id, questions }),
|
||||
});
|
||||
rejectFallbackLike(`${prefix} questions edit`, edited.json);
|
||||
assert(edited.json?.status === "draft", `${prefix} edit should keep draft status`, edited.json);
|
||||
assert(edited.json?.questions_edited === true, `${prefix} edit should mark questions_edited`, edited.json);
|
||||
assert(outlineOf(edited.json)?.[0]?.question?.includes("[write-flow"), `${prefix} edited question not persisted`, edited.json);
|
||||
|
||||
const approved = await request(`${prefix} approve`, "/services/interview/approve", {
|
||||
method: "POST",
|
||||
headers: authHeaders({ "content-type": "application/json" }),
|
||||
body: JSON.stringify({ session_id: preview.json.session_id }),
|
||||
});
|
||||
rejectFallbackLike(`${prefix} approve`, approved.json);
|
||||
assert(approved.json?.status === "configured", `${prefix} approve should configure session`, approved.json);
|
||||
assert(approved.json?.approved === true, `${prefix} approve missing approved flag`, approved.json);
|
||||
|
||||
const review = await request(`${prefix} review`, `/services/interview/review/${encodeURIComponent(preview.json.session_id)}`, {
|
||||
headers: authHeaders(),
|
||||
}, 15000);
|
||||
rejectFallbackLike(`${prefix} review`, review.json);
|
||||
assert(review.json?.status === "processing" || typeof review.json?.overall_score === "number", `${prefix} review shape invalid`, review.json);
|
||||
|
||||
return {
|
||||
sessionId: preview.json.session_id,
|
||||
reviewStatus: review.json?.status ?? "complete",
|
||||
durationsMs: {
|
||||
preview: preview.durationMs,
|
||||
edit: edited.durationMs,
|
||||
approve: approved.durationMs,
|
||||
review: review.durationMs,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function runRoleplayFlow(iteration) {
|
||||
const prefix = `[write ${iteration}] roleplay`;
|
||||
const previewPayload = {
|
||||
user_id: userId,
|
||||
org_id: "growqr",
|
||||
persona_id: "emma",
|
||||
duration_minutes: 5,
|
||||
roleplay_type: "custom",
|
||||
brief: "Practice a concise salary negotiation opening for a product manager offer.",
|
||||
metadata: {
|
||||
target_role: "Product Manager",
|
||||
difficulty: "medium",
|
||||
source: "registry-write-flow",
|
||||
personalize: false,
|
||||
},
|
||||
};
|
||||
const preview = await request(`${prefix} preview`, "/services/roleplay/preview", {
|
||||
method: "POST",
|
||||
headers: authHeaders({ "content-type": "application/json" }),
|
||||
body: JSON.stringify(previewPayload),
|
||||
}, previewTimeoutMs);
|
||||
assertDraftPreview(`${prefix} preview`, preview.json);
|
||||
|
||||
const questions = asRoleplayPrompts(preview.json, iteration);
|
||||
assert(questions.every((item) => item.text.includes("[write-flow")), `${prefix} prompt edit payload invalid`, questions);
|
||||
const edited = await request(`${prefix} prompt edit`, "/services/roleplay/questions", {
|
||||
method: "POST",
|
||||
headers: authHeaders({ "content-type": "application/json" }),
|
||||
body: JSON.stringify({ session_id: preview.json.session_id, questions }),
|
||||
});
|
||||
rejectFallbackLike(`${prefix} prompt edit`, edited.json);
|
||||
assert(edited.json?.status === "draft", `${prefix} edit should keep draft status`, edited.json);
|
||||
assert(edited.json?.questions_edited === true, `${prefix} edit should mark questions_edited`, edited.json);
|
||||
assert(outlineOf(edited.json)?.[0]?.prompt?.includes("[write-flow"), `${prefix} edited prompt not persisted`, edited.json);
|
||||
|
||||
const approved = await request(`${prefix} approve`, "/services/roleplay/approve", {
|
||||
method: "POST",
|
||||
headers: authHeaders({ "content-type": "application/json" }),
|
||||
body: JSON.stringify({ session_id: preview.json.session_id }),
|
||||
});
|
||||
rejectFallbackLike(`${prefix} approve`, approved.json);
|
||||
assert(approved.json?.status === "configured", `${prefix} approve should configure session`, approved.json);
|
||||
assert(approved.json?.approved === true, `${prefix} approve missing approved flag`, approved.json);
|
||||
|
||||
const review = await request(`${prefix} review`, `/services/roleplay/review/${encodeURIComponent(preview.json.session_id)}`, {
|
||||
headers: authHeaders(),
|
||||
}, 15000);
|
||||
rejectFallbackLike(`${prefix} review`, review.json);
|
||||
assert(review.json?.status === "processing" || typeof review.json?.overall_score === "number", `${prefix} review shape invalid`, review.json);
|
||||
|
||||
return {
|
||||
sessionId: preview.json.session_id,
|
||||
reviewStatus: review.json?.status ?? "complete",
|
||||
durationsMs: {
|
||||
preview: preview.durationMs,
|
||||
edit: edited.durationMs,
|
||||
approve: approved.durationMs,
|
||||
review: review.durationMs,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const results = [];
|
||||
for (let i = 1; i <= iterations; i += 1) {
|
||||
const interview = await runInterviewFlow(i);
|
||||
const roleplay = await runRoleplayFlow(i);
|
||||
const result = { iteration: i, interview, roleplay };
|
||||
results.push(result);
|
||||
console.log(JSON.stringify(result));
|
||||
}
|
||||
|
||||
console.log(JSON.stringify({ ok: true, iterations, results }));
|
||||
@@ -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 ??
|
||||
|
||||
@@ -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)");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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"),
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
102
src/services/user-context.ts
Normal file
102
src/services/user-context.ts
Normal 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;
|
||||
}
|
||||
@@ -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>;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user