From 54c2c1e666b74a807fbb44b46d23d584a73540cf Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sat, 1 Aug 2026 14:38:45 -0700 Subject: [PATCH] FN-8719: fix stale milestone validation badges Keep persisted milestone validation rollups and dashboard badges synchronized after assertion changes. - Reconcile assertion mutations and validation rollups atomically within each project. - Prevent stale rollup and telemetry responses from overwriting newer milestone state. - Cover repaired and removed failure states with PostgreSQL and dashboard tests. Files changed: .changeset/fn-8719-milestone-validation-rollup.md | 7 + docs/missions.md | 4 + .../__tests__/postgres/mission-store.pg.test.ts | 61 +++++++ packages/core/src/async-mission-store.ts | 142 ++++++++++------ .../dashboard/app/components/MissionManager.tsx | 108 ++++++++---- .../MissionManager.validation-rollup.test.tsx | 181 +++++++++++++++++++++ 6 files changed, 416 insertions(+), 87 deletions(-) Fusion-Task-Id: FN-8719 Fusion-Task-Lineage: c505211c-935a-4ad3-b35d-81c486e56121 Co-authored-by: Fusion (runfusion.ai) --- .../fn-8719-milestone-validation-rollup.md | 7 + docs/missions.md | 4 + .../postgres/mission-store.pg.test.ts | 61 ++++++ packages/core/src/async-mission-store.ts | 142 +++++++++----- .../app/components/MissionManager.tsx | 108 +++++++---- .../MissionManager.validation-rollup.test.tsx | 181 ++++++++++++++++++ 6 files changed, 416 insertions(+), 87 deletions(-) create mode 100644 .changeset/fn-8719-milestone-validation-rollup.md create mode 100644 packages/dashboard/app/components/__tests__/MissionManager.validation-rollup.test.tsx diff --git a/.changeset/fn-8719-milestone-validation-rollup.md b/.changeset/fn-8719-milestone-validation-rollup.md new file mode 100644 index 0000000000..b2efd66df3 --- /dev/null +++ b/.changeset/fn-8719-milestone-validation-rollup.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Keep milestone validation badges current after assertion repairs or removals. +category: fix +dev: Reconciles project-scoped PostgreSQL rollups before events and discards stale dashboard refresh responses by milestone generation. diff --git a/docs/missions.md b/docs/missions.md index 62282854ff..ec4b93eec1 100644 --- a/docs/missions.md +++ b/docs/missions.md @@ -492,6 +492,10 @@ interface MilestoneValidationRollup { 5. `failed` — at least one assertion failed 6. `blocked` — at least one assertion is blocked +**Current-state reconciliation:** after every successful assertion create, repair, removal, or feature-link change, the PostgreSQL store recomputes this rollup from current assertions, persists the resulting `milestones.validationState` within the same project partition, then emits the validation refresh event. A repaired final failure therefore cannot leave a persisted `failed` badge behind; a remaining failed assertion still wins the current rollup. + +**Dashboard refresh freshness:** rollup and validation-telemetry requests share one monotonically increasing generation per milestone. A response writes badge/panel state only when its captured generation is still current, including initial selection, expansion, mutation refreshes, and SSE events. This is request ordering, not validation-state precedence: a newer response is allowed to legitimately transition a milestone back to `failed`. + #### Completion Gate Contract Canonical authored feature criteria live on `MissionFeature.acceptanceCriteria`, and each feature validator derives its verdict only from its **linked feature-scoped assertions**. Validator prompts list each authoritative assertion ID in brackets; responses must return exactly one result keyed by each listed ID. To recover older model output safely, only an exact-count response with zero recognized IDs is matched positionally and recorded in diagnostics. Partial matches, duplicate IDs, and count mismatches remain fail-closed. Model summary prose, milestone prose, and behavioral results that are not mapped to a linked behavioral assertion cannot override that verdict. diff --git a/packages/core/src/__tests__/postgres/mission-store.pg.test.ts b/packages/core/src/__tests__/postgres/mission-store.pg.test.ts index 401785c52a..170f2cf4e7 100644 --- a/packages/core/src/__tests__/postgres/mission-store.pg.test.ts +++ b/packages/core/src/__tests__/postgres/mission-store.pg.test.ts @@ -35,6 +35,7 @@ import { listMilestones as listMilestoneRows, listMissionEvents, listMissions as listMissionRows, + updateMilestoneValidationState, } from "../../async-mission-store.js"; import { BUILTIN_CODING_WORKFLOW_IR } from "../../index.js"; @@ -113,6 +114,13 @@ pgTest("MissionStore (PostgreSQL backend mode)", () => { expect(projectB.events.map(({ description }) => description)).toEqual(["Project B event"]); expect(await listMissionRows(db)).toEqual([]); + await db.transaction(async (tx) => { + await tx.execute(sql`SELECT set_config('fusion.project_id', 'project-a', true)`); + await updateMilestoneValidationState(tx, "MS-SHARED", "failed"); + }); + expect((await readProject("project-a")).milestones[0]?.validationState).toBe("failed"); + expect((await readProject("project-b")).milestones[0]?.validationState).toBe("not_started"); + await db.transaction(async (tx) => { await tx.execute(sql`SELECT set_config('fusion.project_id', 'project-a', true)`); expect(await deleteMissionRow(tx, "M-SHARED")).toBe(true); @@ -683,6 +691,59 @@ pgTest("MissionStore (PostgreSQL backend mode)", () => { expect(list.find((a) => a.id === created.id)!.assertion).toBe("GET /x returns 200"); }); + it("reconciles repaired and deleted failed assertions before publishing validation updates", async () => { + const m = missions(); + const mission = await m.createMission({ title: "Current assertion rollup" }); + const milestone = await m.addMilestone(mission.id, { title: "MS" }); + const validationEvents: Array<{ state: string; rollup: { state: string } }> = []; + m.on("milestone:validation:updated", (payload) => { + if (payload.milestoneId === milestone.id) validationEvents.push(payload); + }); + const failed = await m.addContractAssertion(milestone.id, { + title: "Repair me", assertion: "works", status: "failed", scope: "milestone", + }); + + const expectCurrentState = async (state: "ready" | "passed" | "blocked" | "not_started") => { + expect((await m.getMilestoneValidationRollup(milestone.id)).state).toBe(state); + expect((await m.getMilestone(milestone.id))?.validationState).toBe(state); + expect(validationEvents.at(-1)).toMatchObject({ state, rollup: { state } }); + }; + + expect(await m.getMilestoneValidationRollup(milestone.id)).toMatchObject({ state: "failed", failedAssertions: 1 }); + expect((await m.getMilestone(milestone.id))?.validationState).toBe("failed"); + await m.updateContractAssertion(failed.id, { status: "pending" }); + await expectCurrentState("ready"); + await m.updateContractAssertion(failed.id, { status: "passed" }); + await expectCurrentState("passed"); + await m.updateContractAssertion(failed.id, { status: "blocked" }); + await expectCurrentState("blocked"); + await m.deleteContractAssertion(failed.id); + await expectCurrentState("not_started"); + + const remainingFailure = await m.addContractAssertion(milestone.id, { + title: "Still failing", assertion: "fails", status: "failed", scope: "milestone", + }); + const repairedFailure = await m.addContractAssertion(milestone.id, { + title: "Repairable", assertion: "also fails", status: "failed", scope: "milestone", + }); + await m.updateContractAssertion(repairedFailure.id, { status: "passed" }); + expect(await m.getMilestoneValidationRollup(milestone.id)).toMatchObject({ state: "failed", failedAssertions: 1 }); + expect((await m.getMilestone(milestone.id))?.validationState).toBe("failed"); + await m.deleteContractAssertion(remainingFailure.id); + + const concurrentFirst = await m.addContractAssertion(milestone.id, { + title: "Concurrent first", assertion: "first fails", status: "failed", scope: "milestone", + }); + const concurrentSecond = await m.addContractAssertion(milestone.id, { + title: "Concurrent second", assertion: "second fails", status: "failed", scope: "milestone", + }); + await Promise.all([ + m.updateContractAssertion(concurrentFirst.id, { status: "passed" }), + m.updateContractAssertion(concurrentSecond.id, { status: "passed" }), + ]); + await expectCurrentState("passed"); + }); + it("startValidatorRun is returned by getValidatorRunsByFeature", async () => { const m = missions(); const mission = await m.createMission({ title: "Validated" }); diff --git a/packages/core/src/async-mission-store.ts b/packages/core/src/async-mission-store.ts index f6536926e1..d4301159cc 100644 --- a/packages/core/src/async-mission-store.ts +++ b/packages/core/src/async-mission-store.ts @@ -1958,38 +1958,38 @@ export class AsyncMissionStore extends EventEmitter { // ════════════════ CONTRACT ASSERTIONS ════════════════ async addContractAssertion(milestoneId: string, input: ContractAssertionCreateInput): Promise { - const milestone = await getMilestone(this.db, milestoneId); - if (!milestone) throw new Error(`Milestone ${milestoneId} not found`); const origin = input.origin ?? "authored"; - const existing = await listContractAssertions(this.db, milestoneId); - if (origin === "derived_milestone_acceptance" - && existing.some((assertion) => assertion.origin === "derived_milestone_acceptance")) { - /* - FNXC:MissionValidation 2026-07-23-17:20: - Reject duplicate canonical provenance before insert; PostgreSQL also - enforces this at rest, while authored/imported rows stay non-unique. - */ - throw new Error(`Milestone ${milestoneId} already has a derived milestone acceptance assertion`); - } - const now = new Date().toISOString(); - const orderIndex = existing.length > 0 ? Math.max(...existing.map((a) => a.orderIndex)) + 1 : 0; - const assertion: MissionContractAssertion = { - id: this.generateId("CA"), - milestoneId, - sourceFeatureId: input.sourceFeatureId, - scope: input.scope ?? "feature", - origin, - title: input.title, - assertion: input.assertion, - status: input.status || "pending", - type: normalizeMissionAssertionType(input.type), - orderIndex, - createdAt: now, - updatedAt: now, - }; - const created = await createContractAssertion(this.db, assertion); + const created = await this.mutateMilestoneAssertions(milestoneId, async (tx) => { + const milestone = await getMilestone(tx, milestoneId); + if (!milestone) throw new Error(`Milestone ${milestoneId} not found`); + const existing = await listContractAssertions(tx, milestoneId); + if (origin === "derived_milestone_acceptance" + && existing.some((assertion) => assertion.origin === "derived_milestone_acceptance")) { + /* + FNXC:MissionValidation 2026-07-23-17:20: + Reject duplicate canonical provenance before insert; PostgreSQL also + enforces this at rest, while authored/imported rows stay non-unique. + */ + throw new Error(`Milestone ${milestoneId} already has a derived milestone acceptance assertion`); + } + const now = new Date().toISOString(); + const orderIndex = existing.length > 0 ? Math.max(...existing.map((a) => a.orderIndex)) + 1 : 0; + return createContractAssertion(tx, { + id: this.generateId("CA"), + milestoneId, + sourceFeatureId: input.sourceFeatureId, + scope: input.scope ?? "feature", + origin, + title: input.title, + assertion: input.assertion, + status: input.status || "pending", + type: normalizeMissionAssertionType(input.type), + orderIndex, + createdAt: now, + updatedAt: now, + }); + }); this.emit("assertion:created", created); - await this.recomputeMilestoneValidation(milestoneId); return created; } @@ -2004,26 +2004,32 @@ export class AsyncMissionStore extends EventEmitter { async updateContractAssertion(id: string, updates: ContractAssertionUpdateInput): Promise { const assertion = await getContractAssertion(this.db, id); if (!assertion) throw new Error(`Assertion ${id} not found`); - const updated: MissionContractAssertion = { - ...assertion, - title: updates.title ?? assertion.title, - assertion: updates.assertion ?? assertion.assertion, - status: updates.status ?? assertion.status, - updatedAt: new Date().toISOString(), - }; - await updateContractAssertion(this.db, updated); + const updated = await this.mutateMilestoneAssertions(assertion.milestoneId, async (tx) => { + const current = await getContractAssertion(tx, id); + if (!current) throw new Error(`Assertion ${id} not found`); + const next: MissionContractAssertion = { + ...current, + title: updates.title ?? current.title, + assertion: updates.assertion ?? current.assertion, + status: updates.status ?? current.status, + updatedAt: new Date().toISOString(), + }; + await updateContractAssertion(tx, next); + return next; + }); this.emit("assertion:updated", updated); - await this.recomputeMilestoneValidation(updated.milestoneId); return updated; } async deleteContractAssertion(id: string): Promise { const assertion = await getContractAssertion(this.db, id); if (!assertion) throw new Error(`Assertion ${id} not found`); - const milestoneId = assertion.milestoneId; - await deleteContractAssertion(this.db, id); + await this.mutateMilestoneAssertions(assertion.milestoneId, async (tx) => { + const current = await getContractAssertion(tx, id); + if (!current) throw new Error(`Assertion ${id} not found`); + await deleteContractAssertion(tx, id); + }); this.emit("assertion:deleted", id); - await this.recomputeMilestoneValidation(milestoneId); } async reorderContractAssertions(milestoneId: string, orderedIds: string[]): Promise { @@ -2049,8 +2055,8 @@ export class AsyncMissionStore extends EventEmitter { throw new Error(`Feature ${featureId} is already linked to assertion ${assertionId}`); } await linkFeatureToAssertion(this.db, featureId, assertionId, new Date().toISOString()); - this.emit("assertion:linked", { featureId, assertionId }); await this.recomputeMilestoneValidation(assertion.milestoneId); + this.emit("assertion:linked", { featureId, assertionId }); } async unlinkFeatureFromAssertion(featureId: string, assertionId: string): Promise { @@ -2058,9 +2064,9 @@ export class AsyncMissionStore extends EventEmitter { throw new Error(`Feature ${featureId} is not linked to assertion ${assertionId}`); } await unlinkFeatureFromAssertion(this.db, featureId, assertionId); - this.emit("assertion:unlinked", { featureId, assertionId }); const assertion = await getContractAssertion(this.db, assertionId); if (assertion) await this.recomputeMilestoneValidation(assertion.milestoneId); + this.emit("assertion:unlinked", { featureId, assertionId }); } async listAssertionsForFeature(featureId: string): Promise { @@ -2159,15 +2165,15 @@ export class AsyncMissionStore extends EventEmitter { } // ════════════════ VALIDATION ROLLUP ════════════════ - async getMilestoneValidationRollup(milestoneId: string): Promise { - const milestone = await getMilestone(this.db, milestoneId); + async getMilestoneValidationRollup(milestoneId: string, handle: QueryHandle = this.db): Promise { + const milestone = await getMilestone(handle, milestoneId); if (!milestone) throw new Error(`Milestone ${milestoneId} not found`); - const assertions = await listContractAssertions(this.db, milestoneId); + const assertions = await listContractAssertions(handle, milestoneId); const totalAssertions = assertions.length; const proseOnMilestone = (milestone.acceptanceCriteria ?? "").trim().length > 0; const [milestoneFeatures, linkedAssertionIds] = await Promise.all([ - listFeaturesForMilestone(this.db, milestoneId), - listLinkedAssertionIds(this.db, assertions.map((assertion) => assertion.id)), + listFeaturesForMilestone(handle, milestoneId), + listLinkedAssertionIds(handle, assertions.map((assertion) => assertion.id)), ]); const proseOnFeatures = milestoneFeatures.some((feature) => (feature.acceptanceCriteria ?? "").trim().length > 0); const hasProseButNoAssertions = totalAssertions === 0 && (proseOnMilestone || proseOnFeatures); @@ -2496,10 +2502,38 @@ export class AsyncMissionStore extends EventEmitter { if (mission && mission.status !== newStatus) await this.updateMission(missionId, { status: newStatus }); } + /* + FNXC:MilestoneValidationReconciliation 2026-08-01-20:42: + Assertion mutations must persist the authoritative current rollup before they publish refresh events. This keeps an operator repair or final-failure deletion from exposing a stale failed milestone state to SSE consumers. + */ private async recomputeMilestoneValidation(milestoneId: string): Promise { - const rollup = await this.getMilestoneValidationRollup(milestoneId); - await updateMilestoneValidationState(this.db, milestoneId, rollup.state); + await this.mutateMilestoneAssertions(milestoneId, async () => undefined); + } + + /* + FNXC:MilestoneValidationReconciliation 2026-08-01-21:02: + Assertion writes and their denormalized milestone rollup share one project-scoped + advisory transaction lock. PostgreSQL READ COMMITTED alone permits two repairs to + publish snapshots in reverse order; the lock makes the committed current rollup + the only state emitted to dashboard refresh consumers. + */ + private async mutateMilestoneAssertions( + milestoneId: string, + mutation: (tx: QueryHandle) => Promise, + ): Promise { + let result!: T; + let rollup!: MilestoneValidationRollup; + await this.layer.transactionImmediate(async (tx) => { + await tx.execute(sql`SELECT pg_advisory_xact_lock(hashtextextended( + CONCAT('mission-validation:', COALESCE(NULLIF(current_setting('fusion.project_id', true), ''), '__legacy_unscoped__'), ':', CAST(${milestoneId} AS text)), + 0 + ))`); + result = await mutation(tx); + rollup = await this.getMilestoneValidationRollup(milestoneId, tx); + await updateMilestoneValidationState(tx, milestoneId, rollup.state); + }); this.emit("milestone:validation:updated", { milestoneId, state: rollup.state, rollup }); + return result; } private deriveFeatureAssertion(feature: MissionFeature): { assertionText: string; textSource: MissionAssertionTextSource } { @@ -2581,5 +2615,9 @@ export async function updateMilestoneValidationState( await handle .update(schema.project.milestones) .set({ validationState: state, updatedAt: new Date().toISOString() }) - .where(eq(schema.project.milestones.id, milestoneId)); + // FNXC:MilestoneValidationReconciliation 2026-08-01-20:42: Shared PostgreSQL milestone IDs must never let one project's validation repair overwrite another project's rollup. + .where(and( + eq(schema.project.milestones.projectId, missionProjectId()), + eq(schema.project.milestones.id, milestoneId), + )); } diff --git a/packages/dashboard/app/components/MissionManager.tsx b/packages/dashboard/app/components/MissionManager.tsx index 90efd0d32f..b8812f9d32 100644 --- a/packages/dashboard/app/components/MissionManager.tsx +++ b/packages/dashboard/app/components/MissionManager.tsx @@ -194,6 +194,25 @@ const validationStateColors: Record = { }; const featureRetryBudgetMax = 3; + +/** + * FNXC:MilestoneValidationFreshness 2026-08-01-20:42: + * Every rollup and telemetry response for a milestone shares this request generation. Only the newest request may update a badge, including when the newest legitimate state regresses to failed. + */ +export class MilestoneValidationFreshnessCoordinator { + private readonly generations = new Map(); + + begin(milestoneId: string): number { + const generation = (this.generations.get(milestoneId) ?? 0) + 1; + this.generations.set(milestoneId, generation); + return generation; + } + + isCurrent(milestoneId: string, generation: number): boolean { + return this.generations.get(milestoneId) === generation; + } +} + const missionInterviewListStatuses: ReadonlySet = new Set([ "generating", "awaiting_input", @@ -986,6 +1005,8 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr const missionsRef = useRef([]); const selectedMissionRef = useRef(null); const selectedMilestoneIdRef = useRef(null); + // FNXC:MilestoneValidationFreshness 2026-08-01-20:42: Rollup and telemetry responses share one per-milestone generation so an older request cannot restore a repaired failed badge, while a newer failure remains valid. + const validationRequestGenerationRef = useRef(new MilestoneValidationFreshnessCoordinator()); const activeTabRef = useRef<"structure" | "activity">("structure"); const eventsFilterRef = useRef<"all" | "errors" | "state_changes" | "tasks" | "slices" | "autopilot">("all"); const [eventsLoading, setEventsLoading] = useState(false); @@ -1023,6 +1044,27 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr activeTabRef.current = activeTab; eventsFilterRef.current = eventsFilter; + const beginValidationRequest = useCallback((milestoneId: string): number => + validationRequestGenerationRef.current.begin(milestoneId), []); + + const isCurrentValidationRequest = useCallback((milestoneId: string, generation: number): boolean => + validationRequestGenerationRef.current.isCurrent(milestoneId, generation), []); + + const loadValidationRollup = useCallback(async (milestoneId: string) => { + const generation = beginValidationRequest(milestoneId); + try { + const rollup = await fetchMilestoneValidation(milestoneId, projectId); + if (!isCurrentValidationRequest(milestoneId, generation)) return; + setValidationRollupByMilestone((prev) => { + const next = new Map(prev); + next.set(milestoneId, rollup); + return next; + }); + } catch { + // Silently fail + } + }, [beginValidationRequest, isCurrentValidationRequest, projectId]); + const scrollActivityToLatest = useCallback((behavior: ScrollBehavior = "auto") => { const endNode = activityEventsEndRef.current; if (endNode && typeof endNode.scrollIntoView === "function") { @@ -1152,13 +1194,7 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr // Load assertions and validation rollup for the selected milestone. void loadAssertionsForMilestone(nextSelectedMilestoneId); - fetchMilestoneValidation(nextSelectedMilestoneId, projectId).then((rollup) => { - setValidationRollupByMilestone((prev) => { - const next = new Map(prev); - next.set(nextSelectedMilestoneId, rollup); - return next; - }); - }).catch(() => { /* silently fail */ }); + void loadValidationRollup(nextSelectedMilestoneId); } else { setSelectedMilestoneId(null); setValidationTelemetry(null); @@ -1169,7 +1205,7 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr } finally { setDetailLoading(false); } - }, [addToast, loadAssertionsForMilestone, projectId]); + }, [addToast, loadAssertionsForMilestone, loadValidationRollup, projectId]); useEffect(() => { let cancelled = false; @@ -1213,15 +1249,23 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr } let cancelled = false; + const generation = beginValidationRequest(selectedMilestoneId); setValidationTelemetry(null); fetchMilestoneValidationTelemetry(selectedMilestoneId, projectId) .then((telemetry) => { - if (cancelled) { + if (cancelled || !isCurrentValidationRequest(selectedMilestoneId, generation)) { return; } if (!isMilestoneValidationTelemetry(telemetry)) { setValidationTelemetry(null); + /* + FNXC:MilestoneValidationFreshness 2026-08-01-21:17: + Telemetry is optional, but its request still supersedes every shared badge writer. + Renew the authoritative rollup when telemetry is absent so the discarded older rollup + cannot leave a repaired milestone's previous failed badge in the map. + */ + void loadValidationRollup(selectedMilestoneId); return; } @@ -1233,15 +1277,16 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr }); }) .catch(() => { - if (!cancelled) { + if (!cancelled && isCurrentValidationRequest(selectedMilestoneId, generation)) { setValidationTelemetry(null); + void loadValidationRollup(selectedMilestoneId); } }); return () => { cancelled = true; }; - }, [isActive, selectedMilestoneId, projectId]); + }, [beginValidationRequest, isActive, isCurrentValidationRequest, loadValidationRollup, selectedMilestoneId, projectId]); useEffect(() => { setValidationRoundsExpanded(true); @@ -1263,10 +1308,17 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr if (!milestoneId || milestoneId !== selectedMilestoneIdRef.current) { return; } + const generation = beginValidationRequest(milestoneId); void fetchMilestoneValidationTelemetry(milestoneId, projectId) .then((telemetry) => { - if (selectedMilestoneIdRef.current !== milestoneId || !isMilestoneValidationTelemetry(telemetry)) { + if (selectedMilestoneIdRef.current !== milestoneId + || !isCurrentValidationRequest(milestoneId, generation)) { + return; + } + if (!isMilestoneValidationTelemetry(telemetry)) { + setValidationTelemetry(null); + void loadValidationRollup(milestoneId); return; } setValidationTelemetry(telemetry); @@ -1277,9 +1329,14 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr }); }) .catch(() => { - // Silently fail - telemetry is supplemental + // Telemetry is supplemental, but the shared generation requires a fresh rollup fallback. + if (selectedMilestoneIdRef.current === milestoneId + && isCurrentValidationRequest(milestoneId, generation)) { + setValidationTelemetry(null); + void loadValidationRollup(milestoneId); + } }); - }, [projectId]); + }, [beginValidationRequest, isCurrentValidationRequest, loadValidationRollup, projectId]); const loadMissionEvents = useCallback(async ( missionId: string, @@ -1908,19 +1965,13 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr next.add(milestoneId); // Load assertions and validation rollup when expanding milestone void loadAssertionsForMilestone(milestoneId); - fetchMilestoneValidation(milestoneId, projectId).then((rollup) => { - setValidationRollupByMilestone((prev) => { - const next = new Map(prev); - next.set(milestoneId, rollup); - return next; - }); - }).catch(() => { /* silently fail */ }); + void loadValidationRollup(milestoneId); } else { next.delete(milestoneId); } return next; }); - }, [loadAssertionsForMilestone, projectId]); + }, [loadAssertionsForMilestone, loadValidationRollup]); // Slice handlers const handleCreateSlice = useCallback((milestoneId: string) => { @@ -2176,19 +2227,6 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr // ── Assertion handlers ── - const loadValidationRollup = useCallback(async (milestoneId: string) => { - try { - const rollup = await fetchMilestoneValidation(milestoneId, projectId); - setValidationRollupByMilestone((prev) => { - const next = new Map(prev); - next.set(milestoneId, rollup); - return next; - }); - } catch { - // Silently fail - } - }, [projectId]); - const handleCreateAssertion = useCallback(async (milestoneId: string) => { if (!assertionForm.title.trim() || !assertionForm.assertion.trim()) { addToast(t("missions.assertionFieldsRequired", "Title and assertion text are required"), "error"); diff --git a/packages/dashboard/app/components/__tests__/MissionManager.validation-rollup.test.tsx b/packages/dashboard/app/components/__tests__/MissionManager.validation-rollup.test.tsx new file mode 100644 index 0000000000..7b9e9ef3af --- /dev/null +++ b/packages/dashboard/app/components/__tests__/MissionManager.validation-rollup.test.tsx @@ -0,0 +1,181 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { render, screen, waitFor } from "@testing-library/react"; +import { MissionManager, MilestoneValidationFreshnessCoordinator } from "../MissionManager"; + +const mockFetchMissions = vi.fn(); +const mockFetchMission = vi.fn(); +const mockFetchMissionsHealth = vi.fn(); +const mockFetchAssertions = vi.fn(); +const mockFetchMilestoneValidation = vi.fn(); +const mockFetchMilestoneValidationTelemetry = vi.fn(); +const mockFetchAiSessions = vi.fn(); +const mockFetchMissionInterviewDrafts = vi.fn(); + +vi.mock("../../hooks/useNavigationHistory", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useNavigationHistoryContext: () => ({ pushNav: vi.fn(), replaceCurrent: vi.fn() }), + }; +}); + +vi.mock("../../api", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + fetchMissions: (...args: unknown[]) => mockFetchMissions(...args), + fetchMission: (...args: unknown[]) => mockFetchMission(...args), + fetchMissionsHealth: (...args: unknown[]) => mockFetchMissionsHealth(...args), + fetchAssertions: (...args: unknown[]) => mockFetchAssertions(...args), + fetchMilestoneValidation: (...args: unknown[]) => mockFetchMilestoneValidation(...args), + fetchMilestoneValidationTelemetry: (...args: unknown[]) => mockFetchMilestoneValidationTelemetry(...args), + fetchAiSessions: (...args: unknown[]) => mockFetchAiSessions(...args), + fetchMissionInterviewDrafts: (...args: unknown[]) => mockFetchMissionInterviewDrafts(...args), + }; +}); + +const failedRollup = { + milestoneId: "MS-1", + totalAssertions: 1, + passedAssertions: 0, + failedAssertions: 1, + blockedAssertions: 0, + pendingAssertions: 0, + unlinkedAssertions: 0, + hasProseButNoAssertions: false, + state: "failed" as const, +}; + +const passedRollup = { + ...failedRollup, + passedAssertions: 1, + failedAssertions: 0, + state: "passed" as const, +}; + +const mission = { + id: "M-1", + title: "Validation mission", + description: "", + status: "active", + interviewState: "completed", + createdAt: "2026-08-01T00:00:00.000Z", + updatedAt: "2026-08-01T00:00:00.000Z", + milestones: [{ + id: "MS-1", + missionId: "M-1", + title: "Validated milestone", + status: "active", + interviewState: "completed", + orderIndex: 0, + dependencies: [], + slices: [], + createdAt: "2026-08-01T00:00:00.000Z", + updatedAt: "2026-08-01T00:00:00.000Z", + }], +}; + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((nextResolve) => { resolve = nextResolve; }); + return { promise, resolve }; +} + +function setViewport(width: number) { + Object.defineProperty(window, "innerWidth", { value: width, configurable: true }); + Object.defineProperty(window, "matchMedia", { + configurable: true, + writable: true, + value: vi.fn().mockImplementation((query: string) => ({ + matches: query.includes("max-width: 768px") ? width <= 768 : false, + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })), + }); +} + +describe("MilestoneValidationFreshnessCoordinator", () => { + beforeEach(() => { + vi.clearAllMocks(); + localStorage.clear(); + mockFetchMissions.mockResolvedValue([{ ...mission, milestones: [] }]); + mockFetchMission.mockResolvedValue(mission); + mockFetchMissionsHealth.mockResolvedValue({}); + mockFetchAssertions.mockResolvedValue([]); + mockFetchAiSessions.mockResolvedValue([]); + mockFetchMissionInterviewDrafts.mockResolvedValue([]); + }); + + it("keeps the newer SSE refresh authoritative when an older failed response settles last", () => { + const coordinator = new MilestoneValidationFreshnessCoordinator(); + const oldRequest = coordinator.begin("MS-1"); + const sseRequest = coordinator.begin("MS-1"); + + expect(coordinator.isCurrent("MS-1", sseRequest)).toBe(true); + expect(coordinator.isCurrent("MS-1", oldRequest)).toBe(false); + }); + + it.each([ + ["desktop", 1280], + ["mobile", 375], + ])("renders the repaired fallback rollup at both badge sites on %s after an older failed response settles", async (_surface, width) => { + setViewport(width); + const oldRollup = deferred(); + const repairedRollup = deferred(); + mockFetchMilestoneValidation + .mockReturnValueOnce(oldRollup.promise) + .mockReturnValueOnce(repairedRollup.promise); + // The newer telemetry request intentionally has no payload, requiring the component fallback. + mockFetchMilestoneValidationTelemetry.mockResolvedValue(undefined); + + render( + {}} + addToast={() => {}} + projectId="p1" + targetMissionId="M-1" + />, + ); + + await waitFor(() => expect(mockFetchMilestoneValidationTelemetry).toHaveBeenCalledWith("MS-1", "p1")); + await waitFor(() => expect(mockFetchMilestoneValidation).toHaveBeenCalledTimes(2)); + + repairedRollup.resolve(passedRollup); + await waitFor(() => { + expect(screen.getByTitle("Validation state")).toHaveTextContent("Passed"); + expect(screen.getAllByText("Passed")).toHaveLength(2); + }); + + oldRollup.resolve(failedRollup); + await waitFor(() => { + expect(screen.getByTitle("Validation state")).toHaveTextContent("Passed"); + expect(screen.getAllByText("Passed")).toHaveLength(2); + expect(screen.queryByText("Failed")).not.toBeInTheDocument(); + }); + }); + + it("permits a subsequent newest-generation transition back to failed", () => { + const coordinator = new MilestoneValidationFreshnessCoordinator(); + const repairedRequest = coordinator.begin("MS-1"); + const laterFailureRequest = coordinator.begin("MS-1"); + + expect(coordinator.isCurrent("MS-1", repairedRequest)).toBe(false); + expect(coordinator.isCurrent("MS-1", laterFailureRequest)).toBe(true); + }); + + it("isolates concurrent milestone refreshes", () => { + const coordinator = new MilestoneValidationFreshnessCoordinator(); + const firstMilestone = coordinator.begin("MS-1"); + const secondMilestone = coordinator.begin("MS-2"); + + expect(coordinator.isCurrent("MS-1", firstMilestone)).toBe(true); + expect(coordinator.isCurrent("MS-2", secondMilestone)).toBe(true); + }); +});