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 c0e68f21d8
commit 0d94bc39df
11 changed files with 1242 additions and 78 deletions

View File

@@ -1,4 +1,4 @@
import { useState, useEffect, useCallback, useRef } from "react";
import { useState, useEffect, useCallback, useRef, useMemo } from "react";
import {
X,
Plus,
@@ -49,6 +49,7 @@ import type {
ContractAssertionCreateInput,
ContractAssertionUpdateInput,
MilestoneValidationRollup,
MilestoneValidationTelemetry,
MissionFeatureLoopSnapshot,
MissionValidatorRun,
} from "./mission-types";
@@ -90,6 +91,7 @@ import {
fetchAssertionsForFeature,
fetchFeaturesForAssertion,
fetchMilestoneValidation,
fetchMilestoneValidationTelemetry,
triggerValidation,
fetchValidationLoopState,
fetchValidationRuns,
@@ -170,6 +172,17 @@ const assertionStatusColors: Record<MissionAssertionStatus, { bg: string; text:
blocked: { bg: "var(--assertion-blocked-bg)", text: "var(--assertion-blocked-text)" },
};
const validationStateColors: Record<string, { bg: string; text: string }> = {
not_started: { bg: "var(--assertion-pending-bg)", text: "var(--assertion-pending-text)" },
needs_coverage: { bg: "var(--loop-needs-fix-bg)", text: "var(--loop-needs-fix-text)" },
ready: { bg: "var(--loop-validating-bg)", text: "var(--loop-validating-text)" },
passed: { bg: "var(--loop-passed-bg)", text: "var(--loop-passed-text)" },
failed: { bg: "var(--loop-blocked-bg)", text: "var(--loop-blocked-text)" },
blocked: { bg: "var(--loop-blocked-bg)", text: "var(--loop-blocked-text)" },
};
const featureRetryBudgetMax = 3;
/** Get the plan state for a milestone (derived from interviewState) */
function getMilestonePlanState(interviewState?: string): "not_started" | "planned" | "needs_update" {
if (interviewState === "completed") return "planned";
@@ -338,6 +351,22 @@ function isMissionEvent(value: unknown): value is MissionEvent {
);
}
function isMilestoneValidationTelemetry(value: unknown): value is MilestoneValidationTelemetry {
if (!value || typeof value !== "object") {
return false;
}
const candidate = value as Partial<MilestoneValidationTelemetry>;
return (
typeof candidate.rollup?.milestoneId === "string" &&
typeof candidate.rollup?.state === "string" &&
Array.isArray(candidate.validationTelemetry?.validationRounds) &&
typeof candidate.validationTelemetry?.totalRuns === "number" &&
candidate.validationContract !== undefined &&
Array.isArray(candidate.fixFeatures)
);
}
const TASK_EVENT_TYPES: MissionEventType[] = ["feature_triaged", "feature_completed"];
const SLICE_EVENT_TYPES: MissionEventType[] = ["slice_activated", "slice_completed", "milestone_completed"];
const STATE_CHANGE_EVENT_TYPES: MissionEventType[] = [
@@ -546,6 +575,9 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
const [unlinkingFeatures, setUnlinkingFeatures] = useState<Set<string>>(new Set());
const [featurePickerOpenForAssertion, setFeaturePickerOpenForAssertion] = useState<string | null>(null);
const [validationRollupByMilestone, setValidationRollupByMilestone] = useState<Map<string, MilestoneValidationRollup>>(new Map());
const [selectedMilestoneId, setSelectedMilestoneId] = useState<string | null>(null);
const [validationTelemetry, setValidationTelemetry] = useState<MilestoneValidationTelemetry | null>(null);
const [validationRoundsExpanded, setValidationRoundsExpanded] = useState(true);
const [validatingFeatures, setValidatingFeatures] = useState<Set<string>>(new Set());
// Feature loop state
@@ -570,6 +602,7 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
const missionEventsRef = useRef<MissionEvent[]>([]);
const missionsRef = useRef<MissionWithSummary[]>([]);
const selectedMissionRef = useRef<MissionWithHierarchy | null>(null);
const selectedMilestoneIdRef = useRef<string | null>(null);
const activeTabRef = useRef<"structure" | "activity">("structure");
const eventsFilterRef = useRef<"all" | "errors" | "state_changes" | "tasks" | "slices" | "autopilot">("all");
const [eventsLoading, setEventsLoading] = useState(false);
@@ -585,6 +618,7 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
// Keep latest state available to long-lived SSE handlers without reconnect churn.
missionsRef.current = missions;
selectedMissionRef.current = selectedMission;
selectedMilestoneIdRef.current = selectedMilestoneId;
activeTabRef.current = activeTab;
eventsFilterRef.current = eventsFilter;
@@ -652,6 +686,8 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
// Auto-expand first milestone and slice
if (data.milestones.length > 0) {
const firstMilestoneId = data.milestones[0].id;
setSelectedMilestoneId(firstMilestoneId);
setValidationRoundsExpanded(true);
setExpandedMilestones(new Set([firstMilestoneId]));
// Load assertions and validation rollup for the first milestone (inline to avoid forward ref)
fetchAssertions(firstMilestoneId, projectId).then((assertions) => {
@@ -671,6 +707,9 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
if (data.milestones[0].slices.length > 0) {
setExpandedSlices(new Set([data.milestones[0].slices[0].id]));
}
} else {
setSelectedMilestoneId(null);
setValidationTelemetry(null);
}
} catch (err: any) {
addToast(err.message || "Failed to load mission details", "error");
@@ -679,6 +718,69 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
}
}, [addToast, projectId]);
useEffect(() => {
if (!isActive || !selectedMilestoneId) {
setValidationTelemetry(null);
return;
}
let cancelled = false;
setValidationTelemetry(null);
fetchMilestoneValidationTelemetry(selectedMilestoneId, projectId)
.then((telemetry) => {
if (cancelled) {
return;
}
if (!isMilestoneValidationTelemetry(telemetry)) {
setValidationTelemetry(null);
return;
}
setValidationTelemetry(telemetry);
setValidationRollupByMilestone((prev) => {
const next = new Map(prev);
next.set(selectedMilestoneId, telemetry.rollup);
return next;
});
})
.catch(() => {
if (!cancelled) {
setValidationTelemetry(null);
}
});
return () => {
cancelled = true;
};
}, [isActive, selectedMilestoneId, projectId]);
useEffect(() => {
setValidationRoundsExpanded(true);
}, [selectedMilestoneId]);
const refreshValidationTelemetry = useCallback((milestoneId: string) => {
if (!milestoneId || milestoneId !== selectedMilestoneIdRef.current) {
return;
}
void fetchMilestoneValidationTelemetry(milestoneId, projectId)
.then((telemetry) => {
if (selectedMilestoneIdRef.current !== milestoneId || !isMilestoneValidationTelemetry(telemetry)) {
return;
}
setValidationTelemetry(telemetry);
setValidationRollupByMilestone((prev) => {
const next = new Map(prev);
next.set(milestoneId, telemetry.rollup);
return next;
});
})
.catch(() => {
// Silently fail - telemetry is supplemental
});
}, [projectId]);
const loadMissionEvents = useCallback(async (
missionId: string,
options?: { append?: boolean },
@@ -743,6 +845,8 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
if (isActive) {
loadMissions();
setSelectedMission(null);
setSelectedMilestoneId(null);
setValidationTelemetry(null);
setMissionEvents([]);
setEventsTotal(0);
setActiveTab("structure");
@@ -852,13 +956,16 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
void loadFeatureLoopState(payload.featureId);
// Refresh validation runs
void loadValidationRuns(payload.featureId);
if (payload.milestoneId) {
refreshValidationTelemetry(payload.milestoneId);
}
}
} catch {
// ignore invalid payloads
}
};
// Handler for validator run completed - refresh feature loop state, runs, and mission detail
// Handler for validator run completed - refresh feature loop state, runs, mission detail, and telemetry
const handleValidatorRunCompleted = (rawEvent: Event) => {
const messageEvent = rawEvent as MessageEvent<string>;
if (!messageEvent.data) return;
@@ -869,6 +976,9 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
void loadFeatureLoopState(payload.featureId);
// Refresh validation runs
void loadValidationRuns(payload.featureId);
if (payload.milestoneId) {
refreshValidationTelemetry(payload.milestoneId);
}
// Refresh mission detail to update feature status
if (selectedMissionRef.current) {
void loadMissionDetail(selectedMissionRef.current.id);
@@ -887,6 +997,7 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
const payload = JSON.parse(messageEvent.data);
if (payload && payload.milestoneId) {
void loadValidationRollup(payload.milestoneId);
refreshValidationTelemetry(payload.milestoneId);
}
} catch {
// ignore invalid payloads
@@ -902,6 +1013,7 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
if (payload && payload.milestoneId) {
void loadAssertionsForMilestone(payload.milestoneId);
void loadValidationRollup(payload.milestoneId);
refreshValidationTelemetry(payload.milestoneId);
}
} catch {
// ignore invalid payloads
@@ -917,6 +1029,18 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
if (payload && payload.sourceFeatureId) {
// Refresh feature loop state for the source feature
void loadFeatureLoopState(payload.sourceFeatureId);
const createdFeatureSliceId = payload?.feature?.sliceId as string | undefined;
const selectedMission = selectedMissionRef.current;
if (createdFeatureSliceId && selectedMission) {
const containingMilestone = selectedMission.milestones.find((milestone) =>
milestone.slices.some((slice) => slice.id === createdFeatureSliceId)
);
if (containingMilestone) {
refreshValidationTelemetry(containingMilestone.id);
}
}
// Refresh mission detail to show the new fix feature in the list
if (selectedMissionRef.current) {
void loadMissionDetail(selectedMissionRef.current.id);
@@ -1011,6 +1135,7 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
loadMissionDetail,
loadMissionHealth,
projectId,
refreshValidationTelemetry,
]);
// Mission handlers
@@ -1163,6 +1288,8 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
}, [addToast, loadMissionDetail, selectedMission, projectId]);
const toggleMilestoneExpanded = useCallback((milestoneId: string) => {
setSelectedMilestoneId(milestoneId);
setValidationRoundsExpanded(true);
setExpandedMilestones((prev) => {
const next = new Set(prev);
const isExpanding = !next.has(milestoneId);
@@ -1553,6 +1680,17 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
}
}, [expandedAssertionId, loadLinkedFeaturesForAssertion]);
const focusAssertion = useCallback((assertionId: string) => {
setExpandedAssertionId(assertionId);
void loadLinkedFeaturesForAssertion(assertionId);
requestAnimationFrame(() => {
const assertionElement = document.querySelector(`[data-mission-assertion-id="${assertionId}"]`);
if (assertionElement instanceof HTMLElement && typeof assertionElement.scrollIntoView === "function") {
assertionElement.scrollIntoView({ behavior: "smooth", block: "center" });
}
});
}, [loadLinkedFeaturesForAssertion]);
const handleLinkFeatureToAssertion = useCallback(async (featureId: string, assertionId: string) => {
try {
setLinkingAssertions((prev) => new Set(prev).add(assertionId));
@@ -1641,6 +1779,46 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
}
}, [projectId]);
const focusFeature = useCallback((featureId: string) => {
const mission = selectedMissionRef.current;
if (!mission) {
return;
}
for (const milestone of mission.milestones) {
for (const slice of milestone.slices) {
const targetFeature = slice.features.find((feature) => feature.id === featureId);
if (!targetFeature) {
continue;
}
setExpandedMilestones((prev) => {
const next = new Set(prev);
next.add(milestone.id);
return next;
});
setExpandedSlices((prev) => {
const next = new Set(prev);
next.add(slice.id);
return next;
});
setExpandedFeatureId(featureId);
setSelectedMilestoneId(milestone.id);
void loadFeatureLoopState(featureId);
void loadValidationRuns(featureId);
requestAnimationFrame(() => {
const featureElement = document.querySelector(`[data-mission-feature-id="${featureId}"]`);
if (featureElement instanceof HTMLElement && typeof featureElement.scrollIntoView === "function") {
featureElement.scrollIntoView({ behavior: "smooth", block: "center" });
}
});
return;
}
}
}, [loadFeatureLoopState, loadValidationRuns]);
// Load run detail with failures
const loadRunDetail = useCallback(async (runId: string) => {
try {
@@ -1730,6 +1908,8 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
const handleSelectMission = useCallback((mission: Mission) => {
setActiveTab("structure");
setSelectedMilestoneId(null);
setValidationTelemetry(null);
setMissionEvents([]);
setEventsTotal(0);
setEventsFilter("all");
@@ -1739,6 +1919,8 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
const handleBackToList = useCallback(() => {
setSelectedMission(null);
setSelectedMilestoneId(null);
setValidationTelemetry(null);
setActiveTab("structure");
setMissionEvents([]);
setEventsTotal(0);
@@ -1755,6 +1937,24 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
selectedMission?.lastAutopilotActivityAt,
);
const selectedMilestoneTelemetry = useMemo(() => {
if (!validationTelemetry || !selectedMilestoneId || !isMilestoneValidationTelemetry(validationTelemetry)) {
return null;
}
return validationTelemetry.rollup.milestoneId === selectedMilestoneId ? validationTelemetry : null;
}, [selectedMilestoneId, validationTelemetry]);
const latestRoundsByFeatureId = useMemo(() => {
const roundsByFeature = new Map<string, MilestoneValidationTelemetry["validationTelemetry"]["validationRounds"][number]>();
for (const round of selectedMilestoneTelemetry?.validationTelemetry.validationRounds ?? []) {
const existing = roundsByFeature.get(round.featureId);
if (!existing || round.startedAt > existing.startedAt) {
roundsByFeature.set(round.featureId, round);
}
}
return roundsByFeature;
}, [selectedMilestoneTelemetry]);
const handleLoadMoreEvents = useCallback(() => {
if (!selectedMission || eventsLoading || !hasMoreEvents) {
return;
@@ -2064,7 +2264,21 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
{activeTab === "structure" ? (
<div className="mission-detail__milestones">
{selectedMission.milestones.map((milestone) => (
{selectedMission.milestones.map((milestone) => {
const milestoneTelemetry = selectedMilestoneTelemetry?.rollup.milestoneId === milestone.id
? selectedMilestoneTelemetry
: null;
const milestoneRollup = milestoneTelemetry?.rollup ?? validationRollupByMilestone.get(milestone.id);
const milestoneRounds = milestoneTelemetry?.validationTelemetry.validationRounds ?? [];
const milestoneFixFeatures = milestoneTelemetry?.fixFeatures ?? [];
const milestoneValidationColors = validationStateColors[milestoneRollup?.state ?? "not_started"]
?? validationStateColors.not_started;
const milestoneBlockedReason =
milestoneRollup && (milestoneRollup.state === "blocked" || milestoneRollup.state === "failed")
? milestoneTelemetry?.validationTelemetry.validationRounds.find((round) => round.blockedReason)?.blockedReason
: undefined;
return (
<div key={milestone.id} className="mission-milestone">
<div className="mission-milestone__header" onClick={() => toggleMilestoneExpanded(milestone.id)}>
<button className="mission-milestone__expand">
@@ -2084,40 +2298,28 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
<span className="mission-milestone__count">{milestone.slices.length} slices</span>
<PlanStateIndicator state={getMilestonePlanState(milestone.interviewState)} />
{/* Validation state badge and coverage bar in milestone header */}
{validationRollupByMilestone.get(milestone.id) && (
{milestoneRollup && (
<>
<span
className="mission-status-badge mission-status-badge--sm"
style={{
backgroundColor: validationRollupByMilestone.get(milestone.id)?.state === "passed"
? "var(--loop-passed-bg)"
: validationRollupByMilestone.get(milestone.id)?.state === "failed"
? "var(--loop-blocked-bg)"
: validationRollupByMilestone.get(milestone.id)?.state === "blocked"
? "var(--loop-blocked-bg)"
: "var(--assertion-pending-bg)",
color: validationRollupByMilestone.get(milestone.id)?.state === "passed"
? "var(--loop-passed-text)"
: validationRollupByMilestone.get(milestone.id)?.state === "failed"
? "var(--loop-blocked-text)"
: validationRollupByMilestone.get(milestone.id)?.state === "blocked"
? "var(--loop-blocked-text)"
: "var(--assertion-pending-text)",
backgroundColor: milestoneValidationColors.bg,
color: milestoneValidationColors.text,
}}
title="Validation state"
>
{formatValidationState(validationRollupByMilestone.get(milestone.id)?.state)}
{formatValidationState(milestoneRollup.state)}
</span>
{validationRollupByMilestone.get(milestone.id)!.totalAssertions > 0 && (
{milestoneRollup.totalAssertions > 0 && (
<div
className="mission-milestone__coverage-bar"
title={`${(validationRollupByMilestone.get(milestone.id)!.passedAssertions ?? 0)} of ${validationRollupByMilestone.get(milestone.id)!.totalAssertions} assertions passing`}
title={`${(milestoneRollup.passedAssertions ?? 0)} of ${milestoneRollup.totalAssertions} assertions passing`}
>
<div
className="mission-milestone__coverage-bar-fill"
style={{
width: `${((validationRollupByMilestone.get(milestone.id)!.passedAssertions ?? 0) / validationRollupByMilestone.get(milestone.id)!.totalAssertions) * 100}%`,
backgroundColor: (validationRollupByMilestone.get(milestone.id)!.passedAssertions ?? 0) === validationRollupByMilestone.get(milestone.id)!.totalAssertions
width: `${((milestoneRollup.passedAssertions ?? 0) / milestoneRollup.totalAssertions) * 100}%`,
backgroundColor: (milestoneRollup.passedAssertions ?? 0) === milestoneRollup.totalAssertions
? "var(--color-success)"
: "var(--color-warning)",
}}
@@ -2192,6 +2394,167 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
</div>
)}
{milestoneTelemetry && (
<div className="mission-validation-telemetry">
<div className="mission-validation-telemetry__header">
<span className="mission-validation-telemetry__title">Validation Telemetry</span>
<span className="mission-validation-telemetry__meta">
{milestoneTelemetry.validationTelemetry.totalRuns} rounds
{milestoneTelemetry.validationTelemetry.lastValidatorStatus
? ` · Last ${milestoneTelemetry.validationTelemetry.lastValidatorStatus}`
: ""}
</span>
</div>
{milestoneBlockedReason && (
<div className="mission-blocked-reason">
<strong>Blocked reason:</strong> {milestoneBlockedReason}
</div>
)}
{milestoneRounds.length > 0 && (
<div className="mission-validation-rounds">
<button
className="mission-btn mission-btn--ghost mission-btn--sm mission-validation-rounds__toggle"
onClick={() => setValidationRoundsExpanded((prev) => !prev)}
title={validationRoundsExpanded ? "Hide validation rounds" : "Show validation rounds"}
>
{validationRoundsExpanded ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
Validation rounds ({milestoneRounds.length})
</button>
{validationRoundsExpanded && (
<div className="mission-validation-rounds__list">
{milestoneRounds.map((round) => (
<div key={round.roundId} className="mission-validation-round">
<div className="mission-validation-round__header">
<span className={`mission-status-badge mission-status-badge--sm mission-validation-round__status mission-validation-round__status--${round.validatorStatus}`}>
{round.validatorStatus}
</span>
<span className="mission-validation-round__feature">{round.featureTitle}</span>
<span className="mission-validation-round__attempts">
impl #{round.implementationAttempt} · validator #{round.validatorAttempt}
</span>
</div>
<div className="mission-validation-round__links">
<span className="mission-validation-round__label">Failed assertions:</span>
{round.failedAssertionIds.length > 0 ? (
<div className="mission-validation-round__chip-list">
{round.failedAssertionIds.map((assertionId) => (
<button
key={`${round.roundId}-${assertionId}`}
className="mission-validation-round__link-chip"
onClick={() => focusAssertion(assertionId)}
title={`Jump to assertion ${assertionId}`}
>
{assertionId}
</button>
))}
</div>
) : (
<span className="mission-validation-round__empty">None</span>
)}
</div>
<div className="mission-validation-round__links">
<span className="mission-validation-round__label">Generated fix features:</span>
{round.generatedFixFeatureIds.length > 0 ? (
<div className="mission-validation-round__chip-list">
{round.generatedFixFeatureIds.map((fixFeatureId) => (
<button
key={`${round.roundId}-${fixFeatureId}`}
className="mission-validation-round__link-chip"
onClick={() => focusFeature(fixFeatureId)}
title={`Jump to fix feature ${fixFeatureId}`}
>
{fixFeatureId}
</button>
))}
</div>
) : (
<span className="mission-validation-round__empty">None</span>
)}
</div>
{round.blockedReason && (
<div className="mission-validation-round__blocked-reason">
{round.blockedReason}
</div>
)}
</div>
))}
</div>
)}
</div>
)}
{milestoneFixFeatures.length > 0 && (
<div className="mission-fix-features">
<div className="mission-fix-features__title">Generated Fix Features</div>
<div className="mission-fix-features__list">
{milestoneFixFeatures.map((fixFeature) => (
<div key={fixFeature.id} className="mission-fix-feature">
<div className="mission-fix-feature__header">
<button
className="mission-fix-feature__title"
onClick={() => focusFeature(fixFeature.id)}
title={`Jump to feature ${fixFeature.id}`}
>
{fixFeature.title}
</button>
<span
className="mission-status-badge mission-status-badge--sm"
style={{
backgroundColor: featureStatusColors[fixFeature.status].bg,
color: featureStatusColors[fixFeature.status].text,
}}
>
{fixFeature.status}
</span>
{fixFeature.loopState && (
<span className={`mission-loop-state mission-loop-state--${fixFeature.loopState}`}>
{fixFeature.loopState}
</span>
)}
</div>
<div className="mission-fix-feature__meta">
<span>Source:</span>
<button
className="mission-validation-round__link-chip"
onClick={() => focusFeature(fixFeature.sourceFeatureId)}
title={`Jump to source feature ${fixFeature.sourceFeatureId}`}
>
{fixFeature.sourceFeatureId}
</button>
<span>Run:</span>
<span className="mission-fix-feature__run">{fixFeature.runId}</span>
</div>
{fixFeature.failedAssertionIds.length > 0 && (
<div className="mission-fix-feature__assertions">
<span className="mission-validation-round__label">Failed assertions:</span>
<div className="mission-validation-round__chip-list">
{fixFeature.failedAssertionIds.map((assertionId) => (
<button
key={`${fixFeature.id}-${assertionId}`}
className="mission-validation-round__link-chip"
onClick={() => focusAssertion(assertionId)}
title={`Jump to assertion ${assertionId}`}
>
{assertionId}
</button>
))}
</div>
</div>
)}
</div>
))}
</div>
</div>
)}
</div>
)}
{/* Slices */}
<div className="mission-slices">
{milestone.slices.map((slice) => (
@@ -2338,7 +2701,11 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
{/* Features */}
<div className="mission-features">
{slice.features?.map((feature) => (
<div key={feature.id} className="mission-feature">
<div
key={feature.id}
className="mission-feature"
data-mission-feature-id={feature.id}
>
<div className="mission-feature__header">
<button
className="mission-feature__expand"
@@ -2375,46 +2742,32 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
{feature.generatedFromFeatureId && (
<button
className="mission-feature__lineage"
onClick={() => {
// Navigate to source feature: find its milestone/slice and expand to it
// Find the source feature in the mission hierarchy
for (const m of selectedMission?.milestones ?? []) {
for (const s of m.slices) {
const sourceFeature = s.features.find((f) => f.id === feature.generatedFromFeatureId);
if (sourceFeature) {
setExpandedMilestones((prev) => {
const next = new Set(prev);
next.add(m.id);
return next;
});
setExpandedSlices((prev) => {
const next = new Set(prev);
next.add(s.id);
return next;
});
setExpandedFeatureId(sourceFeature.id);
// Load loop state for source feature
void loadFeatureLoopState(sourceFeature.id);
void loadValidationRuns(sourceFeature.id);
return;
}
}
}
}}
onClick={() => focusFeature(feature.generatedFromFeatureId!)}
title={`Generated from feature: ${feature.generatedFromFeatureId}`}
>
🔗 Fix
</button>
)}
{/* Retry budget display */}
{feature.loopState && feature.loopState !== "idle" && featureLoopStates.get(feature.id) && (
<span
className="mission-feature__retry-budget"
title="Implementation attempts remaining"
>
Attempt {featureLoopStates.get(feature.id)!.implementationAttemptCount} of {featureLoopStates.get(feature.id)!.implementationAttemptCount + featureLoopStates.get(feature.id)!.retryBudgetRemaining}
</span>
)}
{/* Retry/iteration display for validating and needs-fix states */}
{(feature.loopState === "validating" || feature.loopState === "needs_fix") && (() => {
const loopSnapshot = featureLoopStates.get(feature.id);
const latestRound = latestRoundsByFeatureId.get(feature.id);
const implementationAttempt = loopSnapshot?.implementationAttemptCount
?? latestRound?.implementationAttempt
?? feature.implementationAttemptCount
?? 0;
const retryBudgetRemaining = loopSnapshot?.retryBudgetRemaining
?? Math.max(0, featureRetryBudgetMax - implementationAttempt);
return (
<span
className="mission-feature__retry-budget"
title="Implementation attempts and remaining retry budget"
>
Attempt {implementationAttempt} · {retryBudgetRemaining} {retryBudgetRemaining === 1 ? "retry" : "retries"} left
</span>
);
})()}
{/* Validation trigger button for implementing features */}
{feature.loopState === "implementing" && (
<button
@@ -2714,33 +3067,25 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
<div className="mission-assertions">
<div className="mission-assertions__header">
<span className="mission-assertions__title">Assertions</span>
{validationRollupByMilestone.get(milestone.id) && (
{milestoneRollup && (
<span
className="mission-status-badge mission-status-badge--sm"
style={{
backgroundColor: validationRollupByMilestone.get(milestone.id)?.state === "passed"
? "var(--loop-passed-bg)"
: validationRollupByMilestone.get(milestone.id)?.state === "failed"
? "var(--loop-blocked-bg)"
: "var(--assertion-pending-bg)",
color: validationRollupByMilestone.get(milestone.id)?.state === "passed"
? "var(--loop-passed-text)"
: validationRollupByMilestone.get(milestone.id)?.state === "failed"
? "var(--loop-blocked-text)"
: "var(--assertion-pending-text)",
backgroundColor: milestoneValidationColors.bg,
color: milestoneValidationColors.text,
}}
>
{formatValidationState(validationRollupByMilestone.get(milestone.id)?.state)}
{formatValidationState(milestoneRollup.state)}
</span>
)}
{/* Assertion coverage bar */}
{validationRollupByMilestone.get(milestone.id) && validationRollupByMilestone.get(milestone.id)!.totalAssertions > 0 && (
<div className="mission-assertions__coverage-bar" title={`${(validationRollupByMilestone.get(milestone.id)!.passedAssertions ?? 0)} of ${validationRollupByMilestone.get(milestone.id)!.totalAssertions} assertions passing`}>
{milestoneRollup && milestoneRollup.totalAssertions > 0 && (
<div className="mission-assertions__coverage-bar" title={`${(milestoneRollup.passedAssertions ?? 0)} of ${milestoneRollup.totalAssertions} assertions passing`}>
<div
className="mission-assertions__coverage-bar-fill"
style={{
width: `${((validationRollupByMilestone.get(milestone.id)!.passedAssertions ?? 0) / validationRollupByMilestone.get(milestone.id)!.totalAssertions) * 100}%`,
backgroundColor: (validationRollupByMilestone.get(milestone.id)!.passedAssertions ?? 0) === validationRollupByMilestone.get(milestone.id)!.totalAssertions
width: `${((milestoneRollup.passedAssertions ?? 0) / milestoneRollup.totalAssertions) * 100}%`,
backgroundColor: (milestoneRollup.passedAssertions ?? 0) === milestoneRollup.totalAssertions
? "var(--color-success)"
: "var(--color-warning)",
}}
@@ -2800,7 +3145,11 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
{/* Assertions list */}
<div className="mission-assertions__list">
{(Array.isArray(assertionsByMilestone.get(milestone.id)) ? assertionsByMilestone.get(milestone.id)! : [] as MissionContractAssertion[]).map((assertion) => (
<div key={assertion.id} className="mission-assertion">
<div
key={assertion.id}
className="mission-assertion"
data-mission-assertion-id={assertion.id}
>
<div className="mission-assertion__header">
{editingAssertionId === assertion.id ? (
<div className="mission-form-card">
@@ -2972,7 +3321,8 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
</div>
)}
</div>
))}
);
})}
{/* Create milestone button/form */}
{selectedMission && !isCreatingMilestone && editingMilestoneId === null && (

View File

@@ -22,6 +22,7 @@ vi.mock("../../api", async () => {
skipMilestoneInterview: (...args: any[]) => mockSkipMilestoneInterview(...args),
skipSliceInterview: (...args: any[]) => mockSkipSliceInterview(...args),
triageFeature: (...args: any[]) => mockTriageFeature(...args),
fetchMilestoneValidationTelemetry: (...args: any[]) => actual.fetchMilestoneValidationTelemetry(...args),
fetchModels: () => Promise.resolve({ models: [], favoriteProviders: [], favoriteModels: [] }),
};
});
@@ -51,6 +52,7 @@ vi.mock("lucide-react", () => ({
Minimize2: () => <span data-testid="minimize-icon">Minimize2</span>,
Lock: () => <span data-testid="lock-icon">Lock</span>,
RefreshCw: ({ className }: any) => <span data-testid="refresh-icon" className={className}>Refresh</span>,
AlertCircle: () => <span data-testid="alert-circle-icon">AlertCircle</span>,
}));
// Mock data
@@ -133,6 +135,31 @@ const mockAutopilotStatus = {
watched: false,
};
const mockMilestoneValidationRollup = {
milestoneId: "MS-001",
totalAssertions: 0,
passedAssertions: 0,
failedAssertions: 0,
blockedAssertions: 0,
pendingAssertions: 0,
unlinkedAssertions: 0,
state: "not_started",
};
const mockMilestoneValidationTelemetry = {
validationContract: {
assertions: [],
featureFulfillment: {},
},
validationTelemetry: {
validationRounds: [],
lastValidatorStatus: null,
totalRuns: 0,
},
fixFeatures: [],
rollup: mockMilestoneValidationRollup,
};
const mockMissionEvents = [
{
id: "E-001",
@@ -265,6 +292,37 @@ function parseMissionEventsResponse(url: string, events = mockMissionEvents) {
};
}
function getValidationApiMock(url: string): unknown | null {
if (url.includes("/validation-telemetry")) {
return mockMilestoneValidationTelemetry;
}
if (url.includes("/validation-runs")) {
return { runs: [], total: 0, limit: 10, offset: 0 };
}
if (url.includes("/validation-loop")) {
return {
featureId: "F-001",
feature: mockMissionDetail.milestones[0].slices[0].features[0],
loopState: "idle",
implementationAttemptCount: 0,
validatorAttemptCount: 0,
retryBudgetRemaining: 3,
};
}
if (url.includes("/validation")) {
return mockMilestoneValidationRollup;
}
if (url.includes("/assertions")) {
return [];
}
return null;
}
class MockEventSource {
static instances: MockEventSource[] = [];
@@ -321,6 +379,11 @@ function createFetchMock() {
return Promise.resolve(mockApiResponse(mockAutopilotStatus));
}
const validationResponse = getValidationApiMock(url);
if (validationResponse !== null) {
return Promise.resolve(mockApiResponse(validationResponse));
}
if (url.includes("/api/missions/") && !url.includes("/milestones") && !url.includes("/status")) {
return Promise.resolve(mockApiResponse(mockMissionDetail));
}
@@ -350,6 +413,11 @@ function createDetailFetchMock(events = mockMissionEvents) {
return Promise.resolve(mockApiResponse(mockAutopilotStatus));
}
const validationResponse = getValidationApiMock(url);
if (validationResponse !== null) {
return Promise.resolve(mockApiResponse(validationResponse));
}
if (url.includes("/api/missions/") && !url.includes("/milestones") && !url.includes("/status")) {
const missionId = extractMissionId(url);
if (missionId === "M-001") {
@@ -386,6 +454,11 @@ function createFetchMockWithHealth(
return Promise.resolve(mockApiResponse(mockAutopilotStatus));
}
const validationResponse = getValidationApiMock(url);
if (validationResponse !== null) {
return Promise.resolve(mockApiResponse(validationResponse));
}
if (url.includes("/api/missions/") && !url.includes("/milestones") && !url.includes("/status")) {
return Promise.resolve(mockApiResponse(mockMissionDetail));
}
@@ -864,6 +937,66 @@ describe("MissionManager", () => {
});
});
it("fetches milestone validation telemetry when mission detail opens", async () => {
const fetchMock = createDetailFetchMock(mockMissionEvents);
globalThis.fetch = fetchMock;
render(<MissionManager isOpen={true} onClose={vi.fn()} addToast={vi.fn()} />);
await waitFor(() => {
expect(screen.getByText("Build Auth System")).toBeDefined();
});
fireEvent.click(screen.getByText("Build Auth System"));
await waitFor(() => {
const telemetryCalls = fetchMock.mock.calls.filter(
(call) => typeof call[0] === "string" && call[0].includes("/milestones/MS-001/validation-telemetry")
).length;
expect(telemetryCalls).toBeGreaterThan(0);
});
});
it("refreshes validation telemetry when validator-run SSE event targets selected milestone", async () => {
const fetchMock = createDetailFetchMock(mockMissionEvents);
globalThis.fetch = fetchMock;
globalThis.EventSource = MockEventSource as unknown as typeof globalThis.EventSource;
render(<MissionManager isOpen={true} onClose={vi.fn()} addToast={vi.fn()} />);
await waitFor(() => {
expect(screen.getByText("Build Auth System")).toBeDefined();
});
fireEvent.click(screen.getByText("Build Auth System"));
await waitFor(() => {
expect(screen.getByTestId("mission-back-btn")).toBeDefined();
});
const initialTelemetryCalls = fetchMock.mock.calls.filter(
(call) => typeof call[0] === "string" && call[0].includes("/milestones/MS-001/validation-telemetry")
).length;
await act(async () => {
for (const source of MockEventSource.instances) {
source.emit("validator-run:started", {
id: "VR-001",
featureId: "F-001",
milestoneId: "MS-001",
status: "running",
});
}
});
await waitFor(() => {
const updatedTelemetryCalls = fetchMock.mock.calls.filter(
(call) => typeof call[0] === "string" && call[0].includes("/milestones/MS-001/validation-telemetry")
).length;
expect(updatedTelemetryCalls).toBeGreaterThan(initialTelemetryCalls);
});
});
it("reloads selected mission detail when mission:updated SSE event arrives", async () => {
const fetchMock = createDetailFetchMock(mockMissionEvents);
globalThis.fetch = fetchMock;

View File

@@ -117,6 +117,7 @@ export interface MilestoneValidationRollup {
failedAssertions: number;
blockedAssertions: number;
pendingAssertions: number;
unlinkedAssertions: number;
state: MilestoneValidationState;
}
@@ -138,6 +139,51 @@ export interface MissionValidatorRun {
updatedAt: string;
}
/** Grouped validation telemetry for a milestone */
export interface MilestoneValidationTelemetry {
validationContract: {
assertions: Array<{
id: string;
title: string;
assertion: string;
status: MissionAssertionStatus;
orderIndex: number;
}>;
featureFulfillment: Record<string, {
assertionIds: string[];
featureTitle: string;
featureStatus: string;
}>;
};
validationTelemetry: {
validationRounds: Array<{
roundId: string;
featureId: string;
featureTitle: string;
validatorStatus: ValidatorRunStatus;
implementationAttempt: number;
validatorAttempt: number;
failedAssertionIds: string[];
generatedFixFeatureIds: string[];
blockedReason?: string;
startedAt: string;
completedAt?: string;
}>;
lastValidatorStatus: ValidatorRunStatus | null;
totalRuns: number;
};
fixFeatures: Array<{
id: string;
title: string;
sourceFeatureId: string;
runId: string;
failedAssertionIds: string[];
status: FeatureStatus;
loopState?: FeatureLoopState;
}>;
rollup: MilestoneValidationRollup;
}
/** Loop state snapshot for a feature */
export interface MissionFeatureLoopSnapshot {
featureId: string;