From 4b652358d3eaddcfa6b83739167db4e07bda823b Mon Sep 17 00:00:00 2001 From: -Puter <22245429+puterhimself@users.noreply.github.com> Date: Mon, 13 Jul 2026 03:31:02 +0530 Subject: [PATCH] fix curator sprint ICP reconciliation --- package.json | 1 + scripts/curator-onboarding-reconcile.test.ts | 30 ++ scripts/curator-persisted-reconcile.test.ts | 148 ++++++++++ src/v1/curator/curator-events.ts | 17 +- src/v1/curator/curator-onboarding-loop.ts | 44 ++- src/v1/curator/curator-store.ts | 280 ++++++++++++++----- src/v1/curator/curator-types.ts | 7 + 7 files changed, 447 insertions(+), 80 deletions(-) create mode 100644 scripts/curator-persisted-reconcile.test.ts diff --git a/package.json b/package.json index 4ee2daa..5995cbd 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "test:missions": "tsx scripts/mission-lifecycle.test.ts", "test:passive-actions": "tsx scripts/passive-actions.test.ts", "test:curator-static": "tsx scripts/curator-static-registry.test.ts", + "test:curator-reconcile": "tsx scripts/curator-persisted-reconcile.test.ts", "start": "node dist/index.js", "typecheck": "tsc -p tsconfig.json --noEmit", "workflows:smoke": "tsx src/workflows/smoke-test.ts", diff --git a/scripts/curator-onboarding-reconcile.test.ts b/scripts/curator-onboarding-reconcile.test.ts index 41362cd..e3c4e09 100644 --- a/scripts/curator-onboarding-reconcile.test.ts +++ b/scripts/curator-onboarding-reconcile.test.ts @@ -1,10 +1,13 @@ import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import { + curatorSprintPlanFingerprint, + curatorSprintTaskPlan, decideCuratorSprintReconciliation, resolveCuratorSprintAccess, shouldReconcileCuratorSprint, } from "../src/v1/curator/curator-store.js"; +import { templateSetFor, trialDaysFor } from "../src/v1/curator/task-registry.js"; const usersRoute = readFileSync(new URL("../src/routes/users.ts", import.meta.url), "utf8"); const ledgerSource = readFileSync(new URL("../src/events/onboarding-ledger.ts", import.meta.url), "utf8"); @@ -44,4 +47,31 @@ const unauthorizedFull = resolveCuratorSprintAccess({ plan: "free", accessChoice assert.deepEqual(unauthorizedFull, { access: "trial", durationDays: 2 }); assert.equal(shouldReconcileCuratorSprint(existingTrial, unauthorizedFull), false); +const startDate = "2026-07-13"; +const studentFingerprint = curatorSprintPlanFingerprint({ startDate, variantId: "student_recent_grad", durationDays: 2, access: "trial" }); +const experiencedFingerprint = curatorSprintPlanFingerprint({ startDate, variantId: "experienced_professional", durationDays: 2, access: "trial" }); +const experiencedPlan = curatorSprintTaskPlan({ startDate, variantId: "experienced_professional", durationDays: 2, access: "trial" }); +assert.notEqual(studentFingerprint, experiencedFingerprint, "ICP changes must change the compatibility fingerprint"); +assert.equal(shouldReconcileCuratorSprint( + { access: "trial", durationDays: 2, variantId: "student_recent_grad", planFingerprint: studentFingerprint }, + { access: "trial", durationDays: 2, variantId: "experienced_professional", planFingerprint: experiencedFingerprint }, +), true, "student started/ready lineage must reconcile to experienced trial"); +const experiencedTrial = trialDaysFor("experienced_professional"); +assert.deepEqual(experiencedPlan[0]?.tasks.map((task) => task.title), [ + experiencedTrial[0]!.socialTitle, + experiencedTrial[0]!.measurementTitle, + experiencedTrial[0]!.proofTitle, + experiencedTrial[0]!.practiceTitle, +]); +assert.deepEqual(experiencedPlan[1]?.tasks.map((task) => task.title), [ + experiencedTrial[1]!.socialTitle, + experiencedTrial[1]!.measurementTitle, + experiencedTrial[1]!.proofTitle, + experiencedTrial[1]!.practiceTitle, +]); +assert.equal(experiencedPlan[0]?.tasks.length, 4); +assert.equal(experiencedPlan[1]?.tasks.length, 4); +assert.deepEqual(experiencedPlan[0]?.tasks.map((task) => task.id), experiencedPlan[0]?.tasks.map((task) => task.id)); +assert.equal(templateSetFor("experienced_professional").id, "experienced_professional"); + console.log("curator onboarding reconciliation tests passed"); diff --git a/scripts/curator-persisted-reconcile.test.ts b/scripts/curator-persisted-reconcile.test.ts new file mode 100644 index 0000000..5ad6a37 --- /dev/null +++ b/scripts/curator-persisted-reconcile.test.ts @@ -0,0 +1,148 @@ +import assert from "node:assert/strict"; +import { eq, sql } from "drizzle-orm"; +import { db } from "../src/db/client.js"; +import { growEvents, onboarding, users } from "../src/db/schema.js"; +import { buildCuratorSprint, curatorSprintTaskPlan } from "../src/v1/curator/curator-store.js"; +import { runCuratorOnboardingLoop } from "../src/v1/curator/curator-onboarding-loop.js"; +import { CURATOR_TRIAL_TASK_REGISTRY } from "../src/v1/curator/task-registry.js"; + +let databaseAvailable = Boolean(process.env.DATABASE_URL); +if (databaseAvailable) { + try { + await db.execute(sql`select 1`); + } catch { + databaseAvailable = false; + } +} + +if (!databaseAvailable) { + console.log("curator persisted reconciliation integration skipped: DATABASE_URL is not reachable"); +} else { + const userId = `curator-reconcile-regression-${Date.now()}`; + const startDate = new Date().toISOString().slice(0, 10); + const sprintId = `curator-sprint:icp-v10-static:${startDate}`; + const staleFingerprint = JSON.stringify({ variantId: "student_recent_grad", taskIds: [] }); + const staleReadyPayload = { + sprintId, + startDate, + durationDays: 2, + access: "trial", + variantId: "student_recent_grad", + planFingerprint: staleFingerprint, + }; + + try { + await db.insert(users).values({ id: userId, email: `${userId}@example.test`, plan: "pro" }); + await db.insert(onboarding).values({ + userId, + data: { + status: "completed", + access_choice: "trial", + profile: { icp: "experienced" }, + }, + payload: { + onboarding_icp: "experienced", + curator_registry_icp: "experienced_professional", + }, + }); + await db.insert(growEvents).values([ + { + userId, + source: "curator-v1", + type: "curator.sprint.started", + category: "mission", + occurredAt: new Date(), + dedupeKey: `${userId}:curator.sprint.started:stale`, + payload: { + sprintId, + startDate, + durationDays: 2, + access: "trial", + version: "icp-v10-static", + variantId: "student_recent_grad", + }, + }, + { + userId, + source: "curator-v1", + type: "curator.onboarding_plan.ready", + category: "mission", + occurredAt: new Date(), + dedupeKey: `${userId}:curator.onboarding_plan.ready:stale`, + payload: staleReadyPayload, + }, + ]); + + const context = { + onboarding: { status: "completed", access_choice: "trial", profile: { icp: "experienced" } }, + preferences: { onboarding: { status: "completed", access_choice: "trial", profile: { icp: "experienced" } } }, + }; + const first = await runCuratorOnboardingLoop({ userId, completedAt: `${startDate}T09:00:00.000Z`, context }); + assert.equal(first.status, "ready", "stale ready event must be replaced"); + + const day1 = await buildCuratorSprint(userId, startDate); + assert.deepEqual(day1.plan.days[0]?.tasks.map(({ id, title }) => ({ id, title })), + expectedTasks(startDate, 1), "Day 1 must expose experienced trial tasks"); + assert.deepEqual(day1.plan.days[1]?.tasks, [], "Day 1 must hide future Day 2 tasks"); + + const noCompletionDay2 = await buildCuratorSprint(userId, addDays(startDate, 1)); + const noCompletionTitles = noCompletionDay2.plan.days[1]?.tasks.map((task) => task.title) ?? []; + assert.ok(noCompletionTitles.includes("Executive Leadership Interview"), "zero-completion Day 2 keeps experienced recovery behavior"); + assert.ok(!noCompletionTitles.some((title) => ["Complete GrowQR Profile", "Career Baseline", "First AI Resume", "Self-Introduction"].includes(title)), "recovery must not reintroduce student tasks"); + + await db.insert(growEvents).values((day1.plan.days[0]?.tasks ?? []).map((task, index) => ({ + userId, + source: "curator-v1", + type: "curator.task.completed", + category: "mission" as const, + occurredAt: new Date(Date.now() + index), + dedupeKey: `${userId}:curator.task.completed:${task.id}`, + payload: { taskId: task.id }, + }))); + + const day2 = await buildCuratorSprint(userId, addDays(startDate, 1)); + assert.deepEqual(day2.plan.days[1]?.tasks.map(({ id, title }) => ({ id, title })), + expectedTasks(startDate, 2), "completed Day 1 must expose exact experienced Day 2 registry tasks"); + + const second = await runCuratorOnboardingLoop({ userId, completedAt: `${startDate}T09:00:00.000Z`, context }); + assert.equal(second.status, "already_ready", "compatible replacement ready event must be reused"); + + const rows = await db.select({ type: growEvents.type, payload: growEvents.payload }) + .from(growEvents).where(eq(growEvents.userId, userId)); + assert.equal(rows.filter((row) => row.type === "curator.sprint.invalidated").length, 1); + assert.equal(rows.filter((row) => row.type === "curator.onboarding_plan.invalidated").length, 1); + const starts = rows.filter((row) => row.type === "curator.sprint.started"); + const readies = rows.filter((row) => row.type === "curator.onboarding_plan.ready"); + assert.equal(starts.length, 2, "one stale and one replacement started lineage"); + assert.equal(readies.length, 2, "one stale and one replacement ready lineage"); + const replacementStart = starts.find((row) => row.payload?.variantId === "experienced_professional"); + const replacementReady = readies.find((row) => row.payload?.planFingerprint === day1.plan.planFingerprint); + assert.ok(replacementStart, "experienced replacement started event must be persisted"); + assert.ok(replacementReady, "experienced replacement ready event must be persisted"); + + console.log("curator persisted reconciliation integration passed"); + } finally { + await db.delete(users).where(eq(users.id, userId)); + await db.execute(sql`select 1`); + } +} + +function addDays(date: string, days: number) { + const value = new Date(`${date}T00:00:00.000Z`); + value.setUTCDate(value.getUTCDate() + days); + return value.toISOString().slice(0, 10); +} + +function expectedTasks(startDate: string, dayIndex: 1 | 2) { + const plan = curatorSprintTaskPlan({ + startDate, + variantId: "experienced_professional", + durationDays: 2, + access: "trial", + }); + const registry = CURATOR_TRIAL_TASK_REGISTRY.experienced_professional[dayIndex - 1]!; + return plan[dayIndex - 1]!.tasks.map(({ id }, index) => ({ + id, + title: [registry.socialTitle, registry.measurementTitle, registry.proofTitle, registry.practiceTitle][index]!, + })); +} diff --git a/src/v1/curator/curator-events.ts b/src/v1/curator/curator-events.ts index af544c7..62e74b9 100644 --- a/src/v1/curator/curator-events.ts +++ b/src/v1/curator/curator-events.ts @@ -7,13 +7,16 @@ function curatorDedupeKey(input: { payload?: Record; }) { const payload = input.payload ?? {}; - const stableId = - payload.taskId ?? - payload.sprintId ?? - payload.startDate ?? - payload.sourceEventId ?? - payload.eventId ?? - payload.date; + const stableId = input.type.includes("invalidated") + ? `${payload.staleSprintEventId ?? payload.staleReadyEventId ?? payload.staleEventId ?? "unknown"}:${payload.planFingerprint ?? "legacy"}` + : payload.planFingerprint ?? + payload.taskPlanFingerprint ?? + payload.taskId ?? + payload.sprintId ?? + payload.startDate ?? + payload.sourceEventId ?? + payload.eventId ?? + payload.date; return `${input.userId}:${input.type}:${stableId ?? Date.now()}`; } diff --git a/src/v1/curator/curator-onboarding-loop.ts b/src/v1/curator/curator-onboarding-loop.ts index ababa94..b9ca9ca 100644 --- a/src/v1/curator/curator-onboarding-loop.ts +++ b/src/v1/curator/curator-onboarding-loop.ts @@ -276,23 +276,48 @@ export async function runCuratorOnboardingLoop(input: OnboardingLoopInput): Prom const existing = await findExistingReadyEvent(userId); if (existing) { - const existingDuration = existing.payload?.durationDays === 7 ? 7 : existing.payload?.durationDays === 2 ? 2 : undefined; - if (existingDuration !== sprint.plan.durationDays) { + const existingFingerprint = typeof existing.payload?.planFingerprint === "string" ? existing.payload.planFingerprint : undefined; + const existingIcpId = typeof existing.payload?.icpId === "string" + ? existing.payload.icpId + : typeof existing.payload?.variantId === "string" ? existing.payload.variantId : undefined; + const compatible = existingFingerprint === sprint.plan.planFingerprint && existingIcpId === sprint.plan.icpId; + if (compatible) return { status: "already_ready", readyEventId: existing.id, sprint }; + + const invalidations = await db + .select({ payload: growEvents.payload }) + .from(growEvents) + .where(and( + eq(growEvents.userId, userId), + eq(growEvents.source, CURATOR_SOURCE), + eq(growEvents.type, "curator.onboarding_plan.invalidated"), + )) + .orderBy(desc(growEvents.occurredAt)) + .limit(20); + const alreadyInvalidated = invalidations.some((row) => ( + row.payload?.staleReadyEventId === existing.id + && row.payload?.planFingerprint === sprint.plan.planFingerprint + )); + if (!alreadyInvalidated) { await emitCuratorEvent({ userId, type: "curator.onboarding_plan.invalidated", payload: { sprintId: sprint.sprintId, staleReadyEventId: existing.id, - previousDurationDays: existingDuration, - access: sprint.plan.durationDays === 7 ? "full" : "trial", + previousIcpId: existingIcpId, + previousDurationDays: existing.payload?.durationDays, + previousPlanFingerprint: existingFingerprint, + icpId: sprint.plan.icpId, + variantId: sprint.plan.icpId, + access: sprint.plan.access, durationDays: sprint.plan.durationDays, + planFingerprint: sprint.plan.planFingerprint, + taskPlan: sprint.plan.taskPlan, dayOneTasks: dayOneTasks(sprint), - reason: "onboarding_access_choice_changed", + reason: "onboarding_plan_changed", }, }); } - return { status: "already_ready", readyEventId: existing.id, sprint }; } const event = await emitCuratorEvent({ @@ -307,11 +332,16 @@ export async function runCuratorOnboardingLoop(input: OnboardingLoopInput): Prom sprintId: sprint.sprintId, planId: sprint.plan.id, durationDays: sprint.plan.durationDays, - access: sprint.plan.durationDays === 7 ? "full" : "trial", + access: sprint.plan.access, + icpId: sprint.plan.icpId, + variantId: sprint.plan.icpId, + planFingerprint: sprint.plan.planFingerprint, + taskPlan: sprint.plan.taskPlan, weekCount: sprint.plan.weeks.length, dayCount: sprint.plan.days.length, activeDayIndex: sprint.activeDayIndex, weeklyThemes: weeklyThemes(sprint), + // Keep the future-day visibility contract: this is Day 1 only. dayOneTasks: dayOneTasks(sprint), notificationId: `curator:onboarding-plan-ready:${userId}`, }, diff --git a/src/v1/curator/curator-store.ts b/src/v1/curator/curator-store.ts index 695bb2b..9e51dd8 100644 --- a/src/v1/curator/curator-store.ts +++ b/src/v1/curator/curator-store.ts @@ -75,6 +75,11 @@ export type CuratorSprintAccess = { durationDays: 2 | 7; }; +export type CuratorSprintCompatibility = CuratorSprintAccess & { + variantId?: CuratorIcpId; + planFingerprint?: string; +}; + type CuratorSprintEntitlementInput = { plan?: unknown; accessChoice?: unknown; @@ -96,23 +101,31 @@ export function resolveCuratorSprintAccess(input: CuratorSprintEntitlementInput) } export function shouldReconcileCuratorSprint( - existing: Pick | undefined, - desired: CuratorSprintAccess, + existing: CuratorSprintCompatibility | undefined, + desired: CuratorSprintCompatibility, ) { - return !existing || existing.access !== desired.access || existing.durationDays !== desired.durationDays; + if (!existing || existing.access !== desired.access || existing.durationDays !== desired.durationDays) return true; + if (desired.variantId !== undefined && existing.variantId !== desired.variantId) return true; + if (desired.planFingerprint !== undefined && existing.planFingerprint !== desired.planFingerprint) return true; + return false; } export type CuratorSprintReconciliation = - | { action: "reuse"; access: "trial" | "full"; durationDays: 2 | 7 } - | { action: "invalidateAndRebuild"; access: "trial" | "full"; durationDays: 2 | 7 }; + | ({ action: "reuse" } & CuratorSprintAccess & Partial>) + | ({ action: "invalidateAndRebuild" } & CuratorSprintAccess & Partial>); export function decideCuratorSprintReconciliation( - existing: Pick | undefined, - desired: CuratorSprintAccess, + existing: CuratorSprintCompatibility | undefined, + desired: CuratorSprintCompatibility, ): CuratorSprintReconciliation { - return shouldReconcileCuratorSprint(existing, desired) - ? { action: "invalidateAndRebuild", access: desired.access, durationDays: desired.durationDays } - : { action: "reuse", access: desired.access, durationDays: desired.durationDays }; + const action = shouldReconcileCuratorSprint(existing, desired) ? "invalidateAndRebuild" : "reuse"; + return { + action, + access: desired.access, + durationDays: desired.durationDays, + ...(desired.variantId === undefined ? {} : { variantId: desired.variantId }), + ...(desired.planFingerprint === undefined ? {} : { planFingerprint: desired.planFingerprint }), + }; } function accessChoiceFromContext(context: Record | undefined): "trial" | "full" | undefined { @@ -473,6 +486,47 @@ export function planSeedsForVariant( return planDays; } +export type CuratorSprintTaskPlan = { + dayIndex: number; + tasks: Array<{ id: string; title: string }>; +}; + +function taskPlanForSeeds(startDate: string, planDays: PlanDaySeed[]): CuratorSprintTaskPlan[] { + return planDays.map((day) => ({ + dayIndex: day.dayIndex, + tasks: day.plannedTasks.map((task) => ({ + id: taskIdFor(startDate, day.dayIndex, task.taskType, task.serviceId), + title: task.title, + })), + })); +} + +export function curatorSprintTaskPlan(input: { + startDate: string; + variantId: CuratorIcpId; + durationDays: 2 | 7; + access: "trial" | "full"; +}): CuratorSprintTaskPlan[] { + return taskPlanForSeeds( + input.startDate, + planSeedsForVariant(templateSetFor(input.variantId), input.startDate, input.durationDays, input.access), + ); +} + +/** Stable compatibility key for the complete static sprint task plan. */ +export function curatorSprintPlanFingerprint(input: { + startDate: string; + variantId: CuratorIcpId; + durationDays: 2 | 7; + access: "trial" | "full"; +}): string { + const taskPlan = curatorSprintTaskPlan(input); + return JSON.stringify({ + variantId: input.variantId, + taskIds: taskPlan.flatMap((day) => day.tasks.map((task) => task.id)), + }); +} + function performanceLabel(percent: number): CuratorWeek["performance"] { if (percent >= 75) return "Excelling"; if (percent >= 50) return "Avg"; @@ -533,10 +587,61 @@ function eventText(value: unknown): string { return ""; } -async function inferIcpVariant(userId: string): Promise { +const CURATOR_ICP_BY_ONBOARDING_ICP: Record = { + student: "student_recent_grad", + intern: "intern", + fresher: "fresher_early_professional", + experienced: "experienced_professional", + freelancer: "freelancer", + founder: "founder", +}; + +function curatorIcpFromContext(context: Record | undefined): CuratorIcpId | undefined { + if (!context) return undefined; + const preferences = context.preferences && typeof context.preferences === "object" && !Array.isArray(context.preferences) + ? context.preferences as Record + : undefined; + const onboardingContext = context.onboarding && typeof context.onboarding === "object" && !Array.isArray(context.onboarding) + ? context.onboarding as Record + : undefined; + const preferenceOnboarding = preferences?.onboarding && typeof preferences.onboarding === "object" && !Array.isArray(preferences.onboarding) + ? preferences.onboarding as Record + : undefined; + const profile = onboardingContext?.profile && typeof onboardingContext.profile === "object" && !Array.isArray(onboardingContext.profile) + ? onboardingContext.profile as Record + : preferenceOnboarding?.profile && typeof preferenceOnboarding.profile === "object" && !Array.isArray(preferenceOnboarding.profile) + ? preferenceOnboarding.profile as Record + : undefined; + const candidates = [ + context.curator_registry_icp, + onboardingContext?.curator_registry_icp, + preferences?.curator_registry_icp, + context.onboarding_icp, + onboardingContext?.onboarding_icp, + profile?.icp, + ]; + for (const candidate of candidates) { + if (typeof candidate !== "string") continue; + if (isCuratorIcpId(candidate)) return candidate; + const mapped = CURATOR_ICP_BY_ONBOARDING_ICP[candidate.toLowerCase()]; + if (mapped) return mapped; + } + return undefined; +} + +async function inferIcpVariant(userId: string, context?: Record, preferContext = false): Promise { + const fromContext = curatorIcpFromContext(context); const saved = await db.query.onboarding.findFirst({ where: eq(onboarding.userId, userId) }); - const canonical = saved?.payload && typeof saved.payload === "object" ? saved.payload.curator_registry_icp : undefined; - if (typeof canonical === "string" && isCuratorIcpId(canonical)) return canonical; + if (preferContext && fromContext) return fromContext; + const savedPayload = saved?.payload && typeof saved.payload === "object" ? saved.payload : undefined; + const savedData = saved?.data && typeof saved.data === "object" ? saved.data : undefined; + const fromSaved = curatorIcpFromContext({ + curator_registry_icp: savedPayload?.curator_registry_icp, + onboarding_icp: savedPayload?.onboarding_icp, + onboarding: savedData, + }); + if (fromSaved) return fromSaved; + if (fromContext) return fromContext; const rows = await db .select({ type: growEvents.type, payload: growEvents.payload }) @@ -602,14 +707,15 @@ async function loadSprintState( ? persistedContext.onboarding as Record : undefined; const contextCompleted = contextOnboarding?.status === "completed" || typeof contextOnboarding?.completed_at === "string"; - if (!dbCompleted && !contextCompleted) { - throw new Error("onboarding_incomplete"); - } + if (!dbCompleted && !contextCompleted) throw new Error("onboarding_incomplete"); const user = await db.query.users.findFirst({ where: eq(users.id, userId) }); const dbChoice = dbData?.access_choice; - const accessChoice = accessChoiceFromContext(persistedContext) ?? dbChoice; + const accessChoice = onboardingContextOverride + ? accessChoiceFromContext(persistedContext) ?? dbChoice + : dbChoice ?? accessChoiceFromContext(persistedContext); const desired = resolveCuratorSprintAccess({ plan: user?.plan, accessChoice }); + const desiredVariant = await inferIcpVariant(userId, persistedContext, Boolean(onboardingContextOverride)); const latest = await db .select({ id: growEvents.id, payload: growEvents.payload }) @@ -625,79 +731,107 @@ async function loadSprintState( const latestPayload = latest[0]?.payload; const existingStartDate = typeof latestPayload?.startDate === "string" ? latestPayload.startDate : undefined; const existingVersion = typeof latestPayload?.version === "string" ? latestPayload.version : undefined; - const existingVariant = typeof latestPayload?.variantId === "string" ? latestPayload.variantId : undefined; + const existingVariant = typeof latestPayload?.variantId === "string" && isCuratorIcpId(latestPayload.variantId) + ? latestPayload.variantId + : undefined; const existingDuration = latestPayload?.durationDays === 7 ? 7 : latestPayload?.durationDays === 2 ? 2 : undefined; const existingAccess = latestPayload?.access === "full" ? "full" : latestPayload?.access === "trial" ? "trial" : undefined; + const existingFingerprint = typeof latestPayload?.planFingerprint === "string" ? latestPayload.planFingerprint : undefined; const rebaseExistingForCompletion = Boolean(onboardingContextOverride && existingStartDate && existingStartDate !== todayDate); const activeExisting = Boolean( - !rebaseExistingForCompletion && existingStartDate && existingVersion === CURATOR_PLAN_VERSION && existingVariant && isCuratorIcpId(existingVariant) - && existingDuration && calendarDiffDays(existingStartDate, todayDate) >= 0 && calendarDiffDays(existingStartDate, todayDate) < existingDuration, + !rebaseExistingForCompletion && existingStartDate && existingVersion === CURATOR_PLAN_VERSION && existingVariant + && existingDuration && existingAccess && calendarDiffDays(existingStartDate, todayDate) >= 0 && calendarDiffDays(existingStartDate, todayDate) < existingDuration, ); + + let replacementStartDate = todayDate; if (activeExisting && existingStartDate && existingVariant && existingDuration && existingAccess) { - const templateSet = templateSetFor(existingVariant as CuratorIcpId); - const reconciliation = decideCuratorSprintReconciliation({ access: existingAccess, durationDays: existingDuration }, desired); - if (reconciliation.action === "invalidateAndRebuild") { - const invalidations = await db - .select({ payload: growEvents.payload }) - .from(growEvents) - .where(and( - eq(growEvents.userId, userId), - eq(growEvents.source, CURATOR_SOURCE), - eq(growEvents.type, "curator.sprint.invalidated"), - )) - .orderBy(desc(growEvents.occurredAt)) - .limit(20); - const alreadyInvalidated = invalidations.some((row) => ( - row.payload?.staleSprintEventId === latest[0]?.id - && row.payload?.access === desired.access - && row.payload?.durationDays === desired.durationDays - )); - if (!alreadyInvalidated) { - await emitCuratorEvent({ - userId, - type: "curator.sprint.invalidated", - payload: { - sprintId: sprintIdFor(existingStartDate), - staleSprintEventId: latest[0]?.id, - staleSprintId: sprintIdFor(existingStartDate), - startDate: existingStartDate, - reason: "onboarding_access_choice_changed", - previousAccess: existingAccess, - previousDurationDays: existingDuration, - access: desired.access, - durationDays: desired.durationDays, - version: CURATOR_PLAN_VERSION, - }, - }); - } - } - return { + const desiredFingerprint = curatorSprintPlanFingerprint({ startDate: existingStartDate, - variantId: existingVariant as CuratorIcpId, + variantId: desiredVariant, durationDays: desired.durationDays, access: desired.access, - planDays: planSeedsForVariant(templateSet, existingStartDate, desired.durationDays, desired.access), - }; + }); + const reconciliation = decideCuratorSprintReconciliation( + { access: existingAccess, durationDays: existingDuration, variantId: existingVariant, planFingerprint: existingFingerprint }, + { ...desired, variantId: desiredVariant, planFingerprint: desiredFingerprint }, + ); + if (reconciliation.action === "reuse") { + return { + startDate: existingStartDate, + variantId: existingVariant, + durationDays: desired.durationDays, + access: desired.access, + planDays: planSeedsForVariant(templateSetFor(existingVariant), existingStartDate, desired.durationDays, desired.access), + }; + } + + const invalidations = await db + .select({ payload: growEvents.payload }) + .from(growEvents) + .where(and( + eq(growEvents.userId, userId), + eq(growEvents.source, CURATOR_SOURCE), + eq(growEvents.type, "curator.sprint.invalidated"), + )) + .orderBy(desc(growEvents.occurredAt)) + .limit(20); + const alreadyInvalidated = invalidations.some((row) => ( + row.payload?.staleSprintEventId === latest[0]?.id + && row.payload?.planFingerprint === desiredFingerprint + )); + if (!alreadyInvalidated) { + await emitCuratorEvent({ + userId, + type: "curator.sprint.invalidated", + payload: { + sprintId: sprintIdFor(existingStartDate), + staleSprintEventId: latest[0]?.id, + staleSprintId: sprintIdFor(existingStartDate), + startDate: existingStartDate, + reason: "onboarding_plan_changed", + previousVariantId: existingVariant, + previousAccess: existingAccess, + previousDurationDays: existingDuration, + previousPlanFingerprint: existingFingerprint, + variantId: desiredVariant, + access: desired.access, + durationDays: desired.durationDays, + planFingerprint: desiredFingerprint, + version: CURATOR_PLAN_VERSION, + }, + }); + } + // Keep the same calendar sprint identity while replacing its incompatible lineage. + replacementStartDate = existingStartDate; } - const variantId = await inferIcpVariant(userId); + const variantId = desiredVariant; const templateSet = templateSetFor(variantId); - const planDays = planSeedsForVariant(templateSet, todayDate, desired.durationDays, desired.access); + const planDays = planSeedsForVariant(templateSet, replacementStartDate, desired.durationDays, desired.access); + const planFingerprint = curatorSprintPlanFingerprint({ + startDate: replacementStartDate, + variantId, + durationDays: desired.durationDays, + access: desired.access, + }); await emitCuratorEvent({ userId, type: "curator.sprint.started", payload: { - startDate: todayDate, - sprintId: sprintIdFor(todayDate), + startDate: replacementStartDate, + sprintId: sprintIdFor(replacementStartDate), durationDays: desired.durationDays, access: desired.access, version: CURATOR_PLAN_VERSION, variantId, + icpId: variantId, icpLabel: templateSet.label, + planFingerprint, + taskPlan: taskPlanForSeeds(replacementStartDate, planDays), }, }); return { - startDate: todayDate, + startDate: replacementStartDate, variantId, durationDays: desired.durationDays, access: desired.access, @@ -1263,6 +1397,20 @@ async function buildCuratorSprintInternal( goals: [templateSet.sprintTheme, templateSet.goal], generatedAt: new Date().toISOString(), durationDays: sprintState.durationDays, + access: sprintState.access, + icpId: sprintState.variantId, + planFingerprint: curatorSprintPlanFingerprint({ + startDate: sprintStartDate, + variantId: sprintState.variantId, + durationDays: sprintState.durationDays, + access: sprintState.access, + }), + taskPlan: curatorSprintTaskPlan({ + startDate: sprintStartDate, + variantId: sprintState.variantId, + durationDays: sprintState.durationDays, + access: sprintState.access, + }), weeks, days, streak, diff --git a/src/v1/curator/curator-types.ts b/src/v1/curator/curator-types.ts index 806a934..4e13516 100644 --- a/src/v1/curator/curator-types.ts +++ b/src/v1/curator/curator-types.ts @@ -120,6 +120,13 @@ export const curatorPlanSchema = z.object({ goals: z.array(z.string()), generatedAt: z.string(), durationDays: z.union([z.literal(2), z.literal(7)]), + access: z.enum(["trial", "full"]), + icpId: z.string(), + planFingerprint: z.string(), + taskPlan: z.array(z.object({ + dayIndex: z.number().int().min(1), + tasks: z.array(z.object({ id: z.string(), title: z.string() })), + })), weeks: z.array(curatorWeekSchema).min(1).max(1), days: z.array(curatorPlanDaySchema).min(2).max(7), streak: curatorStreakSchema,