Merge PRM-80 backend canonical service event contract

This commit is contained in:
Sai-karthik
2026-06-29 11:37:22 +00:00
15 changed files with 559 additions and 73 deletions
+1
View File
@@ -6,6 +6,7 @@
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc -p tsconfig.json",
"test:onboarding": "tsx scripts/onboarding-ledger.test.ts",
"start": "node dist/index.js",
"typecheck": "tsc -p tsconfig.json --noEmit",
"workflows:smoke": "tsx src/workflows/smoke-test.ts",
+74
View File
@@ -0,0 +1,74 @@
import assert from "node:assert/strict";
import {
completedAtFromOnboardingPayload,
isValidOnboardingLedgerEvent,
normalizeOnboardingEventType,
} from "../src/events/onboarding-ledger.js";
const now = "2026-06-28T00:00:00.000Z";
assert.equal(normalizeOnboardingEventType("user_onboarding_completed"), "user.onboarding.completed");
assert.equal(
isValidOnboardingLedgerEvent({
type: "onboarding.completed",
payload: {},
}),
true,
"explicit completion event should satisfy onboarding status",
);
assert.equal(
isValidOnboardingLedgerEvent({
type: "user.onboarding.completed",
payload: {},
}),
true,
"user completion event should satisfy onboarding status",
);
assert.equal(
isValidOnboardingLedgerEvent({
type: "profile.onboarding.completed",
payload: {},
}),
true,
"profile completion event should satisfy onboarding status",
);
assert.equal(
isValidOnboardingLedgerEvent({
type: "onboarding.snapshot.saved",
payload: { onboarding: { current_step: 2 } },
}),
false,
"intermediate snapshots must not bypass onboarding",
);
assert.equal(
isValidOnboardingLedgerEvent({
type: "onboarding.snapshot.saved",
payload: { onboarding: { completed_at: now } },
}),
true,
"completion snapshots should satisfy onboarding status",
);
assert.equal(
completedAtFromOnboardingPayload({
preferences: { onboarding: { completed_at: now } },
}),
now,
"completion timestamp should be extracted from preferences snapshot",
);
assert.equal(
completedAtFromOnboardingPayload({
completedAt: "not-a-date",
})?.endsWith("Z"),
true,
"invalid completion timestamps should normalize to a valid ISO timestamp",
);
console.log("onboarding-ledger tests passed");
process.exit(0);
+1 -1
View File
@@ -33,7 +33,7 @@ const requiredServices = [
const directHealth = [
["interview", "http://127.0.0.1:8007/health"],
["roleplay", "http://127.0.0.1:8008/health"],
["roleplay", "http://127.0.0.1:8040/health"],
["resume", "http://127.0.0.1:8002/health"],
["qscore", "http://127.0.0.1:8000/health"],
["courses", "http://127.0.0.1:8060/api/v1/health"],
+192
View File
@@ -0,0 +1,192 @@
import { and, desc, eq, inArray } from "drizzle-orm";
import { db } from "../db/client.js";
import { growEvents, type GrowEventRow } from "../db/schema.js";
import { asRecord } from "./envelope.js";
import {
markGrowEventFailed,
markGrowEventProcessed,
markGrowEventProcessing,
recordGrowEvent,
} from "./record-grow-event.js";
import { ensureOnboardingBaselineQscoreForCompletedAt } from "./onboarding-qscore.js";
import {
onboardingCompletedAtFromEvent,
runCuratorOnboardingLoopSafely,
} from "../v1/curator/curator-onboarding-loop.js";
export const ONBOARDING_LEDGER_EVENT_TYPES = [
"onboarding.snapshot.saved",
"onboarding.completed",
"user.onboarding.completed",
"profile.onboarding.completed",
] as const;
export type OnboardingLedgerEventType = (typeof ONBOARDING_LEDGER_EVENT_TYPES)[number];
export const ONBOARDING_LEDGER_QUERY_TYPES = [
...ONBOARDING_LEDGER_EVENT_TYPES,
"onboarding_snapshot_saved",
"onboarding_completed",
"user_onboarding_completed",
"profile_onboarding_completed",
] as const;
export type OnboardingStatusEvent = {
id: string;
type: string;
occurredAt: Date;
processingStatus: string;
};
const COMPLETION_EVENT_TYPES = new Set<string>([
"onboarding.completed",
"user.onboarding.completed",
"profile.onboarding.completed",
]);
export function normalizeOnboardingEventType(type: string) {
return type.toLowerCase().replaceAll("_", ".");
}
export function completedAtFromOnboardingPayload(payload: Record<string, unknown> | null | undefined) {
const data = payload ?? {};
const preferences = asRecord(data.preferences);
const onboarding = asRecord(data.onboarding ?? preferences.onboarding);
const candidate =
data.completedAt ??
data.completed_at ??
data.onboardingCompletedAt ??
data.onboarding_completed_at ??
onboarding.completed_at ??
onboarding.completedAt ??
asRecord(preferences.onboarding).completed_at ??
asRecord(preferences.onboarding).completedAt;
if (candidate instanceof Date) {
return Number.isNaN(candidate.getTime()) ? undefined : candidate.toISOString();
}
if (typeof candidate !== "string" || !candidate.trim()) return undefined;
const parsed = new Date(candidate);
return Number.isNaN(parsed.getTime()) ? new Date().toISOString() : parsed.toISOString();
}
export function isValidOnboardingLedgerEvent(event: Pick<GrowEventRow, "type" | "payload">) {
const normalizedType = normalizeOnboardingEventType(event.type);
if (COMPLETION_EVENT_TYPES.has(normalizedType)) return true;
// Snapshots are status-valid only when they are completion snapshots. Plain
// intermediate step saves must not let a new seeker bypass onboarding.
if (normalizedType === "onboarding.snapshot.saved") {
return Boolean(completedAtFromOnboardingPayload(event.payload));
}
return false;
}
export async function getLatestValidOnboardingLedgerEvent(userId: string): Promise<OnboardingStatusEvent | null> {
const rows = await db
.select({
id: growEvents.id,
type: growEvents.type,
payload: growEvents.payload,
occurredAt: growEvents.occurredAt,
processingStatus: growEvents.processingStatus,
})
.from(growEvents)
.where(
and(
eq(growEvents.userId, userId),
inArray(growEvents.type, [...ONBOARDING_LEDGER_QUERY_TYPES]),
),
)
.orderBy(desc(growEvents.occurredAt))
.limit(25);
const row = rows.find((event) => isValidOnboardingLedgerEvent(event));
if (!row) return null;
const { payload: _payload, ...statusEvent } = row;
return statusEvent;
}
export async function ensureOnboardingBaselineQscoreFromLedger(userId: string) {
const event = await getLatestValidOnboardingLedgerEvent(userId);
if (!event) return false;
return ensureOnboardingBaselineQscoreForCompletedAt(userId, event.occurredAt);
}
function onboardingContextFromInput(context?: Record<string, unknown>) {
const input = context ?? {};
const preferences = asRecord(input.preferences);
const onboarding = asRecord(input.onboarding ?? preferences.onboarding);
return {
...input,
onboarding,
preferences: Object.keys(preferences).length ? preferences : { onboarding },
};
}
export async function ensureOnboardingSideEffectsForEvent(event: GrowEventRow) {
if (!event.userId || !isValidOnboardingLedgerEvent(event)) {
return {
qscoreBaselineSeeded: false,
curatorOnboarding: { status: "skipped" as const, reason: event.userId ? "not_onboarding_completion" : "missing_user_id" },
};
}
const completedAt =
onboardingCompletedAtFromEvent(event) ??
completedAtFromOnboardingPayload(event.payload) ??
event.occurredAt.toISOString();
const qscoreBaselineSeeded = await ensureOnboardingBaselineQscoreForCompletedAt(event.userId, completedAt);
const curatorOnboarding = await runCuratorOnboardingLoopSafely({
userId: event.userId,
completedAt,
sourceEventId: event.id,
source: event.source,
context: onboardingContextFromInput(event.payload),
});
return { qscoreBaselineSeeded, curatorOnboarding };
}
export async function recordAndProcessOnboardingCompletion(input: {
userId: string;
completedAt: string | Date;
source?: string;
context?: Record<string, unknown>;
}) {
const completedAt =
input.completedAt instanceof Date
? input.completedAt.toISOString()
: input.completedAt;
const context = onboardingContextFromInput(input.context);
const event = await recordGrowEvent(
{
source: input.source ?? "onboarding",
type: "onboarding.completed",
category: "usage",
userId: input.userId,
occurredAt: completedAt,
payload: {
completedAt,
...context,
},
dedupeKey: `onboarding:completed:${input.userId}`,
},
{ userId: input.userId, source: input.source ?? "onboarding" },
);
if (event.processingStatus !== "processed") {
await markGrowEventProcessing(event.id);
}
const sideEffects = await ensureOnboardingSideEffectsForEvent(event);
if (sideEffects.curatorOnboarding.status === "skipped" && sideEffects.curatorOnboarding.reason === "loop_failed") {
await markGrowEventFailed(event.id, new Error("curator_onboarding_loop_failed"));
} else if (event.processingStatus !== "processed") {
await markGrowEventProcessed(event.id);
}
return { event, ...sideEffects };
}
+18 -4
View File
@@ -9,12 +9,18 @@ function asRecord(value: unknown): Record<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : {};
}
function parseCompletedAt(value: unknown): Date | null {
if (value instanceof Date) {
return Number.isNaN(value.getTime()) ? null : value;
}
if (typeof value !== "string" || !value.trim()) return null;
const parsed = new Date(value);
return Number.isNaN(parsed.getTime()) ? new Date() : parsed;
}
function onboardingCompletedAt(preferences: Record<string, unknown> | undefined): Date | null {
const onboarding = asRecord(preferences?.onboarding);
const completedAt = onboarding.completed_at;
if (typeof completedAt !== "string" || !completedAt.trim()) return null;
const parsed = new Date(completedAt);
return Number.isNaN(parsed.getTime()) ? new Date() : parsed;
return parseCompletedAt(onboarding.completed_at ?? onboarding.completedAt);
}
/**
@@ -32,7 +38,15 @@ export async function ensureOnboardingBaselineQscore(
): Promise<boolean> {
const completedAt = onboardingCompletedAt(preferences);
if (!completedAt) return false;
return ensureOnboardingBaselineQscoreForCompletedAt(userId, completedAt);
}
export async function ensureOnboardingBaselineQscoreForCompletedAt(
userId: string,
completedAtInput: string | Date,
): Promise<boolean> {
const completedAt = parseCompletedAt(completedAtInput);
if (!completedAt) return false;
const latestSignals = await db
.select({ signalId: growQscoreLatest.signalId, score: growQscoreLatest.score })
.from(growQscoreLatest)
+25 -3
View File
@@ -1,9 +1,15 @@
import { randomUUID } from "node:crypto";
import { config } from "../config.js";
import { log } from "../log.js";
import { recordGrowEvent } from "./record-grow-event.js";
import {
markGrowEventFailed,
markGrowEventProcessed,
markGrowEventProcessing,
recordGrowEvent,
} from "./record-grow-event.js";
import { routeGrowEventToUserActor } from "./route-to-user-actor.js";
import { runCuratorOnboardingLoopForEventSafely } from "../v1/curator/curator-onboarding-loop.js";
import { applyQscoreProjection } from "./projectors/qscore-projector.js";
import { ensureOnboardingSideEffectsForEvent } from "./onboarding-ledger.js";
// This file has two Redis ingestion modes:
// 1. Canonical GrowEvent stream: grow.events.raw — future service event bus.
@@ -151,7 +157,23 @@ async function recordAndRoute(input: unknown) {
await routeGrowEventToUserActor(event).catch((err) => {
log.warn({ err, eventId: event.id, userId: event.userId }, "failed to route grow event to user actor");
});
await runCuratorOnboardingLoopForEventSafely(event);
if (!event.userId || event.processingStatus === "processed") return event;
await markGrowEventProcessing(event.id);
try {
await applyQscoreProjection(event);
const onboarding = await ensureOnboardingSideEffectsForEvent(event);
if (
onboarding.curatorOnboarding.status === "skipped" &&
onboarding.curatorOnboarding.reason === "loop_failed"
) {
throw new Error("curator_onboarding_loop_failed");
}
await markGrowEventProcessed(event.id);
} catch (err) {
await markGrowEventFailed(event.id, err);
throw err;
}
return event;
}
+2 -2
View File
@@ -17,7 +17,7 @@ import {
} 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 { ensureOnboardingBaselineQscoreFromLedger } from "../events/onboarding-ledger.js";
import { HomeFeedAgentError, refineHomeNotificationsWithAgent } from "./home-feed-agent.js";
import { listAvailableMissionDefinitions } from "../missions/registry.js";
import { listServiceCapabilities } from "../workflows/service-capabilities.js";
@@ -615,7 +615,7 @@ async function buildIdentity(ctx: HomeContext) {
}
export async function getHomeFeed(userId: string, opts: { refresh?: boolean; userProfile?: Record<string, unknown>; preferences?: Record<string, unknown> } = {}): Promise<HomeFeedResponse> {
await ensureOnboardingBaselineQscore(userId, opts.preferences);
await ensureOnboardingBaselineQscoreFromLedger(userId);
const ctx = await collectContext(userId, { userProfile: opts.userProfile, preferences: opts.preferences });
const persisted = await readPersistedNotifications(userId);
const newest = persisted[0]?.createdAt?.getTime() ?? 0;
+47 -8
View File
@@ -4,9 +4,15 @@ import { config } from "../config.js";
import { db } from "../db/client.js";
import { growEvents } from "../db/schema.js";
import { requireUser, type AuthContext } from "../auth/clerk.js";
import { recordGrowEvent } from "../events/record-grow-event.js";
import {
markGrowEventFailed,
markGrowEventProcessed,
markGrowEventProcessing,
recordGrowEvent,
} from "../events/record-grow-event.js";
import { routeGrowEventToUserActor } from "../events/route-to-user-actor.js";
import { runCuratorOnboardingLoopForEventSafely } from "../v1/curator/curator-onboarding-loop.js";
import { applyQscoreProjection } from "../events/projectors/qscore-projector.js";
import { ensureOnboardingSideEffectsForEvent } from "../events/onboarding-ledger.js";
function serviceAuthorized(auth: string | undefined) {
const token = (auth ?? "").replace(/^Bearer\s+/i, "").trim();
@@ -21,8 +27,41 @@ async function ingest(body: unknown, userId?: string, source?: string) {
routed: false as const,
reason: err instanceof Error ? err.message : String(err),
}));
const curatorOnboarding = await runCuratorOnboardingLoopForEventSafely(event);
return { event, route, curatorOnboarding };
if (event.processingStatus === "processed") {
return {
event,
processingStatus: "processed" as const,
route,
qscore: { signals: [], score: undefined, idempotent: true },
onboarding: { qscoreBaselineSeeded: false, curatorOnboarding: { status: "already_processed" } },
};
}
if (!event.userId) {
return {
event,
processingStatus: event.processingStatus,
route,
qscore: { signals: [], score: undefined, skipped: "missing_user_id" },
onboarding: { qscoreBaselineSeeded: false, curatorOnboarding: { status: "skipped", reason: "missing_user_id" } },
};
}
await markGrowEventProcessing(event.id);
try {
const qscore = await applyQscoreProjection(event);
const onboarding = await ensureOnboardingSideEffectsForEvent(event);
if (
onboarding.curatorOnboarding.status === "skipped" &&
onboarding.curatorOnboarding.reason === "loop_failed"
) {
throw new Error("curator_onboarding_loop_failed");
}
await markGrowEventProcessed(event.id);
return { event, processingStatus: "processed" as const, route, qscore, onboarding };
} catch (err) {
await markGrowEventFailed(event.id, err);
throw err;
}
}
export function eventRoutes() {
@@ -32,8 +71,8 @@ export function eventRoutes() {
app.post("/ingest", requireUser, async (c) => {
const userId = c.get("userId");
const body = await c.req.json().catch(() => ({}));
const { event, route, curatorOnboarding } = await ingest(body, userId);
return c.json({ eventId: event.id, processingStatus: event.processingStatus, route, curatorOnboarding }, 202);
const { event, processingStatus, route, qscore, onboarding } = await ingest(body, userId);
return c.json({ eventId: event.id, processingStatus, route, qscore, onboarding }, 202);
});
// Service-to-service ingress. Services may include userId directly, or we resolve it from session correlation.
@@ -43,8 +82,8 @@ export function eventRoutes() {
}
const body = await c.req.json().catch(() => ({}));
const source = c.req.header("x-growqr-source") ?? undefined;
const { event, route, curatorOnboarding } = await ingest(body, undefined, source);
return c.json({ eventId: event.id, processingStatus: event.processingStatus, route, curatorOnboarding }, 202);
const { event, processingStatus, route, qscore, onboarding } = await ingest(body, undefined, source);
return c.json({ eventId: event.id, processingStatus, route, qscore, onboarding }, 202);
});
app.get("/", requireUser, async (c) => {
+2 -2
View File
@@ -10,7 +10,7 @@ import { db } from "../db/client.js";
import { events, growQscoreLatest, growQscoreProjectionState } from "../db/schema.js";
import { recordGrowEvent } from "../events/record-grow-event.js";
import { routeGrowEventToUserActor } from "../events/route-to-user-actor.js";
import { ensureOnboardingBaselineQscore } from "../events/onboarding-qscore.js";
import { ensureOnboardingBaselineQscoreFromLedger } from "../events/onboarding-ledger.js";
import { getRequestUserPreferences, getRequestUserProfile } from "../services/user-context.js";
import { log } from "../log.js";
@@ -655,7 +655,7 @@ export function serviceRoutes() {
app.get("/qscore/current", async (c) => {
const userId = c.get("userId");
try {
await ensureOnboardingBaselineQscore(userId, await getRequestUserPreferences(c.req.raw, userId));
await ensureOnboardingBaselineQscoreFromLedger(userId);
} catch (err) {
log.warn({ err, userId }, "failed to seed onboarding Q Score baseline before current Q Score read");
}
+8 -38
View File
@@ -1,22 +1,17 @@
import { Hono } from "hono";
import { requireUser, type AuthContext } from "../auth/clerk.js";
import { db } from "../db/client.js";
import { growEvents, users, userStacks, type UserStack } from "../db/schema.js";
import { and, desc, eq, inArray } from "drizzle-orm";
import { users, userStacks, type UserStack } from "../db/schema.js";
import { eq } from "drizzle-orm";
import { log } from "../log.js";
import { config } from "../config.js";
import { ensureOnboardingBaselineQscore } from "../events/onboarding-qscore.js";
import {
onboardingCompletedAtFromPreferences,
runCuratorOnboardingLoopSafely,
} from "../v1/curator/curator-onboarding-loop.js";
const ONBOARDING_LEDGER_EVENT_TYPES = [
"onboarding.snapshot.saved",
"onboarding.completed",
"user.onboarding.completed",
"profile.onboarding.completed",
] as const;
import {
getLatestValidOnboardingLedgerEvent,
recordAndProcessOnboardingCompletion,
} from "../events/onboarding-ledger.js";
function publicStack(stack: UserStack | null | undefined) {
if (!stack) return stack;
@@ -62,27 +57,6 @@ async function ensureUserServiceUser(req: Request) {
return res.json() as Promise<Record<string, unknown>>;
}
async function getOnboardingEventStatus(userId: string) {
const [row] = await db
.select({
id: growEvents.id,
type: growEvents.type,
occurredAt: growEvents.occurredAt,
processingStatus: growEvents.processingStatus,
})
.from(growEvents)
.where(
and(
eq(growEvents.userId, userId),
inArray(growEvents.type, [...ONBOARDING_LEDGER_EVENT_TYPES]),
),
)
.orderBy(desc(growEvents.occurredAt))
.limit(1);
return row ?? null;
}
export function userRoutes() {
const app = new Hono<AuthContext>();
app.use("*", requireUser);
@@ -120,7 +94,7 @@ export function userRoutes() {
app.get("/onboarding-status", async (c) => {
const userId = c.get("userId");
const event = await getOnboardingEventStatus(userId);
const event = await getLatestValidOnboardingLedgerEvent(userId);
return c.json({
userId,
@@ -149,13 +123,9 @@ export function userRoutes() {
const normalizedPreferences = preferences && typeof preferences === "object" && !Array.isArray(preferences)
? (preferences as Record<string, unknown>)
: undefined;
await ensureOnboardingBaselineQscore(
c.get("userId"),
normalizedPreferences,
);
const completedAt = onboardingCompletedAtFromPreferences(normalizedPreferences);
if (completedAt) {
await runCuratorOnboardingLoopSafely({
await recordAndProcessOnboardingCompletion({
userId: c.get("userId"),
completedAt,
source: "user-service-profile",
+155 -9
View File
@@ -62,6 +62,19 @@ export type ServiceRecord = {
qscoreSignals?: string[];
usage: string;
};
eventContract: {
canonicalServiceId: string;
openEvents: string[];
startedEvents: string[];
configuredEvents: string[];
completionEvents: string[];
abandonmentEvents: string[];
qscoreSignalIds: readonly string[];
analyticsDimensions: readonly string[];
requiredCorrelationFields: readonly string[];
handoffParams: readonly string[];
actionKind: "active_user_action" | "passive_background_action" | "measurement_review";
};
usageDocs: string[];
};
@@ -196,7 +209,7 @@ const serviceRegistry: ServiceRecord[] = [
media: "video",
},
toolName: "prepare_interview_preview",
completionEvents: ["interview.session.configured", "interview.feedback.generated", "interview.completed"],
completionEvents: ["interview.session.configured", "interview.session.completed", "interview.feedback.generated"],
qscoreSignals: ["communication.interview", "proof.story_bank", "readiness.practice"],
usage: "Include missionInstanceId, missionId, stageId, curatorTaskId, role, and media when building stateful handoffs.",
},
@@ -204,6 +217,19 @@ const serviceRegistry: ServiceRecord[] = [
"Call buildServiceLink('interview-service', 'preview', state) for curator handoffs.",
"Call getServiceEndpoint('interview-service', 'configure') for backend contract metadata.",
],
eventContract: {
canonicalServiceId: "interview",
openEvents: ["task.opened", "interview.preview.opened"],
startedEvents: ["service.started", "interview.session.started"],
configuredEvents: ["service.configured", "interview.session.configured"],
completionEvents: ["service.completed", "interview.session.completed", "interview.feedback.generated"],
abandonmentEvents: ["service.abandoned", "interview.session.abandoned"],
qscoreSignalIds: ["interview.communication_score", "interview.confidence_score", "readiness.practice"],
analyticsDimensions: ["interview_type", "target_role", "difficulty", "duration_minutes", "overall_score"],
requiredCorrelationFields: ["taskId"],
handoffParams: ["curatorTaskId", "missionInstanceId", "missionId", "stageId"],
actionKind: "active_user_action",
},
},
{
id: "roleplay-service",
@@ -281,7 +307,7 @@ const serviceRegistry: ServiceRecord[] = [
mode: "video",
},
toolName: "prepare_roleplay_preview",
completionEvents: ["roleplay.scenario.configured", "roleplay.feedback.generated", "roleplay.completed"],
completionEvents: ["roleplay.scenario.configured", "roleplay.scenario.completed", "roleplay.feedback.generated"],
qscoreSignals: ["communication.roleplay", "networking.conversation", "readiness.practice"],
usage: "Include role, brief, mission state, and curatorTaskId when building stateful handoffs.",
},
@@ -289,6 +315,19 @@ const serviceRegistry: ServiceRecord[] = [
"Call buildServiceLink('roleplay-service', 'builder', state) for curator handoffs.",
"Call getServiceEndpoint('roleplay-service', 'configure') for backend contract metadata.",
],
eventContract: {
canonicalServiceId: "roleplay",
openEvents: ["task.opened", "roleplay.preview.opened"],
startedEvents: ["service.started", "roleplay.scenario.started"],
configuredEvents: ["service.configured", "roleplay.scenario.configured"],
completionEvents: ["service.completed", "roleplay.scenario.completed", "roleplay.feedback.generated"],
abandonmentEvents: ["service.abandoned", "roleplay.scenario.abandoned"],
qscoreSignalIds: ["roleplay.negotiation_confidence", "communication.roleplay", "readiness.practice"],
analyticsDimensions: ["roleplay_type", "target_role", "difficulty", "duration_minutes", "overall_score"],
requiredCorrelationFields: ["taskId"],
handoffParams: ["curatorTaskId", "missionInstanceId", "missionId", "stageId"],
actionKind: "active_user_action",
},
},
{
id: "resume-service",
@@ -356,7 +395,7 @@ const serviceRegistry: ServiceRecord[] = [
tab: "resumes",
},
toolName: "prepare_resume_upload",
completionEvents: ["resume.analysis.completed", "resume.parsed", "resume.updated"],
completionEvents: ["resume.analysis.completed", "resume.parsed", "resume.updated", "resume.exported"],
qscoreSignals: ["proof.resume", "readiness.ats", "profile.skills"],
usage: "Include mission state and optional section when linking into resume work.",
},
@@ -364,6 +403,19 @@ const serviceRegistry: ServiceRecord[] = [
"Call buildServiceLink('resume-service', 'workspace', { tab: 'resumes' }) for curator handoffs.",
"Use the resume gateway proxy for browser calls that need Clerk auth.",
],
eventContract: {
canonicalServiceId: "resume",
openEvents: ["task.opened"],
startedEvents: ["service.started", "resume.uploaded"],
configuredEvents: ["service.configured", "resume.parsed"],
completionEvents: ["service.completed", "resume.analysis.completed", "resume.updated", "resume.exported"],
abandonmentEvents: ["service.abandoned"],
qscoreSignalIds: ["resume.ats_score", "resume.profile_completeness", "proof.resume"],
analyticsDimensions: ["resume_id", "version_id", "ats_score", "role_fit_score", "updated_sections"],
requiredCorrelationFields: ["taskId"],
handoffParams: ["curatorTaskId", "missionInstanceId", "missionId", "stageId"],
actionKind: "active_user_action",
},
},
{
id: "cover-letter-service",
@@ -424,7 +476,7 @@ const serviceRegistry: ServiceRecord[] = [
tab: "cover-letters",
},
toolName: "prepare_cover_letter_handoff",
completionEvents: ["cover_letter.generated", "cover_letter.updated", "cover_letter.analysis_completed"],
completionEvents: ["cover_letter.generated", "cover_letter.edited", "cover_letter.exported"],
qscoreSignals: ["proof.cover_letter", "readiness.application"],
usage: "Use for application-specific artifact tasks; share mission/job context in query or payload.",
},
@@ -432,6 +484,19 @@ const serviceRegistry: ServiceRecord[] = [
"Call buildServiceLink('cover-letter-service', 'workspace', { tab: 'cover-letters' }) for curator handoffs.",
"Backend endpoint metadata maps to resume-builder cover-letter APIs.",
],
eventContract: {
canonicalServiceId: "cover_letter",
openEvents: ["task.opened"],
startedEvents: ["service.started"],
configuredEvents: ["service.configured"],
completionEvents: ["service.completed", "cover_letter.generated", "cover_letter.edited", "cover_letter.exported"],
abandonmentEvents: ["service.abandoned"],
qscoreSignalIds: ["proof.cover_letter", "readiness.application"],
analyticsDimensions: ["cover_letter_id", "role_fit_score", "export_format"],
requiredCorrelationFields: ["taskId"],
handoffParams: ["curatorTaskId", "missionInstanceId", "missionId", "stageId"],
actionKind: "active_user_action",
},
},
{
id: "courses-service",
@@ -482,11 +547,24 @@ const serviceRegistry: ServiceRecord[] = [
start: "Start course",
},
toolName: "prepare_course_handoff",
completionEvents: ["course.started", "course.completed"],
completionEvents: ["course.generated", "course.started", "course.lesson.completed", "course.completed", "course.weakness.applied"],
qscoreSignals: ["skills.learning", "readiness.course_progress"],
usage: "Use for skill-gap tasks that should become learning work.",
},
usageDocs: ["Call buildServiceLink('courses-service', 'catalog', state) for course handoffs."],
eventContract: {
canonicalServiceId: "course",
openEvents: ["task.opened"],
startedEvents: ["service.started", "course.started"],
configuredEvents: ["course.generated"],
completionEvents: ["service.completed", "course.lesson.completed", "course.completed", "course.weakness.applied"],
abandonmentEvents: ["service.abandoned"],
qscoreSignalIds: ["course.skill_gap_closed", "skills.learning", "readiness.course_progress"],
analyticsDimensions: ["course_id", "lesson_id", "progress_percentage", "weakness_tags"],
requiredCorrelationFields: ["taskId"],
handoffParams: ["curatorTaskId", "missionInstanceId", "missionId", "stageId"],
actionKind: "active_user_action",
},
},
{
id: "assessment-service",
@@ -528,11 +606,24 @@ const serviceRegistry: ServiceRecord[] = [
review: "Review assessment",
},
toolName: "prepare_assessment_handoff",
completionEvents: ["assessment.started", "assessment.submitted", "assessment.completed"],
completionEvents: ["assessment.started", "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."],
eventContract: {
canonicalServiceId: "assessment",
openEvents: ["task.opened"],
startedEvents: ["service.started", "assessment.started"],
configuredEvents: [],
completionEvents: ["service.completed", "assessment.completed"],
abandonmentEvents: ["service.abandoned"],
qscoreSignalIds: ["skills.assessment", "readiness.measurement"],
analyticsDimensions: ["assessment_id", "skill_area", "score", "weakness_tags"],
requiredCorrelationFields: ["taskId"],
handoffParams: ["curatorTaskId", "missionInstanceId", "missionId", "stageId"],
actionKind: "measurement_review",
},
},
{
id: "matchmaking-service",
@@ -577,7 +668,15 @@ const serviceRegistry: ServiceRecord[] = [
review: "Review matches",
},
toolName: "prepare_matchmaking_handoff",
completionEvents: ["matchmaking.feed_viewed", "matchmaking.match_saved", "matchmaking.preference_updated"],
completionEvents: [
"matchmaking.matches.generated",
"matchmaking.feed.viewed",
"matchmaking.match.viewed",
"matchmaking.match.saved",
"matchmaking.match.dismissed",
"matchmaking.application.started",
"matchmaking.application.completed",
],
qscoreSignals: ["market.matches", "networking.opportunities"],
usage: "Use for immediate opportunity matching or mentor/network suggestions.",
},
@@ -585,6 +684,26 @@ const serviceRegistry: ServiceRecord[] = [
"Call buildServiceLink('matchmaking-service', 'jobs', state) for job matching.",
"Use the canonical /agents/matchmaking page for Scout-style opportunity review.",
],
eventContract: {
canonicalServiceId: "matchmaking",
openEvents: ["task.opened", "matchmaking.feed.viewed"],
startedEvents: ["service.started", "matchmaking.application.started"],
configuredEvents: [],
completionEvents: [
"service.completed",
"matchmaking.matches.generated",
"matchmaking.match.viewed",
"matchmaking.match.saved",
"matchmaking.match.dismissed",
"matchmaking.application.completed",
],
abandonmentEvents: ["service.abandoned"],
qscoreSignalIds: ["matchmaking.role_fit", "market.matches", "networking.opportunities"],
analyticsDimensions: ["match_count", "role_ids", "role_fit", "saved_count", "dismissed_reason"],
requiredCorrelationFields: ["taskId"],
handoffParams: ["curatorTaskId", "missionInstanceId", "missionId", "stageId"],
actionKind: "active_user_action",
},
},
{
id: "qscore-service",
@@ -625,11 +744,24 @@ const serviceRegistry: ServiceRecord[] = [
review: "Review QScore",
},
toolName: "prepare_qscore_review",
completionEvents: ["qscore.updated", "qscore.signal.projected"],
completionEvents: ["qscore.baseline.created", "qscore.review.opened", "qscore.weak_driver.reviewed", "qscore.signal.projected", "qscore.updated"],
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."],
eventContract: {
canonicalServiceId: "qscore",
openEvents: ["qscore.review.opened"],
startedEvents: ["service.started"],
configuredEvents: ["qscore.baseline.created"],
completionEvents: ["qscore.signal.projected", "qscore.updated", "qscore.weak_driver.reviewed"],
abandonmentEvents: [],
qscoreSignalIds: ["qscore.updated", "qscore.signal.projected"],
analyticsDimensions: ["score_before", "score_after", "signal_id", "delta", "trend_label"],
requiredCorrelationFields: [],
handoffParams: [],
actionKind: "measurement_review",
},
},
{
id: "social-branding-service",
@@ -672,11 +804,24 @@ const serviceRegistry: ServiceRecord[] = [
review: "Review profile",
},
toolName: "prepare_social_branding_handoff",
completionEvents: ["social.profile_updated", "social.linkedin_connected", "social.branding_analyzed"],
completionEvents: ["brand.post.drafted", "brand.post.edited", "brand.post.exported", "brand.profile.updated", "brand.content.published"],
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."],
eventContract: {
canonicalServiceId: "brand",
openEvents: ["task.opened"],
startedEvents: ["service.started"],
configuredEvents: ["brand.post.drafted"],
completionEvents: ["service.completed", "brand.post.edited", "brand.post.exported", "brand.profile.updated", "brand.content.published"],
abandonmentEvents: ["service.abandoned"],
qscoreSignalIds: ["brand.visibility_signal", "profile.linkedin", "proof.visibility"],
analyticsDimensions: ["content_type", "channel", "draft_id", "topic", "profile_field_updated"],
requiredCorrelationFields: ["taskId"],
handoffParams: ["curatorTaskId", "missionInstanceId", "missionId", "stageId"],
actionKind: "active_user_action",
},
},
];
@@ -751,6 +896,7 @@ export function listServicesForCatalog() {
},
frontend: service.frontend,
curator: service.curator,
eventContract: service.eventContract,
usageDocs: service.usageDocs,
}));
}
+11 -1
View File
@@ -12,6 +12,16 @@ import type { CuratorSprintResponse } from "./curator-types.js";
const CURATOR_SOURCE = "curator-v1";
const ONBOARDING_READY_EVENT = "curator.onboarding_plan.ready";
const ONBOARDING_SKIPPED_EVENT = "curator.onboarding_plan.skipped";
const ONBOARDING_CONTEXT_EVENT_TYPES = [
"onboarding.snapshot.saved",
"onboarding.completed",
"user.onboarding.completed",
"profile.onboarding.completed",
"onboarding_snapshot_saved",
"onboarding_completed",
"user_onboarding_completed",
"profile_onboarding_completed",
] as const;
type OnboardingLoopInput = {
userId: string;
@@ -140,7 +150,7 @@ async function findLatestOnboardingContext(userId: string) {
.from(growEvents)
.where(and(
eq(growEvents.userId, userId),
inArray(growEvents.type, ["onboarding.snapshot.saved", "onboarding.completed", "user.onboarding.completed", "profile.onboarding.completed"]),
inArray(growEvents.type, [...ONBOARDING_CONTEXT_EVENT_TYPES]),
))
.orderBy(desc(growEvents.occurredAt))
.limit(1);
+12 -2
View File
@@ -25,12 +25,22 @@ const VALID_COMPLETION_TYPES = [
"resume.analysis.completed",
"resume.parsed",
"resume.updated",
"resume.exported",
"interview.session.configured",
"interview.session.completed",
"interview.feedback.generated",
"interview.completed",
"roleplay.scenario.configured",
"roleplay.scenario.completed",
"roleplay.feedback.generated",
"roleplay.completed",
"assessment.completed",
"course.completed",
"course.lesson.completed",
"brand.post.exported",
"brand.profile.updated",
"matchmaking.matches.generated",
"matchmaking.feed.viewed",
"matchmaking.match.saved",
"matchmaking.application.completed",
"qscore.updated",
"qscore.signal.projected",
"curator.task.completed",
+9 -3
View File
@@ -4,7 +4,7 @@ import { requireUser, type AuthContext } from "../../auth/clerk.js";
import { applyQscoreProjection } from "../../events/projectors/qscore-projector.js";
import { applyServiceSessionProjection } from "../../events/projectors/service-session-projector.js";
import { markGrowEventFailed, markGrowEventProcessed, markGrowEventProcessing, recordGrowEvent } from "../../events/record-grow-event.js";
import { runCuratorOnboardingLoopForEventSafely } from "../curator/curator-onboarding-loop.js";
import { ensureOnboardingSideEffectsForEvent } from "../../events/onboarding-ledger.js";
const eventTrackSchema = z.object({
id: z.string().optional(),
@@ -90,14 +90,20 @@ export function v1EventRoutes() {
try {
const serviceSession = await applyServiceSessionProjection(event);
const qscore = await applyQscoreProjection(event);
const curatorOnboarding = await runCuratorOnboardingLoopForEventSafely(event);
const onboarding = await ensureOnboardingSideEffectsForEvent(event);
if (
onboarding.curatorOnboarding.status === "skipped" &&
onboarding.curatorOnboarding.reason === "loop_failed"
) {
throw new Error("curator_onboarding_loop_failed");
}
await markGrowEventProcessed(event.id);
return c.json({
eventId: event.id,
processingStatus: "processed",
serviceSession,
qscore,
curatorOnboarding,
onboarding,
}, 202);
} catch (err) {
await markGrowEventFailed(event.id, err);
+2
View File
@@ -17,6 +17,7 @@ export type ServiceCapability = {
backend?: unknown;
frontend?: unknown;
curator?: unknown;
eventContract?: unknown;
usageDocs?: string[];
};
@@ -43,6 +44,7 @@ export function listServiceCapabilities(opts: { public?: boolean } = {}): Servic
},
frontend: service.frontend,
curator: service.curator,
eventContract: service.eventContract,
usageDocs: service.usageDocs,
}));