5 Commits

Author SHA1 Message Date
Sai-karthik
7d74f81913 Recover deep-linked curator starts 2026-06-30 15:46:15 +00:00
Sai-karthik
3bba99e874 Recover completed deep-linked curator tasks 2026-06-30 13:31:30 +00:00
Sai-karthik
697040e9d3 Complete deep-linked matchmaking tasks 2026-06-30 13:23:44 +00:00
Sai-karthik
39ba59ab25 Cap home mission cards 2026-06-29 18:44:28 +00:00
Sai-karthik
10478fb035 Fix staging home mission service routing 2026-06-29 18:38:03 +00:00
6 changed files with 122 additions and 62 deletions

View File

@@ -31,8 +31,9 @@ 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));
const HOME_FEED_AGENT_ENABLED = process.env.HOME_FEED_AGENT_ENABLED === "true";
const HOME_FEED_AGENT_TIMEOUT_MS = Number(process.env.HOME_FEED_AGENT_TIMEOUT_MS ?? 5000);
const HOME_FEED_AGENT_ATTEMPTS = Math.max(1, Number(process.env.HOME_FEED_AGENT_ATTEMPTS ?? 1));
export type AgentHomeNotification = z.infer<typeof notificationSchema>;
@@ -50,7 +51,6 @@ Every notification must point to one of these real dashboard routes:
- /agents/resume for resume building, resume analysis, ATS, resume suggestions
- /agents/interview for mock interview setup, interview session, interview review
- /agents/roleplay for recruiter/manager/salary/stakeholder roleplay
- /agents/qscore for Q Score/readiness explanations
- /missions for mission progress, approvals, artifacts, next stages
- /agents/social-branding for LinkedIn/social branding
- /agents/matchmaking for Scout/opportunity matching
@@ -58,7 +58,7 @@ Every notification must point to one of these real dashboard routes:
- /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
- source: one of resume, interview, roleplay, 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.`;
@@ -79,8 +79,7 @@ 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("/missions")) return "mission";
if (href.startsWith("/agents/social-branding")) return "social";
if (href.startsWith("/agents/matchmaking")) return "pathways";
if (href.startsWith("/rewards")) return "rewards";
@@ -97,7 +96,6 @@ function moduleFromSource(source: NonNullable<AgentHomeNotification["source"]>):
}
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";
@@ -112,7 +110,6 @@ function defaultHrefForSource(source: NonNullable<AgentHomeNotification["source"
if (source === "resume") return "/agents/resume";
if (source === "interview") return "/agents/interview";
if (source === "roleplay") return "/agents/roleplay";
if (source === "qscore") return "/agents/qscore";
if (source === "mission") return "/missions";
if (source === "social") return "/agents/social-branding";
if (source === "pathways") return "/agents/matchmaking";
@@ -127,10 +124,12 @@ function normalizeAgentNotification(
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 normalizedSource = inferredSource === "qscore" ? "pathways" : inferredSource;
const moduleId = raw.moduleId ?? seed?.moduleId ?? (normalizedSource ? moduleFromSource(normalizedSource) : "suggestions");
const rawHref = raw.href ?? seed?.href ?? (normalizedSource ? defaultHrefForSource(normalizedSource, moduleId) : `/${moduleId}`);
const href = sanitizeHref(rawHref, moduleId);
const source = raw.source ?? seed?.source ?? sourceFromHref(href);
const seedSource = seed?.source === "qscore" ? "pathways" : seed?.source;
const source = raw.source ?? seedSource ?? sourceFromHref(href);
return notificationSchema.parse({
...raw,
tag: raw.tag ?? seed?.tag ?? tagFromSource(source),
@@ -157,7 +156,8 @@ function completeNotificationsWithSeeds(
for (const seed of seeds) {
if (completed.length >= 6) break;
const candidate = normalizeAgentNotification(seed, seeds);
const candidateSeed = seed.source === "qscore" ? { ...seed, source: "pathways" as const, href: seed.href?.startsWith("/agents/qscore") ? "/agents/matchmaking" : seed.href } : seed;
const candidate = normalizeAgentNotification(candidateSeed, seeds);
const key = notificationKey(candidate);
if (seen.has(key)) continue;
completed.push(candidate);
@@ -184,6 +184,7 @@ 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 (!HOME_FEED_AGENT_ENABLED) return [];
if (!config.llmApiKey && config.nodeEnv === "production") {
throw new HomeFeedAgentError("home_feed_agent_missing_llm_api_key");
}

View File

@@ -35,14 +35,13 @@ import {
const FRESH_MS = 10 * 60 * 1000;
const EXPIRY_MS = 24 * 60 * 60 * 1000;
const MISSION_MODULE_LIMIT = 3;
const SERVICE_HREFS = {
resume: buildServiceLink("resume-service", "workspace") ?? "/agents/resume",
interview: buildServiceLink("interview-service", "discovery") ?? "/agents/interview",
roleplay: buildServiceLink("roleplay-service", "discovery") ?? "/agents/roleplay",
qscore: buildServiceLink("qscore-service", "dashboard") ?? "/agents/qscore",
mission: "/missions/active",
social: buildServiceLink("social-branding-service", "profile") ?? "/agents/social-branding",
pathways: buildServiceLink("matchmaking-service", "jobs") ?? "/agents/matchmaking",
rewards: "/rewards",
suggestions: "/suggestions",
@@ -101,10 +100,10 @@ function profileFromPreferences(preferences: Record<string, unknown>) {
};
}
function serviceHref(service: "resume" | "interview" | "roleplay" | "qscore", ctx: HomeContext, mission?: { instanceId?: string; missionId?: string; stageId?: string | null }) {
function serviceHref(service: "resume" | "interview" | "roleplay", ctx: HomeContext, mission?: { instanceId?: string; missionId?: string; stageId?: string | null }) {
const profile = profileFromPreferences(ctx.preferences);
const serviceId = service === "qscore" ? "qscore-service" : `${service}-service`;
const pageId = service === "resume" ? "workspace" : service === "qscore" ? "dashboard" : "setup";
const serviceId = `${service}-service`;
const pageId = service === "resume" ? "workspace" : "setup";
return buildServiceLink(serviceId, pageId, {
source: "home",
missionInstanceId: mission?.instanceId,
@@ -126,7 +125,7 @@ function sourceFromSuggestionRole(role: string): HomeSource {
if (value.includes("resume")) return "resume";
if (value.includes("roleplay")) return "roleplay";
if (value.includes("interview")) return "interview";
if (value.includes("q")) return "qscore";
if (value.includes("match") || value.includes("pathway") || value.includes("job")) return "pathways";
return "mission";
}
@@ -193,9 +192,8 @@ function buildDayOneSeeds(): SeedNotification[] {
{ id: "resume-service", moduleId: "productivity" as const, href: SERVICE_HREFS.resume, source: "resume" as const, urgency: "today" as const },
{ id: "interview-service", moduleId: "productivity" as const, href: SERVICE_HREFS.interview, source: "interview" as const, urgency: "today" as const },
{ id: "roleplay-service", moduleId: "productivity" as const, href: SERVICE_HREFS.roleplay, source: "roleplay" as const, urgency: "soon" as const },
{ id: "qscore-service", moduleId: "suggestions" as const, href: SERVICE_HREFS.qscore, source: "qscore" as const, urgency: "now" as const },
{ id: "social-branding-service", moduleId: "social" as const, href: SERVICE_HREFS.social, source: "social" as const, urgency: "soon" as const },
{ id: "matchmaking-service", moduleId: "pathways" as const, href: SERVICE_HREFS.pathways, source: "pathways" as const, urgency: "calm" as const },
{ id: "courses-service", moduleId: "productivity" as const, href: SERVICE_HREFS.productivity, source: "system" as const, urgency: "soon" as const },
{ id: "matchmaking-service", moduleId: "pathways" as const, href: SERVICE_HREFS.pathways, source: "pathways" as const, urgency: "today" as const },
];
for (const [index, card] of serviceCards.entries()) {
@@ -233,7 +231,14 @@ function buildDynamicSeeds(ctx: HomeContext): SeedNotification[] {
const roleplayReview = serviceEvent(ctx, "roleplay.", "review");
const resumeAnalysis = serviceEvent(ctx, "resume.", "analysis");
for (const suggestion of ctx.missionSuggestions.slice(0, 5)) {
const visibleMissionSuggestions = ctx.missionSuggestions
.filter((suggestion) => {
const haystack = `${suggestion.role} ${suggestion.title} ${suggestion.body} ${suggestion.ctaLabel} ${suggestion.ctaHref}`.toLowerCase();
return !haystack.includes("qscore") && !haystack.includes("q score") && !haystack.includes("assessment") && !haystack.includes("/agents/qscore");
})
.slice(0, 5);
for (const suggestion of visibleMissionSuggestions) {
const mission = ctx.activeMissions.find((item) => item.instanceId === suggestion.missionInstanceId);
const source = sourceFromSuggestionRole(suggestion.role);
const href = sanitizeHref(suggestion.ctaHref, mission ? `/missions/active?missionInstanceId=${encodeURIComponent(mission.instanceId)}` : SERVICE_HREFS.mission);
@@ -276,13 +281,13 @@ function buildDynamicSeeds(ctx: HomeContext): SeedNotification[] {
if (ctx.qscore || ctx.qscoreSignals.length) {
pushSeed(seeds, {
moduleId: "suggestions",
title: qscore >= 80 ? "Protect your Q Score momentum" : "Raise your Q Score next",
subtitle: qscore >= 80 ? `Readiness is trending at ${qscore}. Keep one proof action moving for ${profile.targetRole}.` : `Current estimate is ${qscore || 64}. Resume + mock practice are fastest for ${profile.targetRole}.`,
tag: "Q Score",
moduleId: "pathways",
title: qscore >= 80 ? "Review your best job matches" : "Find better-fit job matches",
subtitle: qscore >= 80 ? `Your profile signals are strong enough to compare matched roles for ${profile.targetRole}.` : `Use resume and interview signals to surface roles that fit ${profile.targetRole}.`,
tag: "Matches",
urgency: qscore >= 80 ? "today" : "now",
href: serviceHref("qscore", ctx),
source: "qscore",
href: SERVICE_HREFS.pathways,
source: "pathways",
priority: 95,
});
}
@@ -300,7 +305,7 @@ function buildDynamicSeeds(ctx: HomeContext): SeedNotification[] {
});
}
for (const mission of ctx.activeMissions.slice(0, 3)) {
for (const mission of ctx.activeMissions.slice(0, MISSION_MODULE_LIMIT)) {
pushSeed(seeds, {
moduleId: "missions",
title: `${mission.title}${mission.progressPercent}%`,
@@ -327,17 +332,6 @@ function buildDynamicSeeds(ctx: HomeContext): SeedNotification[] {
});
}
pushSeed(seeds, {
moduleId: "social",
title: "Turn proof into LinkedIn updates",
subtitle: ctx.artifacts.length ? `${ctx.artifacts.length} artifact${ctx.artifacts.length === 1 ? "" : "s"} can feed headline, featured, or post ideas.` : `Connect LinkedIn and use ${profile.targetRole} proof to improve your profile.`,
tag: ctx.artifacts.length ? "Proof" : "Setup",
urgency: ctx.artifacts.length ? "today" : "soon",
href: SERVICE_HREFS.social,
source: "social",
priority: 70,
});
if (resumeAnalysis || resumeSession || ats !== undefined) {
pushSeed(seeds, {
moduleId: "productivity",
@@ -381,7 +375,7 @@ function buildDynamicSeeds(ctx: HomeContext): SeedNotification[] {
}
if (!ctx.activeMissions.length) {
pushSeed(seeds, { moduleId: "missions", title: "Start Interview-to-Offer", subtitle: `Bundle resume fit, mock practice, and Q Score deltas for ${profile.targetRole}.`, tag: "Begin", urgency: "today", href: "/missions/available", source: "mission", priority: 80 });
pushSeed(seeds, { moduleId: "missions", title: "Start Interview-to-Offer", subtitle: `Bundle resume fit, mock practice, and matched roles for ${profile.targetRole}.`, tag: "Begin", urgency: "today", href: "/missions/available", source: "mission", priority: 80 });
}
return seeds;
@@ -399,7 +393,7 @@ async function collectContext(userId: string, input: { userProfile?: Record<stri
const activeMissions = await db
.select({ instanceId: growActiveMissions.instanceId, missionId: growActiveMissions.missionId, title: growActiveMissions.title, status: growActiveMissions.status, progressPercent: growActiveMissions.progressPercent, currentStageId: growActiveMissions.currentStageId, updatedAt: growActiveMissions.updatedAt })
.from(growActiveMissions)
.where(eq(growActiveMissions.userId, userId))
.where(and(eq(growActiveMissions.userId, userId), eq(growActiveMissions.status, "active")))
.orderBy(desc(growActiveMissions.updatedAt))
.limit(6);
const suggestions = await db
@@ -501,7 +495,6 @@ function hasLegacyMockSeed(rows: GrowHomeNotificationRow[]) {
"Complete your QX self-check",
"Create your interview room",
"Browse 1 career pathway",
"Start with your Q Score",
"Explore Interview-to-Offer",
"Pathways are warming up",
"Open Resume Builder",
@@ -557,8 +550,7 @@ function moduleCount(moduleId: HomeModuleId, notifications: HomeNotification[],
return String(notifications.length);
}
if (moduleId === "missions") {
if (ctx.activeMissions.length) return `${ctx.activeMissions.length} active`;
return mode === "day1" ? "0" : String(notifications.length);
return mode === "day1" && notifications.length === 0 ? "0" : String(Math.min(notifications.length, MISSION_MODULE_LIMIT));
}
if (moduleId === "productivity") {
const active = ctx.sessions.filter((s) => s.status === "active" || s.status === "configured" || s.status === "processing").length;
@@ -580,10 +572,11 @@ function buildModules(rows: GrowHomeNotificationRow[], ctx: HomeContext, mode: H
return MODULE_IDS.map((moduleId) => {
const notifications = byModule.get(moduleId) ?? [];
const visibleNotifications = moduleId === "missions" ? notifications.slice(0, MISSION_MODULE_LIMIT) : notifications;
return {
...MODULE_META[moduleId],
count: moduleCount(moduleId, notifications, ctx, mode),
notifications,
count: moduleCount(moduleId, visibleNotifications, ctx, mode),
notifications: visibleNotifications,
};
});
}

View File

@@ -909,6 +909,7 @@ export function serviceRoutes() {
taskId: curatorTaskIdFromRequest(c.req.raw, body),
externalId: getString(result.task_id ?? result.taskId),
},
mission: missionFromRequest(c.req.raw, body),
}).catch((err) => log.warn({ err, userId, action }, "failed to record matchmaking workflow event"));
return c.json(result);
});

View File

@@ -131,6 +131,14 @@ function getSessionId(detail?: Record<string, unknown>) {
const frontendBaseUrl = config.workflowsDashboardUrl.replace(/\/$/, "");
const allowedServiceIds = new Set<ServiceId>([
"interview-service",
"matchmaking-service",
"roleplay-service",
"courses-service",
"resume-service",
]);
const serviceRegistry: ServiceRecord[] = [
{
id: "interview-service",
@@ -837,12 +845,12 @@ export function normalizeServiceId(serviceId?: string | null): ServiceId | undef
}
export function listServices() {
return serviceRegistry;
return serviceRegistry.filter((service) => allowedServiceIds.has(service.id));
}
export function getService(serviceId?: string | null) {
const normalized = normalizeServiceId(serviceId);
return normalized ? serviceRegistry.find((service) => service.id === normalized) : undefined;
return normalized ? listServices().find((service) => service.id === normalized) : undefined;
}
export function getServiceBackend(serviceId?: string | null) {
@@ -881,7 +889,7 @@ export function buildServiceLink(serviceId: string | undefined, pageId?: string,
}
export function listServicesForCatalog() {
return serviceRegistry.map((service) => ({
return listServices().map((service) => ({
id: service.id,
label: service.label,
description: service.description,

View File

@@ -16,6 +16,29 @@ type CuratorActorState = {
lastEventId?: string;
};
function addDaysIsoLocal(startDate: string, days: number) {
const date = new Date(`${startDate}T00:00:00.000Z`);
if (Number.isNaN(date.getTime())) return undefined;
date.setUTCDate(date.getUTCDate() + days);
return date.toISOString().slice(0, 10);
}
function dateFromTaskId(taskId: string) {
const match = /:icp-v\d+:(\d{4}-\d{2}-\d{2}):day-(\d+):/.exec(taskId);
if (!match) return undefined;
const startDate = match[1];
const daySegment = match[2];
if (!startDate || !daySegment) return undefined;
const dayIndex = Number(daySegment);
if (!Number.isFinite(dayIndex) || dayIndex < 1) return undefined;
return addDaysIsoLocal(startDate, dayIndex - 1);
}
function taskActionDate(input: { taskId: string; date?: string }) {
return input.date ?? dateFromTaskId(input.taskId) ?? todayIsoDate();
}
function touch(c: { state: CuratorActorState }, input: { userId: string }) {
if (c.state.userId && c.state.userId !== input.userId) throw new Error("curatorActor initialized for a different user");
c.state.userId = input.userId;
@@ -88,9 +111,16 @@ export const curatorService = {
},
async startTask(input: { userId: string; taskId: string; date?: string }) {
const date = input.date ?? todayIsoDate();
const date = taskActionDate(input);
const task = (await buildCuratorTasks(input.userId, date)).find((item) => item.id === input.taskId);
if (!task) throw new Error("curator_task_not_found");
if (!task) {
const event = await emitCuratorEvent({
userId: input.userId,
type: "curator.task.started",
payload: { taskId: input.taskId, date, recoveredDeepLink: true },
});
return { task: { id: input.taskId, date, status: "started" as const }, eventId: event.id };
}
const event = await emitCuratorEvent({
userId: input.userId,
type: "curator.task.started",
@@ -101,7 +131,7 @@ export const curatorService = {
},
async prepareTaskHandoff(input: { userId: string; taskId: string; date?: string }) {
const date = input.date ?? todayIsoDate();
const date = taskActionDate(input);
const task = (await buildCuratorTasks(input.userId, date)).find((item) => item.id === input.taskId);
if (!task) throw new Error("curator_task_not_found");
if (task.serviceId) return prepareHandoffForTask(input.userId, task, task.serviceId);
@@ -109,11 +139,21 @@ export const curatorService = {
},
async completeTask(input: { userId: string; taskId: string; date?: string; reason?: string }) {
const date = input.date ?? todayIsoDate();
const date = taskActionDate(input);
const task = (await buildCuratorTasks(input.userId, date)).find((item) => item.id === input.taskId);
if (!task) throw new Error("curator_task_not_found");
const reason = input.reason ?? "subtasks_completed";
const allowDirectServiceCompletion = task.serviceId === "qscore-service" && reason === "qscore_review_opened";
if (!task && reason === "matchmaking_matches_reviewed") {
const event = await emitCuratorEvent({
userId: input.userId,
type: "curator.task.completed",
payload: { taskId: input.taskId, date, reason, recoveredDeepLink: true },
});
return { task: { id: input.taskId, date, status: "completed" as const }, eventId: event.id };
}
if (!task) throw new Error("curator_task_not_found");
const allowDirectServiceCompletion =
(task.serviceId === "qscore-service" && reason === "qscore_review_opened") ||
(task.serviceId === "matchmaking-service" && reason === "matchmaking_matches_reviewed");
if (task.serviceId && !allowDirectServiceCompletion) {
throw new Error("curator_service_task_requires_service_event");
}

View File

@@ -113,14 +113,17 @@ type ServiceCurationPreviewInput = {
};
const QSCORE_TASK_SERVICE_REPLACEMENTS: Record<CuratorTaskType, CuratorServiceId> = {
measurement: "assessment-service",
measurement: "matchmaking-service",
proof: "resume-service",
practice: "interview-service",
recovery: "roleplay-service",
};
function curatorAssignableServiceId(serviceId: CuratorServiceId, taskType: CuratorTaskType): CuratorServiceId {
return serviceId === "qscore-service" ? QSCORE_TASK_SERVICE_REPLACEMENTS[taskType] : serviceId;
if (serviceId === "qscore-service") return QSCORE_TASK_SERVICE_REPLACEMENTS[taskType];
if (serviceId === "social-branding-service") return taskType === "practice" ? "roleplay-service" : "resume-service";
if (serviceId === "assessment-service") return "matchmaking-service";
return serviceId;
}
function qscoreFreeTaskCopy(input: {
@@ -159,10 +162,12 @@ function qscoreFreeTaskCopy(input: {
}
return {
title: input.title.replace(/Q Score|QScore/gi, "readiness assessment"),
subtitle: input.subtitle.replace(/Q Score|QScore/gi, "the assessment service"),
cta: "Open assessment",
signals: input.signals.map((signal) => signal.replace(/q\s?score/gi, "assessment")),
title: input.title.replace(/Q Score|QScore/gi, "job matches"),
subtitle: input.subtitle
.replace(/Q Score|QScore/gi, "job matching")
.replace(/assessment service|assessment/gi, "job matching"),
cta: "View matches",
signals: input.signals.map((signal) => signal.replace(/q\s?score|assessment/gi, "job matching")),
};
}
@@ -877,6 +882,18 @@ function seed(
): TaskSeed {
const assignedServiceId = curatorAssignableServiceId(serviceId, taskType);
const copy = qscoreFreeTaskCopy({ serviceId, taskType, title, subtitle, cta, signals });
if (serviceId === "social-branding-service") {
return {
taskType,
serviceId: assignedServiceId,
title: copy.title.replace(/social proof|social profile|public credibility|visibility/gi, "resume proof"),
subtitle: copy.subtitle.replace(/social proof|social profile|public credibility|visibility|public positioning/gi, "resume proof"),
effort,
qxImpact,
cta: "Open resume workspace",
signals: copy.signals.map((signal) => signal.replace(/social|public|visibility/gi, "resume proof")),
};
}
return { taskType, serviceId: assignedServiceId, title: copy.title, subtitle: copy.subtitle, effort, qxImpact, cta: copy.cta, signals: copy.signals };
}