feat(FN-1569): add mission validation telemetry APIs and UI

- Emit fix-feature mission events and forward validator telemetry through mission execution
- Add core and dashboard API types plus a validation telemetry route in mission-routes
- Update SSE mission event handling so validation telemetry updates stream reliably to clients
- Add telemetry surfaces in MissionManager with corresponding mission-specific UI styling
- Expand MissionManager and mission e2e test coverage for validation telemetry flows
This commit is contained in:
Fusion
2026-04-16 23:33:05 -07:00
committed by gsxdsm
parent ee4e60413c
commit db611765e4
11 changed files with 1242 additions and 78 deletions

View File

@@ -22,6 +22,8 @@ import type {
MissionHealth,
MissionContractAssertion,
ContractAssertionCreateInput,
MissionValidatorRun,
MissionAssertionFailureRecord,
} from "@fusion/core";
import type { AiSessionRow } from "./ai-session-store.js";
import {
@@ -45,6 +47,8 @@ function createMockMissionStore() {
const missionEvents: Map<string, MissionEvent[]> = new Map();
const assertions: Map<string, MissionContractAssertion> = new Map();
const assertionLinks: Array<{ featureId: string; assertionId: string }> = [];
const validatorRuns: Map<string, MissionValidatorRun> = new Map();
const runFailures: Map<string, MissionAssertionFailureRecord[]> = new Map();
let missionCounter = 1;
let milestoneCounter = 1;
@@ -421,6 +425,39 @@ function createMockMissionStore() {
assertionLinks.push({ featureId, assertionId });
}),
listAssertionsForFeature: vi.fn((featureId: string) =>
assertionLinks
.filter((link) => link.featureId === featureId)
.map((link) => assertions.get(link.assertionId))
.filter((assertion): assertion is MissionContractAssertion => Boolean(assertion))
),
listFeaturesForAssertion: vi.fn((assertionId: string) =>
assertionLinks
.filter((link) => link.assertionId === assertionId)
.map((link) => features.get(link.featureId))
.filter((feature): feature is MissionFeature => Boolean(feature))
),
getValidatorRunsByFeature: vi.fn((featureId: string) =>
Array.from(validatorRuns.values())
.filter((run) => run.featureId === featureId)
.sort((a, b) => b.startedAt.localeCompare(a.startedAt))
),
getFailuresForRun: vi.fn((runId: string) => runFailures.get(runId) ?? []),
getMilestoneValidationRollup: vi.fn((milestoneId: string) => ({
milestoneId,
totalAssertions: 0,
passedAssertions: 0,
failedAssertions: 0,
blockedAssertions: 0,
pendingAssertions: 0,
unlinkedAssertions: 0,
state: "not_started",
})),
reorderMilestones: vi.fn((missionId: string, orderedIds: string[]) => {
orderedIds.forEach((id, index) => {
const milestone = milestones.get(id);
@@ -4316,6 +4353,172 @@ describe("Mission API", () => {
expect(res.status).toBe(404);
});
});
describe("GET /milestones/:milestoneId/validation-telemetry", () => {
it("returns empty grouped telemetry for milestones without assertions or runs", async () => {
const { app, missionStore } = buildApp();
const ms = missionStore as ReturnType<typeof createMockMissionStore>;
const mission = ms.createMission({ title: "Telemetry Mission" });
const milestone = ms.addMilestone(mission.id, { title: "Milestone A" });
const res = await get(app, `/api/missions/milestones/${milestone.id}/validation-telemetry`);
expect(res.status).toBe(200);
expect(res.body.validationContract.assertions).toEqual([]);
expect(res.body.validationContract.featureFulfillment).toEqual({});
expect(res.body.validationTelemetry.validationRounds).toEqual([]);
expect(res.body.validationTelemetry.lastValidatorStatus).toBeNull();
expect(res.body.validationTelemetry.totalRuns).toBe(0);
expect(res.body.fixFeatures).toEqual([]);
expect(res.body.rollup.state).toBe("not_started");
});
it("returns contract assertions and feature fulfillment links", async () => {
const { app, missionStore } = buildApp();
const ms = missionStore as ReturnType<typeof createMockMissionStore>;
const mission = ms.createMission({ title: "Contract Mission" });
const milestone = ms.addMilestone(mission.id, { title: "Milestone B" });
const slice = ms.addSlice(milestone.id, { title: "Slice B" });
const featureOne = ms.addFeature(slice.id, { title: "Feature One" });
const featureTwo = ms.addFeature(slice.id, { title: "Feature Two" });
const assertionOne = ms.addContractAssertion(milestone.id, {
title: "Assertion One",
assertion: "Feature one must pass",
});
const assertionTwo = ms.addContractAssertion(milestone.id, {
title: "Assertion Two",
assertion: "Feature two must pass",
});
ms.linkFeatureToAssertion(featureOne.id, assertionOne.id);
ms.linkFeatureToAssertion(featureTwo.id, assertionTwo.id);
const res = await get(app, `/api/missions/milestones/${milestone.id}/validation-telemetry`);
expect(res.status).toBe(200);
expect(res.body.validationContract.assertions).toHaveLength(2);
expect(res.body.validationContract.assertions).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: assertionOne.id,
title: assertionOne.title,
assertion: assertionOne.assertion,
status: assertionOne.status,
}),
expect.objectContaining({
id: assertionTwo.id,
title: assertionTwo.title,
assertion: assertionTwo.assertion,
status: assertionTwo.status,
}),
])
);
expect(res.body.validationContract.featureFulfillment[featureOne.id]).toEqual({
assertionIds: [assertionOne.id],
featureTitle: featureOne.title,
featureStatus: featureOne.status,
});
expect(res.body.validationContract.featureFulfillment[featureTwo.id]).toEqual({
assertionIds: [assertionTwo.id],
featureTitle: featureTwo.title,
featureStatus: featureTwo.status,
});
});
it("returns validator rounds and generated fix-feature lineage", async () => {
const { app, missionStore } = buildApp();
const ms = missionStore as ReturnType<typeof createMockMissionStore>;
const mission = ms.createMission({ title: "Validation Mission" });
const milestone = ms.addMilestone(mission.id, { title: "Milestone C" });
const slice = ms.addSlice(milestone.id, { title: "Slice C" });
const sourceFeature = ms.addFeature(slice.id, { title: "Source Feature" });
const fixFeature = ms.addFeature(slice.id, { title: "Fix Feature" });
const assertion = ms.addContractAssertion(milestone.id, {
title: "Fails assertion",
assertion: "Must not regress",
});
ms.updateFeature(fixFeature.id, {
generatedFromFeatureId: sourceFeature.id,
generatedFromRunId: "VR-FAILED-001",
});
(missionStore.getValidatorRunsByFeature as ReturnType<typeof vi.fn>).mockImplementation((featureId: string) => {
if (featureId !== sourceFeature.id) {
return [];
}
return [
{
id: "VR-FAILED-001",
featureId: sourceFeature.id,
milestoneId: milestone.id,
sliceId: slice.id,
status: "failed",
implementationAttempt: 2,
validatorAttempt: 2,
startedAt: "2026-04-16T12:00:00.000Z",
completedAt: "2026-04-16T12:02:00.000Z",
createdAt: "2026-04-16T12:00:00.000Z",
updatedAt: "2026-04-16T12:02:00.000Z",
},
] as MissionValidatorRun[];
});
(missionStore.getFailuresForRun as ReturnType<typeof vi.fn>).mockImplementation((runId: string) => {
if (runId !== "VR-FAILED-001") {
return [];
}
return [
{
id: "VAF-001",
runId: "VR-FAILED-001",
featureId: sourceFeature.id,
assertionId: assertion.id,
message: "Assertion failed",
createdAt: "2026-04-16T12:01:00.000Z",
},
] as MissionAssertionFailureRecord[];
});
const res = await get(app, `/api/missions/milestones/${milestone.id}/validation-telemetry`);
expect(res.status).toBe(200);
expect(res.body.validationTelemetry.validationRounds).toEqual(
expect.arrayContaining([
expect.objectContaining({
roundId: "VR-FAILED-001",
validatorStatus: "failed",
failedAssertionIds: [assertion.id],
generatedFixFeatureIds: [fixFeature.id],
}),
])
);
expect(res.body.fixFeatures).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: fixFeature.id,
sourceFeatureId: sourceFeature.id,
runId: "VR-FAILED-001",
failedAssertionIds: [assertion.id],
}),
])
);
});
it("returns 404 when milestone does not exist", async () => {
const { app } = buildApp();
const res = await get(app, "/api/missions/milestones/MS-MISSING-TST/validation-telemetry");
expect(res.status).toBe(404);
expect(res.body.error).toBe("Milestone not found");
});
});
});
/**

View File

@@ -32,6 +32,7 @@ import type {
InterviewState,
MissionAssertionStatus,
FeatureLoopState,
ValidatorRunStatus,
ContractAssertionCreateInput,
ContractAssertionUpdateInput,
} from "@fusion/core";
@@ -1869,6 +1870,150 @@ export function createMissionRouter(
})
);
/**
* GET /api/missions/milestones/:milestoneId/validation-telemetry
*
* Returns grouped milestone validation data by combining:
* - Contract assertions (`listContractAssertions`) and per-feature assertion links (`listAssertionsForFeature`)
* - Validator run rounds (`getValidatorRunsByFeature`) and failures (`getFailuresForRun`)
* - Generated fix feature lineage (features with `generatedFromFeatureId` and `generatedFromRunId`)
* - Milestone validation rollup (`getMilestoneValidationRollup`)
*/
router.get(
"/milestones/:milestoneId/validation-telemetry",
catchTypedHandler(async (req, res) => {
const { milestoneId } = req.params;
if (!validateMilestoneId(milestoneId)) {
throw badRequest("Invalid milestone ID format");
}
const milestone = missionStore.getMilestone(milestoneId);
if (!milestone) {
throw notFound("Milestone not found");
}
const assertions = missionStore.listContractAssertions(milestoneId);
const slices = missionStore.listSlices(milestoneId);
const allFeatures: MissionFeature[] = slices.flatMap((slice) => missionStore.listFeatures(slice.id));
const featureFulfillment: Record<string, {
assertionIds: string[];
featureTitle: string;
featureStatus: string;
}> = {};
for (const feature of allFeatures) {
const linkedAssertions = missionStore.listAssertionsForFeature(feature.id);
featureFulfillment[feature.id] = {
assertionIds: linkedAssertions.map((assertion) => assertion.id),
featureTitle: feature.title,
featureStatus: feature.status,
};
}
const generatedFixFeatureIdsByRunId = new Map<string, string[]>();
for (const feature of allFeatures) {
const runId = feature.generatedFromRunId;
if (!runId) continue;
const existing = generatedFixFeatureIdsByRunId.get(runId) ?? [];
existing.push(feature.id);
generatedFixFeatureIdsByRunId.set(runId, existing);
}
const failedAssertionIdsByRunId = new Map<string, string[]>();
const validationRounds = [] as Array<{
roundId: string;
featureId: string;
featureTitle: string;
validatorStatus: ValidatorRunStatus;
implementationAttempt: number;
validatorAttempt: number;
failedAssertionIds: string[];
generatedFixFeatureIds: string[];
blockedReason?: string;
startedAt: string;
completedAt?: string;
}>;
for (const feature of allFeatures) {
const runs = missionStore.getValidatorRunsByFeature(feature.id);
for (const run of runs) {
let failedAssertionIds: string[] = [];
if (run.status === "failed") {
failedAssertionIds = missionStore
.getFailuresForRun(run.id)
.map((failure) => failure.assertionId);
failedAssertionIdsByRunId.set(run.id, failedAssertionIds);
}
validationRounds.push({
roundId: run.id,
featureId: run.featureId,
featureTitle: feature.title,
validatorStatus: run.status,
implementationAttempt: run.implementationAttempt,
validatorAttempt: run.validatorAttempt,
failedAssertionIds,
generatedFixFeatureIds: generatedFixFeatureIdsByRunId.get(run.id) ?? [],
blockedReason: run.blockedReason,
startedAt: run.startedAt,
completedAt: run.completedAt,
});
}
}
validationRounds.sort((a, b) => b.startedAt.localeCompare(a.startedAt));
const lastValidatorStatus = validationRounds[0]?.validatorStatus ?? null;
const fixFeatures = allFeatures
.filter((feature) => feature.generatedFromFeatureId && feature.generatedFromRunId)
.map((feature) => {
const runId = feature.generatedFromRunId as string;
let failedAssertionIds = failedAssertionIdsByRunId.get(runId);
if (!failedAssertionIds) {
failedAssertionIds = missionStore
.getFailuresForRun(runId)
.map((failure) => failure.assertionId);
failedAssertionIdsByRunId.set(runId, failedAssertionIds);
}
return {
id: feature.id,
title: feature.title,
sourceFeatureId: feature.generatedFromFeatureId as string,
runId,
failedAssertionIds,
status: feature.status,
loopState: feature.loopState,
};
});
const rollup = missionStore.getMilestoneValidationRollup(milestoneId);
res.json({
validationContract: {
assertions: assertions.map((assertion) => ({
id: assertion.id,
title: assertion.title,
assertion: assertion.assertion,
status: assertion.status,
orderIndex: assertion.orderIndex,
})),
featureFulfillment,
},
validationTelemetry: {
validationRounds,
lastValidatorStatus,
totalRuns: validationRounds.length,
},
fixFeatures,
rollup,
});
})
);
// ── Validation & Loop State Endpoints ─────────────────────────────────────
/**

View File

@@ -1,5 +1,15 @@
import type { Request, Response } from "express";
import type { TaskStore, MissionStore, PluginStore, PluginInstallation, PluginState, AgentStore, MessageStore } from "@fusion/core";
import type {
TaskStore,
MissionStore,
PluginStore,
PluginInstallation,
PluginState,
AgentStore,
MessageStore,
MissionValidatorRun,
FixFeatureCreatedPayload,
} from "@fusion/core";
import type { AiSessionStore } from "./ai-session-store.js";
let activeConnections = 0;
@@ -289,6 +299,18 @@ export function createSSE(
send(`event: milestone:validation:updated\ndata: ${JSON.stringify(data)}\n\n`);
};
const onValidatorRunStarted = (run: MissionValidatorRun) => {
send(`event: validator-run:started\ndata: ${JSON.stringify(run)}\n\n`);
};
const onValidatorRunCompleted = (run: MissionValidatorRun) => {
send(`event: validator-run:completed\ndata: ${JSON.stringify(run)}\n\n`);
};
const onFixFeatureCreated = (payload: FixFeatureCreatedPayload) => {
send(`event: fix-feature:created\ndata: ${JSON.stringify(payload)}\n\n`);
};
const onAiSessionUpdated = (data: any) => {
send(`event: ai_session:updated\ndata: ${JSON.stringify(data)}\n\n`);
};
@@ -402,6 +424,9 @@ export function createSSE(
missionStore.off("assertion:unlinked", onAssertionUnlinked);
missionStore.off("mission:event", onMissionEvent);
missionStore.off("milestone:validation:updated", onMilestoneValidationUpdated);
missionStore.off("validator-run:started", onValidatorRunStarted);
missionStore.off("validator-run:completed", onValidatorRunCompleted);
missionStore.off("fix-feature:created", onFixFeatureCreated);
}
if (aiSessionStore) {
aiSessionStore.off("ai_session:updated", onAiSessionUpdated);
@@ -459,6 +484,9 @@ export function createSSE(
missionStore.on("assertion:unlinked", onAssertionUnlinked);
missionStore.on("mission:event", onMissionEvent);
missionStore.on("milestone:validation:updated", onMilestoneValidationUpdated);
missionStore.on("validator-run:started", onValidatorRunStarted);
missionStore.on("validator-run:completed", onValidatorRunCompleted);
missionStore.on("fix-feature:created", onFixFeatureCreated);
}
if (aiSessionStore) {