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
|
5. `failed` — at least one assertion failed
|
||||||
6. `blocked` — at least one assertion is blocked
|
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
|
#### 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.
|
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,
|
listMilestones as listMilestoneRows,
|
||||||
listMissionEvents,
|
listMissionEvents,
|
||||||
listMissions as listMissionRows,
|
listMissions as listMissionRows,
|
||||||
|
updateMilestoneValidationState,
|
||||||
} from "../../async-mission-store.js";
|
} from "../../async-mission-store.js";
|
||||||
import { BUILTIN_CODING_WORKFLOW_IR } from "../../index.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(projectB.events.map(({ description }) => description)).toEqual(["Project B event"]);
|
||||||
expect(await listMissionRows(db)).toEqual([]);
|
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 db.transaction(async (tx) => {
|
||||||
await tx.execute(sql`SELECT set_config('fusion.project_id', 'project-a', true)`);
|
await tx.execute(sql`SELECT set_config('fusion.project_id', 'project-a', true)`);
|
||||||
expect(await deleteMissionRow(tx, "M-SHARED")).toBe(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");
|
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 () => {
|
it("startValidatorRun is returned by getValidatorRunsByFeature", async () => {
|
||||||
const m = missions();
|
const m = missions();
|
||||||
const mission = await m.createMission({ title: "Validated" });
|
const mission = await m.createMission({ title: "Validated" });
|
||||||
|
|||||||
@@ -1958,38 +1958,38 @@ export class AsyncMissionStore extends EventEmitter<MissionStoreEvents> {
|
|||||||
|
|
||||||
// ════════════════ CONTRACT ASSERTIONS ════════════════
|
// ════════════════ CONTRACT ASSERTIONS ════════════════
|
||||||
async addContractAssertion(milestoneId: string, input: ContractAssertionCreateInput): Promise<MissionContractAssertion> {
|
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 origin = input.origin ?? "authored";
|
||||||
const existing = await listContractAssertions(this.db, milestoneId);
|
const created = await this.mutateMilestoneAssertions(milestoneId, async (tx) => {
|
||||||
if (origin === "derived_milestone_acceptance"
|
const milestone = await getMilestone(tx, milestoneId);
|
||||||
&& existing.some((assertion) => assertion.origin === "derived_milestone_acceptance")) {
|
if (!milestone) throw new Error(`Milestone ${milestoneId} not found`);
|
||||||
/*
|
const existing = await listContractAssertions(tx, milestoneId);
|
||||||
FNXC:MissionValidation 2026-07-23-17:20:
|
if (origin === "derived_milestone_acceptance"
|
||||||
Reject duplicate canonical provenance before insert; PostgreSQL also
|
&& existing.some((assertion) => assertion.origin === "derived_milestone_acceptance")) {
|
||||||
enforces this at rest, while authored/imported rows stay non-unique.
|
/*
|
||||||
*/
|
FNXC:MissionValidation 2026-07-23-17:20:
|
||||||
throw new Error(`Milestone ${milestoneId} already has a derived milestone acceptance assertion`);
|
Reject duplicate canonical provenance before insert; PostgreSQL also
|
||||||
}
|
enforces this at rest, while authored/imported rows stay non-unique.
|
||||||
const now = new Date().toISOString();
|
*/
|
||||||
const orderIndex = existing.length > 0 ? Math.max(...existing.map((a) => a.orderIndex)) + 1 : 0;
|
throw new Error(`Milestone ${milestoneId} already has a derived milestone acceptance assertion`);
|
||||||
const assertion: MissionContractAssertion = {
|
}
|
||||||
id: this.generateId("CA"),
|
const now = new Date().toISOString();
|
||||||
milestoneId,
|
const orderIndex = existing.length > 0 ? Math.max(...existing.map((a) => a.orderIndex)) + 1 : 0;
|
||||||
sourceFeatureId: input.sourceFeatureId,
|
return createContractAssertion(tx, {
|
||||||
scope: input.scope ?? "feature",
|
id: this.generateId("CA"),
|
||||||
origin,
|
milestoneId,
|
||||||
title: input.title,
|
sourceFeatureId: input.sourceFeatureId,
|
||||||
assertion: input.assertion,
|
scope: input.scope ?? "feature",
|
||||||
status: input.status || "pending",
|
origin,
|
||||||
type: normalizeMissionAssertionType(input.type),
|
title: input.title,
|
||||||
orderIndex,
|
assertion: input.assertion,
|
||||||
createdAt: now,
|
status: input.status || "pending",
|
||||||
updatedAt: now,
|
type: normalizeMissionAssertionType(input.type),
|
||||||
};
|
orderIndex,
|
||||||
const created = await createContractAssertion(this.db, assertion);
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
});
|
||||||
|
});
|
||||||
this.emit("assertion:created", created);
|
this.emit("assertion:created", created);
|
||||||
await this.recomputeMilestoneValidation(milestoneId);
|
|
||||||
return created;
|
return created;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2004,26 +2004,32 @@ export class AsyncMissionStore extends EventEmitter<MissionStoreEvents> {
|
|||||||
async updateContractAssertion(id: string, updates: ContractAssertionUpdateInput): Promise<MissionContractAssertion> {
|
async updateContractAssertion(id: string, updates: ContractAssertionUpdateInput): Promise<MissionContractAssertion> {
|
||||||
const assertion = await getContractAssertion(this.db, id);
|
const assertion = await getContractAssertion(this.db, id);
|
||||||
if (!assertion) throw new Error(`Assertion ${id} not found`);
|
if (!assertion) throw new Error(`Assertion ${id} not found`);
|
||||||
const updated: MissionContractAssertion = {
|
const updated = await this.mutateMilestoneAssertions(assertion.milestoneId, async (tx) => {
|
||||||
...assertion,
|
const current = await getContractAssertion(tx, id);
|
||||||
title: updates.title ?? assertion.title,
|
if (!current) throw new Error(`Assertion ${id} not found`);
|
||||||
assertion: updates.assertion ?? assertion.assertion,
|
const next: MissionContractAssertion = {
|
||||||
status: updates.status ?? assertion.status,
|
...current,
|
||||||
updatedAt: new Date().toISOString(),
|
title: updates.title ?? current.title,
|
||||||
};
|
assertion: updates.assertion ?? current.assertion,
|
||||||
await updateContractAssertion(this.db, updated);
|
status: updates.status ?? current.status,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
await updateContractAssertion(tx, next);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
this.emit("assertion:updated", updated);
|
this.emit("assertion:updated", updated);
|
||||||
await this.recomputeMilestoneValidation(updated.milestoneId);
|
|
||||||
return updated;
|
return updated;
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteContractAssertion(id: string): Promise<void> {
|
async deleteContractAssertion(id: string): Promise<void> {
|
||||||
const assertion = await getContractAssertion(this.db, id);
|
const assertion = await getContractAssertion(this.db, id);
|
||||||
if (!assertion) throw new Error(`Assertion ${id} not found`);
|
if (!assertion) throw new Error(`Assertion ${id} not found`);
|
||||||
const milestoneId = assertion.milestoneId;
|
await this.mutateMilestoneAssertions(assertion.milestoneId, async (tx) => {
|
||||||
await deleteContractAssertion(this.db, id);
|
const current = await getContractAssertion(tx, id);
|
||||||
|
if (!current) throw new Error(`Assertion ${id} not found`);
|
||||||
|
await deleteContractAssertion(tx, id);
|
||||||
|
});
|
||||||
this.emit("assertion:deleted", id);
|
this.emit("assertion:deleted", id);
|
||||||
await this.recomputeMilestoneValidation(milestoneId);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async reorderContractAssertions(milestoneId: string, orderedIds: string[]): Promise<void> {
|
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}`);
|
throw new Error(`Feature ${featureId} is already linked to assertion ${assertionId}`);
|
||||||
}
|
}
|
||||||
await linkFeatureToAssertion(this.db, featureId, assertionId, new Date().toISOString());
|
await linkFeatureToAssertion(this.db, featureId, assertionId, new Date().toISOString());
|
||||||
this.emit("assertion:linked", { featureId, assertionId });
|
|
||||||
await this.recomputeMilestoneValidation(assertion.milestoneId);
|
await this.recomputeMilestoneValidation(assertion.milestoneId);
|
||||||
|
this.emit("assertion:linked", { featureId, assertionId });
|
||||||
}
|
}
|
||||||
|
|
||||||
async unlinkFeatureFromAssertion(featureId: string, assertionId: string): Promise<void> {
|
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}`);
|
throw new Error(`Feature ${featureId} is not linked to assertion ${assertionId}`);
|
||||||
}
|
}
|
||||||
await unlinkFeatureFromAssertion(this.db, featureId, assertionId);
|
await unlinkFeatureFromAssertion(this.db, featureId, assertionId);
|
||||||
this.emit("assertion:unlinked", { featureId, assertionId });
|
|
||||||
const assertion = await getContractAssertion(this.db, assertionId);
|
const assertion = await getContractAssertion(this.db, assertionId);
|
||||||
if (assertion) await this.recomputeMilestoneValidation(assertion.milestoneId);
|
if (assertion) await this.recomputeMilestoneValidation(assertion.milestoneId);
|
||||||
|
this.emit("assertion:unlinked", { featureId, assertionId });
|
||||||
}
|
}
|
||||||
|
|
||||||
async listAssertionsForFeature(featureId: string): Promise<MissionContractAssertion[]> {
|
async listAssertionsForFeature(featureId: string): Promise<MissionContractAssertion[]> {
|
||||||
@@ -2159,15 +2165,15 @@ export class AsyncMissionStore extends EventEmitter<MissionStoreEvents> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ════════════════ VALIDATION ROLLUP ════════════════
|
// ════════════════ VALIDATION ROLLUP ════════════════
|
||||||
async getMilestoneValidationRollup(milestoneId: string): Promise<MilestoneValidationRollup> {
|
async getMilestoneValidationRollup(milestoneId: string, handle: QueryHandle = this.db): Promise<MilestoneValidationRollup> {
|
||||||
const milestone = await getMilestone(this.db, milestoneId);
|
const milestone = await getMilestone(handle, milestoneId);
|
||||||
if (!milestone) throw new Error(`Milestone ${milestoneId} not found`);
|
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 totalAssertions = assertions.length;
|
||||||
const proseOnMilestone = (milestone.acceptanceCriteria ?? "").trim().length > 0;
|
const proseOnMilestone = (milestone.acceptanceCriteria ?? "").trim().length > 0;
|
||||||
const [milestoneFeatures, linkedAssertionIds] = await Promise.all([
|
const [milestoneFeatures, linkedAssertionIds] = await Promise.all([
|
||||||
listFeaturesForMilestone(this.db, milestoneId),
|
listFeaturesForMilestone(handle, milestoneId),
|
||||||
listLinkedAssertionIds(this.db, assertions.map((assertion) => assertion.id)),
|
listLinkedAssertionIds(handle, assertions.map((assertion) => assertion.id)),
|
||||||
]);
|
]);
|
||||||
const proseOnFeatures = milestoneFeatures.some((feature) => (feature.acceptanceCriteria ?? "").trim().length > 0);
|
const proseOnFeatures = milestoneFeatures.some((feature) => (feature.acceptanceCriteria ?? "").trim().length > 0);
|
||||||
const hasProseButNoAssertions = totalAssertions === 0 && (proseOnMilestone || proseOnFeatures);
|
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 });
|
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> {
|
private async recomputeMilestoneValidation(milestoneId: string): Promise<void> {
|
||||||
const rollup = await this.getMilestoneValidationRollup(milestoneId);
|
await this.mutateMilestoneAssertions(milestoneId, async () => undefined);
|
||||||
await updateMilestoneValidationState(this.db, milestoneId, rollup.state);
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
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 });
|
this.emit("milestone:validation:updated", { milestoneId, state: rollup.state, rollup });
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
private deriveFeatureAssertion(feature: MissionFeature): { assertionText: string; textSource: MissionAssertionTextSource } {
|
private deriveFeatureAssertion(feature: MissionFeature): { assertionText: string; textSource: MissionAssertionTextSource } {
|
||||||
@@ -2581,5 +2615,9 @@ export async function updateMilestoneValidationState(
|
|||||||
await handle
|
await handle
|
||||||
.update(schema.project.milestones)
|
.update(schema.project.milestones)
|
||||||
.set({ validationState: state, updatedAt: new Date().toISOString() })
|
.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;
|
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([
|
const missionInterviewListStatuses: ReadonlySet<AiSessionSummary["status"]> = new Set([
|
||||||
"generating",
|
"generating",
|
||||||
"awaiting_input",
|
"awaiting_input",
|
||||||
@@ -986,6 +1005,8 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
|
|||||||
const missionsRef = useRef<MissionWithSummary[]>([]);
|
const missionsRef = useRef<MissionWithSummary[]>([]);
|
||||||
const selectedMissionRef = useRef<MissionWithHierarchy | null>(null);
|
const selectedMissionRef = useRef<MissionWithHierarchy | null>(null);
|
||||||
const selectedMilestoneIdRef = useRef<string | 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 activeTabRef = useRef<"structure" | "activity">("structure");
|
||||||
const eventsFilterRef = useRef<"all" | "errors" | "state_changes" | "tasks" | "slices" | "autopilot">("all");
|
const eventsFilterRef = useRef<"all" | "errors" | "state_changes" | "tasks" | "slices" | "autopilot">("all");
|
||||||
const [eventsLoading, setEventsLoading] = useState(false);
|
const [eventsLoading, setEventsLoading] = useState(false);
|
||||||
@@ -1023,6 +1044,27 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
|
|||||||
activeTabRef.current = activeTab;
|
activeTabRef.current = activeTab;
|
||||||
eventsFilterRef.current = eventsFilter;
|
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 scrollActivityToLatest = useCallback((behavior: ScrollBehavior = "auto") => {
|
||||||
const endNode = activityEventsEndRef.current;
|
const endNode = activityEventsEndRef.current;
|
||||||
if (endNode && typeof endNode.scrollIntoView === "function") {
|
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.
|
// Load assertions and validation rollup for the selected milestone.
|
||||||
void loadAssertionsForMilestone(nextSelectedMilestoneId);
|
void loadAssertionsForMilestone(nextSelectedMilestoneId);
|
||||||
fetchMilestoneValidation(nextSelectedMilestoneId, projectId).then((rollup) => {
|
void loadValidationRollup(nextSelectedMilestoneId);
|
||||||
setValidationRollupByMilestone((prev) => {
|
|
||||||
const next = new Map(prev);
|
|
||||||
next.set(nextSelectedMilestoneId, rollup);
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
}).catch(() => { /* silently fail */ });
|
|
||||||
} else {
|
} else {
|
||||||
setSelectedMilestoneId(null);
|
setSelectedMilestoneId(null);
|
||||||
setValidationTelemetry(null);
|
setValidationTelemetry(null);
|
||||||
@@ -1169,7 +1205,7 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
|
|||||||
} finally {
|
} finally {
|
||||||
setDetailLoading(false);
|
setDetailLoading(false);
|
||||||
}
|
}
|
||||||
}, [addToast, loadAssertionsForMilestone, projectId]);
|
}, [addToast, loadAssertionsForMilestone, loadValidationRollup, projectId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
@@ -1213,15 +1249,23 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
|
|||||||
}
|
}
|
||||||
|
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
const generation = beginValidationRequest(selectedMilestoneId);
|
||||||
setValidationTelemetry(null);
|
setValidationTelemetry(null);
|
||||||
|
|
||||||
fetchMilestoneValidationTelemetry(selectedMilestoneId, projectId)
|
fetchMilestoneValidationTelemetry(selectedMilestoneId, projectId)
|
||||||
.then((telemetry) => {
|
.then((telemetry) => {
|
||||||
if (cancelled) {
|
if (cancelled || !isCurrentValidationRequest(selectedMilestoneId, generation)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!isMilestoneValidationTelemetry(telemetry)) {
|
if (!isMilestoneValidationTelemetry(telemetry)) {
|
||||||
setValidationTelemetry(null);
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1233,15 +1277,16 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
|
|||||||
});
|
});
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
if (!cancelled) {
|
if (!cancelled && isCurrentValidationRequest(selectedMilestoneId, generation)) {
|
||||||
setValidationTelemetry(null);
|
setValidationTelemetry(null);
|
||||||
|
void loadValidationRollup(selectedMilestoneId);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [isActive, selectedMilestoneId, projectId]);
|
}, [beginValidationRequest, isActive, isCurrentValidationRequest, loadValidationRollup, selectedMilestoneId, projectId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setValidationRoundsExpanded(true);
|
setValidationRoundsExpanded(true);
|
||||||
@@ -1263,10 +1308,17 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
|
|||||||
if (!milestoneId || milestoneId !== selectedMilestoneIdRef.current) {
|
if (!milestoneId || milestoneId !== selectedMilestoneIdRef.current) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const generation = beginValidationRequest(milestoneId);
|
||||||
|
|
||||||
void fetchMilestoneValidationTelemetry(milestoneId, projectId)
|
void fetchMilestoneValidationTelemetry(milestoneId, projectId)
|
||||||
.then((telemetry) => {
|
.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;
|
return;
|
||||||
}
|
}
|
||||||
setValidationTelemetry(telemetry);
|
setValidationTelemetry(telemetry);
|
||||||
@@ -1277,9 +1329,14 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
|
|||||||
});
|
});
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.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 (
|
const loadMissionEvents = useCallback(async (
|
||||||
missionId: string,
|
missionId: string,
|
||||||
@@ -1908,19 +1965,13 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
|
|||||||
next.add(milestoneId);
|
next.add(milestoneId);
|
||||||
// Load assertions and validation rollup when expanding milestone
|
// Load assertions and validation rollup when expanding milestone
|
||||||
void loadAssertionsForMilestone(milestoneId);
|
void loadAssertionsForMilestone(milestoneId);
|
||||||
fetchMilestoneValidation(milestoneId, projectId).then((rollup) => {
|
void loadValidationRollup(milestoneId);
|
||||||
setValidationRollupByMilestone((prev) => {
|
|
||||||
const next = new Map(prev);
|
|
||||||
next.set(milestoneId, rollup);
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
}).catch(() => { /* silently fail */ });
|
|
||||||
} else {
|
} else {
|
||||||
next.delete(milestoneId);
|
next.delete(milestoneId);
|
||||||
}
|
}
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
}, [loadAssertionsForMilestone, projectId]);
|
}, [loadAssertionsForMilestone, loadValidationRollup]);
|
||||||
|
|
||||||
// Slice handlers
|
// Slice handlers
|
||||||
const handleCreateSlice = useCallback((milestoneId: string) => {
|
const handleCreateSlice = useCallback((milestoneId: string) => {
|
||||||
@@ -2176,19 +2227,6 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
|
|||||||
|
|
||||||
// ── Assertion handlers ──
|
// ── 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) => {
|
const handleCreateAssertion = useCallback(async (milestoneId: string) => {
|
||||||
if (!assertionForm.title.trim() || !assertionForm.assertion.trim()) {
|
if (!assertionForm.title.trim() || !assertionForm.assertion.trim()) {
|
||||||
addToast(t("missions.assertionFieldsRequired", "Title and assertion text are required"), "error");
|
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