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) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-8719-milestone-validation-rollup.md
Normal file
7
.changeset/fn-8719-milestone-validation-rollup.md
Normal file
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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" });
|
||||
|
||||
@@ -1958,38 +1958,38 @@ export class AsyncMissionStore extends EventEmitter<MissionStoreEvents> {
|
||||
|
||||
// ════════════════ CONTRACT ASSERTIONS ════════════════
|
||||
async addContractAssertion(milestoneId: string, input: ContractAssertionCreateInput): Promise<MissionContractAssertion> {
|
||||
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<MissionStoreEvents> {
|
||||
async updateContractAssertion(id: string, updates: ContractAssertionUpdateInput): Promise<MissionContractAssertion> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
@@ -2049,8 +2055,8 @@ export class AsyncMissionStore extends EventEmitter<MissionStoreEvents> {
|
||||
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<void> {
|
||||
@@ -2058,9 +2064,9 @@ export class AsyncMissionStore extends EventEmitter<MissionStoreEvents> {
|
||||
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<MissionContractAssertion[]> {
|
||||
@@ -2159,15 +2165,15 @@ export class AsyncMissionStore extends EventEmitter<MissionStoreEvents> {
|
||||
}
|
||||
|
||||
// ════════════════ VALIDATION ROLLUP ════════════════
|
||||
async getMilestoneValidationRollup(milestoneId: string): Promise<MilestoneValidationRollup> {
|
||||
const milestone = await getMilestone(this.db, milestoneId);
|
||||
async getMilestoneValidationRollup(milestoneId: string, handle: QueryHandle = this.db): Promise<MilestoneValidationRollup> {
|
||||
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<MissionStoreEvents> {
|
||||
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<void> {
|
||||
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<T>(
|
||||
milestoneId: string,
|
||||
mutation: (tx: QueryHandle) => Promise<T>,
|
||||
): Promise<T> {
|
||||
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),
|
||||
));
|
||||
}
|
||||
|
||||
@@ -194,6 +194,25 @@ const validationStateColors: Record<string, { bg: string; text: string }> = {
|
||||
};
|
||||
|
||||
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<string, number>();
|
||||
|
||||
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<AiSessionSummary["status"]> = new Set([
|
||||
"generating",
|
||||
"awaiting_input",
|
||||
@@ -986,6 +1005,8 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
|
||||
const missionsRef = useRef<MissionWithSummary[]>([]);
|
||||
const selectedMissionRef = useRef<MissionWithHierarchy | null>(null);
|
||||
const selectedMilestoneIdRef = useRef<string | null>(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");
|
||||
|
||||
@@ -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<typeof import("../../hooks/useNavigationHistory")>();
|
||||
return {
|
||||
...actual,
|
||||
useNavigationHistoryContext: () => ({ pushNav: vi.fn(), replaceCurrent: vi.fn() }),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../api", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../../api")>();
|
||||
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<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((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<typeof failedRollup>();
|
||||
const repairedRollup = deferred<typeof passedRollup>();
|
||||
mockFetchMilestoneValidation
|
||||
.mockReturnValueOnce(oldRollup.promise)
|
||||
.mockReturnValueOnce(repairedRollup.promise);
|
||||
// The newer telemetry request intentionally has no payload, requiring the component fallback.
|
||||
mockFetchMilestoneValidationTelemetry.mockResolvedValue(undefined);
|
||||
|
||||
render(
|
||||
<MissionManager
|
||||
isInline
|
||||
isOpen
|
||||
onClose={() => {}}
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user