From 3eec1338c54148104141d7e82317dc0546122889 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Tue, 11 Aug 2026 00:23:10 -0700 Subject: [PATCH] FN-8970: add mission reconcile controls Add a dry-run mission reconciliation control with explicit apply feedback. - Add the dashboard reconcile API client and exported result type. - Present preview, apply, empty, and archived reconciliation states in Mission Manager. - Guard reconcile requests across direct selection and target-mission deep-link changes. - Cover reconcile interactions, stale requests, and deep-link intent updates. Files changed: .changeset/fn-8970-mission-reconcile-control.md | 7 + docs/missions.md | 6 + packages/dashboard/app/api/legacy.ts | 2 + packages/dashboard/app/api/missions/missions.ts | 31 +++ packages/dashboard/app/components/MissionManager.css | 48 +++++ packages/dashboard/app/components/MissionManager.tsx | 184 +++++++++++++- packages/dashboard/app/components/__tests__/MissionManager.mobile-css.test.ts | 8 + packages/dashboard/app/components/__tests__/MissionManager.reconcile.test.tsx | 277 +++++++++++++++++++++ 8 files changed, 557 insertions(+), 6 deletions(-) Fusion-Task-Id: FN-8970 Fusion-Task-Lineage: 62802129-a7ea-43cb-8068-5ce2c2c21b77 Co-authored-by: Fusion (runfusion.ai) --- .../fn-8970-mission-reconcile-control.md | 7 + docs/missions.md | 6 + packages/dashboard/app/api/legacy.ts | 2 + .../dashboard/app/api/missions/missions.ts | 31 ++ .../app/components/MissionManager.css | 48 +++ .../app/components/MissionManager.tsx | 184 +++++++++++- .../MissionManager.mobile-css.test.ts | 8 + .../MissionManager.reconcile.test.tsx | 277 ++++++++++++++++++ 8 files changed, 557 insertions(+), 6 deletions(-) create mode 100644 .changeset/fn-8970-mission-reconcile-control.md create mode 100644 packages/dashboard/app/components/__tests__/MissionManager.reconcile.test.tsx diff --git a/.changeset/fn-8970-mission-reconcile-control.md b/.changeset/fn-8970-mission-reconcile-control.md new file mode 100644 index 0000000000..dcd9c35f7b --- /dev/null +++ b/.changeset/fn-8970-mission-reconcile-control.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": minor +--- + +summary: Add a Mission Manager "Reconcile now" control with a dry-run preview before applying. +category: feature +dev: Thin client over POST /api/missions/:missionId/reconcile; new reconcileMission API wrapper in the dashboard app. diff --git a/docs/missions.md b/docs/missions.md index d5359ef5a8..483610423b 100644 --- a/docs/missions.md +++ b/docs/missions.md @@ -775,6 +775,12 @@ Mission hierarchy operations are available with the same project-scoped `Mission The scheduler startup and self-healing maintenance passes, mission autopilot, task moves, and `fn_mission_reconcile({ id?, dryRun? })` use one idempotent reconciliation authority. `POST /api/missions/:missionId/reconcile` exposes the same pass; `dryRun: true` returns planned changes without mutation. Automatic writes are attributed to `mission-reconcile:` and API/tool calls retain their operator or agent actor. +### Mission Manager reconcile control + +Mission detail includes **Reconcile now** for an on-demand operator pass. It first requests a zero-write dry-run preview and lists the server-returned planned feature actions. **Apply reconcile** is a separate explicit action; a failed apply leaves that preview available to retry. Archived missions report as skipped and offer no apply action. + +Selection changes discard reconcile responses silently, including responses arriving before the newly selected mission detail finishes loading. Leaving a mission also releases its busy and preview state so the next mission is immediately actionable. While a new mission detail is loading, the retained previous header's reconcile controls are inert (disabled and handler-refused), preventing reconciliation of the mission just left. + Correction scans every non-archived mission and slice but never activates or triages work. It maps deterministic task lifecycle lanes, failure state, and assertion validation to feature status, repairs stale validation badges when the store supports its fenced repair primitive, and uses explicit task links only to reconcile shipped archived delivery through the store's `terminal-task-reconcile` attribution. A bounded `mission:reconcile-pass` audit event records IDs, source enums, and counters only. Git history, GitHub polling, FR-41 receipts, and FN-8845 spec-lock drift are deliberately deferred extension inputs. For example, activate a ready work unit with `fn_slice_activate({ id: "SL-…" })`. Link it to live work with `fn_feature_link_task({ featureId: "F-…", taskId: "FN-…" })`. Linking delegates to `MissionStore.linkFeatureToTask()`: it verifies the task is a live row in the same project, changes the feature to `triaged`, and records the mission/slice linkage on the task. Archived, deleted, missing, and other-project tasks are rejected. diff --git a/packages/dashboard/app/api/legacy.ts b/packages/dashboard/app/api/legacy.ts index 07973f550e..5173c74acc 100644 --- a/packages/dashboard/app/api/legacy.ts +++ b/packages/dashboard/app/api/legacy.ts @@ -1101,6 +1101,7 @@ export { triggerValidation, VALIDATION_ALREADY_RUNNING, repairFeatureValidation, + reconcileMission, unlinkFeatureFromAssertion, unlinkFeatureFromTask, updateAssertion, @@ -1130,6 +1131,7 @@ export type { MissionEventsResponse, MissionFeature, MissionFeatureLoopSnapshot, + MissionReconcilePassResult, MissionStatus, MissionSummary, MissionValidatorRun, diff --git a/packages/dashboard/app/api/missions/missions.ts b/packages/dashboard/app/api/missions/missions.ts index 419a82c58b..0c295164e4 100644 --- a/packages/dashboard/app/api/missions/missions.ts +++ b/packages/dashboard/app/api/missions/missions.ts @@ -609,6 +609,37 @@ export function triggerValidation(featureId: string, projectId?: string): Promis }); } +/* +FNXC:MissionReconcileControl 2026-08-11-06:49: +The dashboard is a thin client over the single server reconcile authority. It renders the +returned plan verbatim and must never re-derive reconcile decisions in browser code. +*/ +export interface MissionReconcilePassResult { + missionsScanned: number; + featuresScanned: number; + statusUpdates: number; + badgeRepairs: number; + badgeRepairsSkipped: number; + terminalRepairs: number; + terminalSkipped: number; + conflicts: number; + failures: number; + skippedReason?: "archived"; + planned?: Array<{ featureId: string; action: "status" | "terminal-done" | "badge-clear" }>; +} + +/** Preview or apply the server-owned mission reconciliation pass. */ +export function reconcileMission( + missionId: string, + options: { dryRun?: boolean } | undefined, + projectId?: string, +): Promise { + return api(withProjectId(`/missions/${encodeURIComponent(missionId)}/reconcile`, projectId), { + method: "POST", + body: JSON.stringify({ dryRun: options?.dryRun === true }), + }); +} + /** Repair a stale feature validation state using server-resolved ground truth. */ export function repairFeatureValidation( featureId: string, diff --git a/packages/dashboard/app/components/MissionManager.css b/packages/dashboard/app/components/MissionManager.css index 15f777a387..a9ec6e4cce 100644 --- a/packages/dashboard/app/components/MissionManager.css +++ b/packages/dashboard/app/components/MissionManager.css @@ -1111,6 +1111,44 @@ Narrow/mobile Missions puts Plan New Mission at the bottom of the list, using th color: var(--text-muted); } +.mission-detail__reconcile-panel { + display: flex; + flex-direction: column; + gap: var(--space-sm); + width: 100%; + padding: var(--space-sm); + border: var(--border) solid; + border-radius: var(--radius-md); + background: color-mix(in srgb, var(--color-warning) 10%, var(--surface)); + color: var(--text); +} + +.mission-detail__reconcile-summary, +.mission-detail__reconcile-actions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--space-sm); +} + +.mission-detail__reconcile-summary { + font-size: var(--font-size-sm); + color: var(--text-muted); +} + +.mission-detail__reconcile-list { + display: flex; + flex-direction: column; + gap: var(--space-xs); + margin: 0; + padding-inline-start: var(--space-lg); + font-size: var(--font-size-sm); +} + +.mission-detail__reconcile-actions { + justify-content: flex-start; +} + .mission-detail__management-actions { display: inline-flex; align-items: center; @@ -2798,6 +2836,16 @@ Generated fix features can carry the same stale blocked state as ordinary rows. min-height: 36px; } + .mission-detail__run-controls > [data-testid="mission-reconcile-now"], + .mission-detail__reconcile-actions .mission-btn { + min-width: 36px; + min-height: 36px; + } + + .mission-detail__reconcile-panel { + max-width: 100%; + } + /* Prevent iOS zoom on focus */ .mission-form-card input[type="text"], .mission-form-card textarea, diff --git a/packages/dashboard/app/components/MissionManager.tsx b/packages/dashboard/app/components/MissionManager.tsx index 71203d3646..3a9c108d06 100644 --- a/packages/dashboard/app/components/MissionManager.tsx +++ b/packages/dashboard/app/components/MissionManager.tsx @@ -107,6 +107,7 @@ import { triggerValidation, VALIDATION_ALREADY_RUNNING, repairFeatureValidation, + reconcileMission, fetchValidationLoopState, fetchValidationRuns, fetchValidationRun, @@ -119,6 +120,7 @@ import { api, type AiSessionSummary, type BranchGroupSummary, + type MissionReconcilePassResult, } from "../api"; import type { AutopilotState, MissionInterviewDraftSummary } from "./mission-types"; import { readCache, SWR_CACHE_KEYS, writeCache } from "../utils/swrCache"; @@ -756,6 +758,58 @@ function normalizeMissionHierarchy(mission: MissionWithHierarchy): MissionWithHi FNXC:MissionValidationRepair 2026-08-11-00:07: Both feature presentations must expose the same narrowly-scoped escape from a stale validation badge. This renders only actions allowed by the core predicate, so a live validation or implementation cycle is never pre-empted and the execution loop remains unable to escape blocked on its own. */ +/* +FNXC:MissionReconcileControl 2026-08-11-06:49: +Preview content is the dry-run result from the server authority, not a browser-derived plan. +Nothing mutates until the operator explicitly applies this panel. +*/ +function MissionReconcilePreview({ + result, + featureTitles, + busy, + disabled, + onApply, + onDismiss, + t, +}: { + result: MissionReconcilePassResult; + featureTitles: Map; + busy: "preview" | "apply" | null; + disabled: boolean; + onApply: () => void; + onDismiss: () => void; + t: ReturnType["t"]; +}) { + const planned = result.planned ?? []; + const isEmpty = planned.length === 0 && result.statusUpdates === 0 && result.badgeRepairs === 0 && result.terminalRepairs === 0; + const canApply = result.skippedReason !== "archived" && !isEmpty && planned.length > 0; + + return ( +
+
+ {t("missions.reconcileStatusUpdates", "Status updates: {{count}}", { count: result.statusUpdates })} + {t("missions.reconcileBadgeRepairs", "Badge repairs: {{count}}", { count: result.badgeRepairs })} + {t("missions.reconcileTerminalRepairs", "Terminal repairs: {{count}}", { count: result.terminalRepairs })} +
+ {result.skippedReason === "archived" ?

{t("missions.reconcileArchived", "Mission is archived — nothing reconciled")}

+ : isEmpty ?

{t("missions.reconcileUpToDate", "Already up to date")}

+ :
    {planned.map((entry) => ( +
  • + {featureTitles.get(entry.featureId) ?? entry.featureId} — {entry.action} +
  • + ))}
} +
+ {canApply && } + +
+
+ ); +} + function FeatureValidationRepairActions({ feature, busy, @@ -813,6 +867,9 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr const initialMissions = readCache(missionsCacheKey); const [missions, setMissions] = useState(() => (Array.isArray(initialMissions) ? initialMissions : [])); const [selectedMission, setSelectedMission] = useState(null); + const [selectedMissionIntentId, setSelectedMissionIntentId] = useState(null); + const [reconcileBusy, setReconcileBusy] = useState<"preview" | "apply" | null>(null); + const [reconcilePreview, setReconcilePreview] = useState<{ missionId: string; result: MissionReconcilePassResult } | null>(null); const [selectedMissionBranchGroup, setSelectedMissionBranchGroup] = useState(null); const [loading, setLoading] = useState(!(Array.isArray(initialMissions) && initialMissions.length > 0)); const hasHydratedRef = useRef(Array.isArray(initialMissions) && initialMissions.length > 0); @@ -1113,6 +1170,8 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr const missionEventsRef = useRef([]); const missionsRef = useRef([]); const selectedMissionRef = useRef(null); + // Intent changes synchronously on list clicks while committed detail intentionally lags its fetch. + const selectedMissionIntentRef = useRef(null); const selectedMilestoneIdRef = useRef(null); /* FNXC:MissionBranchGroupDetail 2026-08-08-16:58: @@ -1122,6 +1181,19 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr */ const missionDetailRequestGenerationRef = useRef(0); /* + FNXC:MissionReconcileControl 2026-08-11-06:49: + Reconcile responses are generation-guarded on success, rejection, and cleanup. A late loser + cannot alter a new mission, while a selection boundary owns synchronous busy/preview release. + */ + const reconcileRequestGenerationRef = useRef(0); + const invalidateReconcileRequests = useCallback((nextIntentMissionId: string | null) => { + reconcileRequestGenerationRef.current += 1; + selectedMissionIntentRef.current = nextIntentMissionId; + setSelectedMissionIntentId(nextIntentMissionId); + setReconcileBusy(null); + setReconcilePreview(null); + }, []); + /* FNXC:MissionBranchGroupDetail 2026-08-08-17:07: Returning to the mission list, deleting the selected mission, hiding this inline view, or unmounting also changes selection. Invalidate in-flight @@ -1130,7 +1202,8 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr */ const invalidateMissionDetailRequests = useCallback(() => { missionDetailRequestGenerationRef.current += 1; - }, []); + invalidateReconcileRequests(null); + }, [invalidateReconcileRequests]); // 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"); @@ -1162,6 +1235,15 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr }, [eventsTotal, missions, selectedMission?.eventCount, selectedMission?.id]); const displayedMissionEvents = useMemo(() => [...missionEvents].reverse(), [missionEvents]); + const reconcileFeatureTitles = useMemo(() => new Map( + (selectedMission?.milestones ?? []).flatMap((milestone) => milestone.slices.flatMap((slice) => + slice.features.map((feature) => [feature.id, feature.title] as const), + )), + ), [selectedMission]); + + useEffect(() => { + setReconcilePreview(null); + }, [selectedMission?.id]); // Keep latest state available to long-lived SSE handlers without reconnect churn. missionsRef.current = missions; @@ -1273,6 +1355,14 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr }, [addToast, projectId, t]); const loadMissionDetail = useCallback(async (missionId: string) => { + /* + FNXC:MissionReconcileControl 2026-08-11-07:20: + Every detail-load entry point, including `targetMissionId` deep links, is a selection boundary. + Update intent before fetching so a new deep-linked mission can reconcile once it commits. + */ + if (selectedMissionIntentRef.current !== missionId) { + invalidateReconcileRequests(missionId); + } const requestGeneration = ++missionDetailRequestGenerationRef.current; try { setDetailLoading(true); @@ -1283,6 +1373,10 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr } const data = normalizeMissionHierarchy(payload as MissionWithHierarchy); + if (selectedMissionIntentRef.current === null) { + selectedMissionIntentRef.current = data.id; + setSelectedMissionIntentId(data.id); + } setSelectedMission(data); if (data.milestones.length > 0) { const firstMilestoneId = data.milestones[0].id; @@ -1336,7 +1430,7 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr setDetailLoading(false); } } - }, [addToast, loadAssertionsForMilestone, loadValidationRollup, projectId]); + }, [addToast, invalidateReconcileRequests, loadAssertionsForMilestone, loadValidationRollup, projectId]); /* FNXC:MissionBranchGroupDetail 2026-08-08-16:11: @@ -2867,7 +2961,57 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr } }, [addToast, loadMissionDetail, loadMissions, projectId]); + const isReconcileRequestStale = useCallback((generation: number, missionId: string) => + generation !== reconcileRequestGenerationRef.current || selectedMissionRef.current?.id !== missionId, + []); + + const handleReconcilePreview = useCallback(async (missionId: string) => { + // The committed selectedMission is not an intent signal during a detail-load switch. + if (missionId !== selectedMissionIntentRef.current) return; + const generation = ++reconcileRequestGenerationRef.current; + setReconcileBusy("preview"); + try { + const result = await reconcileMission(missionId, { dryRun: true }, projectId); + if (isReconcileRequestStale(generation, missionId)) return; + setReconcilePreview({ missionId, result }); + } catch (err) { + if (isReconcileRequestStale(generation, missionId)) return; + addToast(getErrorMessage(err) || t("missions.reconcilePreviewFailed", "Failed to preview reconcile"), "error"); + } finally { + if (!isReconcileRequestStale(generation, missionId)) setReconcileBusy(null); + } + }, [addToast, isReconcileRequestStale, projectId, t]); + + const handleReconcileApply = useCallback(async (missionId: string) => { + // Refuse an apply dispatched from an old header in the synchronous selection window. + if (missionId !== selectedMissionIntentRef.current) return; + const generation = ++reconcileRequestGenerationRef.current; + setReconcileBusy("apply"); + try { + const result = await reconcileMission(missionId, { dryRun: false }, projectId); + if (isReconcileRequestStale(generation, missionId)) return; + addToast(t("missions.reconcileApplied", "Reconciled: {{status}} status, {{badge}} badge, {{terminal}} terminal repairs", { + status: result.statusUpdates, badge: result.badgeRepairs, terminal: result.terminalRepairs, + }), "success"); + setReconcilePreview(null); + await loadMissionDetail(missionId); + if (isReconcileRequestStale(generation, missionId)) return; + void loadMissions(); + } catch (err) { + if (isReconcileRequestStale(generation, missionId)) return; + addToast(getErrorMessage(err) || t("missions.reconcileApplyFailed", "Failed to apply reconcile"), "error"); + } finally { + if (!isReconcileRequestStale(generation, missionId)) setReconcileBusy(null); + } + }, [addToast, isReconcileRequestStale, loadMissionDetail, loadMissions, projectId, t]); + const handleSelectMission = useCallback((mission: Mission) => { + /* + FNXC:MissionReconcileControl 2026-08-11-06:49: + Detail refs lag a direct mission switch until its fetch commits. Record operator intent and + invalidate/release synchronously so the retained old header cannot reconcile an abandoned mission. + */ + invalidateReconcileRequests(mission.id); setActiveTab("structure"); setSelectedMilestoneId(null); setValidationTelemetry(null); @@ -2876,7 +3020,7 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr setEventsFilter("all"); setExpandedEventMetadata(new Set()); loadMissionDetail(mission.id); - }, [loadMissionDetail]); + }, [invalidateReconcileRequests, loadMissionDetail]); const handleBackToList = useCallback(() => { invalidateMissionDetailRequests(); @@ -2908,9 +3052,15 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr useEffect(() => { if (!isActive) { + /* + FNXC:MissionReconcileControl 2026-08-11-06:49: + Inline Mission Manager stays mounted when hidden. Treat that visibility boundary like a + deselection so an in-flight reconcile cannot toast or update hidden, abandoned detail. + */ + invalidateMissionDetailRequests(); previousMobileDetailVisibleRef.current = false; } - }, [isActive]); + }, [invalidateMissionDetailRequests, isActive]); useEffect(() => { const isMobileDetailVisible = isActive && isMobile && Boolean(selectedMission); @@ -3248,6 +3398,28 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
+ + {reconcilePreview?.missionId === selectedMission.id && ( + handleReconcileApply(selectedMission.id)} + onDismiss={() => setReconcilePreview(null)} + t={t} + /> + )} {selectedMission.status === "active" && (
- ) : detailLoading ? ( + ) : detailLoading && !selectedMission ? (
{t("missions.loadingMissionDetails", "Loading mission details...")} @@ -5572,7 +5744,7 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr )}
- {detailLoading ? ( + {detailLoading && !selectedMission ? (
{t("missions.loadingMissionDetails", "Loading mission details...")} diff --git a/packages/dashboard/app/components/__tests__/MissionManager.mobile-css.test.ts b/packages/dashboard/app/components/__tests__/MissionManager.mobile-css.test.ts index 357878f16b..03d68abbf1 100644 --- a/packages/dashboard/app/components/__tests__/MissionManager.mobile-css.test.ts +++ b/packages/dashboard/app/components/__tests__/MissionManager.mobile-css.test.ts @@ -26,6 +26,14 @@ describe("MissionManager mobile styles", () => { expect(section).toContain("min-height: 36px;"); }); + it("keeps reconcile preview controls touch-sized", () => { + const section = getMissionMobileSection(loadAllAppCss()); + expect(section).toContain('.mission-detail__run-controls > [data-testid="mission-reconcile-now"]'); + expect(section).toContain(".mission-detail__reconcile-actions .mission-btn"); + expect(section).toContain("min-width: 36px;"); + expect(section).toContain("min-height: 36px;"); + }); + it("adds responsive tab and activity layout rules", () => { const css = loadAllAppCss(); const section = getMissionMobileSection(css); diff --git a/packages/dashboard/app/components/__tests__/MissionManager.reconcile.test.tsx b/packages/dashboard/app/components/__tests__/MissionManager.reconcile.test.tsx new file mode 100644 index 0000000000..34d408838a --- /dev/null +++ b/packages/dashboard/app/components/__tests__/MissionManager.reconcile.test.tsx @@ -0,0 +1,277 @@ +import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { ConfirmDialogProvider } from "../../hooks/useConfirm"; +import { MissionManager } from "../MissionManager"; + +const fetchMissions = vi.fn(); +const fetchMission = vi.fn(); +const reconcileMission = vi.fn(); +const fetchMissionsHealth = vi.fn(); +const fetchMissionEvents = vi.fn(); +const fetchAssertions = vi.fn(); +const fetchMilestoneValidation = vi.fn(); +const fetchMilestoneValidationTelemetry = vi.fn(); +const fetchValidationLoopState = vi.fn(); +const fetchValidationRuns = vi.fn(); +const fetchAiSessions = vi.fn(); +const fetchAiSession = vi.fn(); +const fetchMissionInterviewDrafts = vi.fn(); + +vi.mock("../../hooks/useNavigationHistory", () => ({ useNavigationHistoryContext: () => ({ pushNav: vi.fn(), replaceCurrent: vi.fn() }) })); +vi.mock("../../sse-bus", () => ({ subscribeSse: vi.fn(() => vi.fn()) })); +vi.mock("../MissionInterviewModal", () => ({ MissionInterviewModal: () => null })); +vi.mock("../MilestoneSliceInterviewModal", () => ({ MilestoneSliceInterviewModal: () => null })); +vi.mock("../../api", async (original) => ({ + ...(await original()), + fetchMissions: (...args: unknown[]) => fetchMissions(...args), + fetchMission: (...args: unknown[]) => fetchMission(...args), + reconcileMission: (...args: unknown[]) => reconcileMission(...args), + fetchMissionsHealth: (...args: unknown[]) => fetchMissionsHealth(...args), + fetchMissionEvents: (...args: unknown[]) => fetchMissionEvents(...args), + fetchAssertions: (...args: unknown[]) => fetchAssertions(...args), + fetchMilestoneValidation: (...args: unknown[]) => fetchMilestoneValidation(...args), + fetchMilestoneValidationTelemetry: (...args: unknown[]) => fetchMilestoneValidationTelemetry(...args), + fetchValidationLoopState: (...args: unknown[]) => fetchValidationLoopState(...args), + fetchValidationRuns: (...args: unknown[]) => fetchValidationRuns(...args), + fetchAiSessions: (...args: unknown[]) => fetchAiSessions(...args), + fetchAiSession: (...args: unknown[]) => fetchAiSession(...args), + fetchMissionInterviewDrafts: (...args: unknown[]) => fetchMissionInterviewDrafts(...args), + fetchGoals: vi.fn().mockResolvedValue([]), + api: vi.fn().mockResolvedValue({ goals: [] }), + fetchModels: vi.fn().mockResolvedValue({ models: [], favoriteProviders: [], favoriteModels: [] }), +})); + +function mission(id = "M-1") { + return { + id, title: id === "M-1" ? "Mission one" : "Mission two", description: "", status: "active", interviewState: "completed", + createdAt: "2026-01-01T00:00:00Z", updatedAt: "2026-01-01T00:00:00Z", + milestones: [{ id: `${id}-MS`, missionId: id, title: "Milestone", status: "active", interviewState: "completed", orderIndex: 0, dependencies: [], createdAt: "", updatedAt: "", slices: [{ id: `${id}-SL`, milestoneId: `${id}-MS`, title: "Slice", status: "active", orderIndex: 0, dependencies: [], createdAt: "", updatedAt: "", features: [{ id: "F-1", title: "Feature title", status: "in-progress", createdAt: "", updatedAt: "" }] }] }], + }; +} +const result = (planned = [{ featureId: "F-1", action: "status" as const }]) => ({ missionsScanned: 1, featuresScanned: 1, statusUpdates: 1, badgeRepairs: 0, badgeRepairsSkipped: 0, terminalRepairs: 0, terminalSkipped: 0, conflicts: 0, failures: 0, planned }); + +/* +FNXC:MissionReconcileControl 2026-08-11-06:49: +Exercise the rendered control rather than an exported handler so dry-run gating, explicit apply, +and selection-boundary response suppression remain user-visible contracts. +*/ +describe("MissionManager reconcile control", () => { + beforeEach(() => { + Object.defineProperty(window, "innerWidth", { configurable: true, value: 1280 }); + window.dispatchEvent(new Event("resize")); + vi.clearAllMocks(); + fetchMissions.mockResolvedValue([mission("M-1"), mission("M-2")].map((item) => ({ ...item, milestones: [] }))); + fetchMission.mockImplementation(async (id: string) => mission(id)); + fetchMissionsHealth.mockResolvedValue({}); fetchMissionEvents.mockResolvedValue([]); fetchAssertions.mockResolvedValue([]); + fetchMilestoneValidation.mockResolvedValue(null); fetchMilestoneValidationTelemetry.mockResolvedValue({ rollup: null, validationTelemetry: { validationRounds: [], totalRuns: 0 }, validationContract: null, fixFeatures: [] }); + fetchValidationLoopState.mockResolvedValue(null); fetchValidationRuns.mockResolvedValue([]); fetchAiSessions.mockResolvedValue([]); fetchAiSession.mockResolvedValue(null); fetchMissionInterviewDrafts.mockResolvedValue([]); + }); + afterEach(cleanup); + + function renderManager(addToast = vi.fn(), targetMissionId = "M-1") { + const rendered = render( {}} addToast={addToast} projectId="p1" targetMissionId={targetMissionId} />); + return { ...rendered, addToast }; + } + + it.each([1280, 640])("previews at %ipx without applying", async (width) => { + Object.defineProperty(window, "innerWidth", { configurable: true, value: width }); window.dispatchEvent(new Event("resize")); + reconcileMission.mockResolvedValue(result()); renderManager(); + await screen.findByText("Feature title"); fireEvent.click(screen.getByTestId("mission-reconcile-now")); + await screen.findByText(/Feature title — status/); + expect(reconcileMission).toHaveBeenCalledTimes(1); + expect(reconcileMission).toHaveBeenCalledWith("M-1", { dryRun: true }, "p1"); + }); + + it("applies only after explicit confirmation and refreshes", async () => { + reconcileMission.mockResolvedValueOnce(result()).mockResolvedValueOnce({ ...result(), planned: undefined }); + const { container, addToast } = renderManager(); await screen.findByText("Feature title"); + fireEvent.click(screen.getByTestId("mission-reconcile-now")); await screen.findByTestId("mission-reconcile-apply"); + fireEvent.click(screen.getByTestId("mission-reconcile-apply")); + await waitFor(() => expect(reconcileMission).toHaveBeenLastCalledWith("M-1", { dryRun: false }, "p1")); + await waitFor(() => expect(container.querySelector(".mission-detail__reconcile-panel")).toBeNull()); + expect(addToast).toHaveBeenCalledWith(expect.stringContaining("Reconciled:"), "success"); + }); + + it("keeps preview for an apply failure", async () => { + reconcileMission.mockResolvedValueOnce(result()).mockRejectedValueOnce(new Error("apply failed")); + const { addToast } = renderManager(); await screen.findByText("Feature title"); + fireEvent.click(screen.getByTestId("mission-reconcile-now")); await screen.findByTestId("mission-reconcile-apply"); + fireEvent.click(screen.getByTestId("mission-reconcile-apply")); + await waitFor(() => expect(addToast).toHaveBeenCalledWith("apply failed", "error")); + expect(screen.getByTestId("mission-reconcile-apply")).toBeEnabled(); + }); + + it("renders empty and archived dry runs without an apply action", async () => { + reconcileMission.mockResolvedValueOnce({ ...result([]), statusUpdates: 0 }).mockResolvedValueOnce({ ...result([]), statusUpdates: 0, skippedReason: "archived" }); + renderManager(); await screen.findByText("Feature title"); fireEvent.click(screen.getByTestId("mission-reconcile-now")); + await screen.findByText("Already up to date"); expect(screen.queryByTestId("mission-reconcile-apply")).toBeNull(); + fireEvent.click(screen.getByText("Dismiss")); fireEvent.click(screen.getByTestId("mission-reconcile-now")); + await screen.findByText("Mission is archived — nothing reconciled"); expect(screen.queryByTestId("mission-reconcile-apply")).toBeNull(); + }); + + it("keeps the affordance out of mission list controls", async () => { + reconcileMission.mockResolvedValue(result()); const { container } = renderManager(); await screen.findByText("Feature title"); + expect(screen.getAllByTestId("mission-reconcile-now")).toHaveLength(1); + expect(container.querySelector(".mission-list__item-run-controls [data-testid=mission-reconcile-now]")).toBeNull(); + }); + + function deferred() { + let resolve!: (value: T) => void; let reject!: (reason?: unknown) => void; + const promise = new Promise((res, rej) => { resolve = res; reject = rej; }); + return { promise, resolve, reject }; + } + + async function openM2() { + fireEvent.click(screen.getByRole("button", { name: "Open mission Mission two" })); + await screen.findByText("Mission two"); + } + + it("updates reconcile intent when a target mission deep link changes", async () => { + reconcileMission.mockResolvedValue(result()); + const { rerender } = renderManager(); + await screen.findByText("Mission one"); + rerender( {}} addToast={vi.fn()} projectId="p1" targetMissionId="M-2" />); + await screen.findByText("Mission two"); + expect(screen.getByTestId("mission-reconcile-now")).toBeEnabled(); + fireEvent.click(screen.getByTestId("mission-reconcile-now")); + await waitFor(() => expect(reconcileMission).toHaveBeenCalledWith("M-2", { dryRun: true }, "p1")); + }); + + it("makes the retained header inert and refuses an attribute-stripped preview click during a switch", async () => { + const m2 = deferred>(); + fetchMission.mockImplementation((id: string) => id === "M-2" ? m2.promise : Promise.resolve(mission(id))); + const { container } = renderManager(); await screen.findByText("Feature title"); + fireEvent.click(screen.getByRole("button", { name: "Open mission Mission two" })); + const button = screen.getByTestId("mission-reconcile-now") as HTMLButtonElement; + expect(button).toBeDisabled(); + // Remove the DOM gate without a React render: this click reaches the production handler. + button.removeAttribute("disabled"); button.disabled = false; + await act(async () => { fireEvent.click(button); }); + expect(reconcileMission).not.toHaveBeenCalled(); + expect(container.querySelector(".mission-detail__reconcile-panel")).toBeNull(); + m2.resolve(mission("M-2")); await screen.findByText("Mission two"); + expect(screen.getByTestId("mission-reconcile-now")).toBeEnabled(); + }); + + it("refuses a same-batch retained-header click while switching missions", async () => { + const m2 = deferred>(); + fetchMission.mockImplementation((id: string) => id === "M-2" ? m2.promise : Promise.resolve(mission(id))); + renderManager(); await screen.findByText("Feature title"); + const row = screen.getByRole("button", { name: "Open mission Mission two" }); + const button = screen.getByTestId("mission-reconcile-now"); + await act(async () => { fireEvent.click(row); fireEvent.click(button); }); + expect(reconcileMission).not.toHaveBeenCalledWith("M-1", expect.anything(), "p1"); + m2.resolve(mission("M-2")); await screen.findByText("Mission two"); + }); + + it("silently discards preview resolution and rejection in the pre-commit switch window", async () => { + const preview = deferred>(); const m2 = deferred>(); + reconcileMission.mockReturnValue(preview.promise); + fetchMission.mockImplementation((id: string) => id === "M-2" ? m2.promise : Promise.resolve(mission(id))); + const { addToast, container } = renderManager(); await screen.findByText("Feature title"); + fireEvent.click(screen.getByTestId("mission-reconcile-now")); await waitFor(() => expect(reconcileMission).toHaveBeenCalled()); + fireEvent.click(screen.getByRole("button", { name: "Open mission Mission two" })); + preview.resolve(result()); await act(async () => {}); + expect(container.querySelector(".mission-detail__reconcile-panel")).toBeNull(); expect(addToast).not.toHaveBeenCalled(); + m2.resolve(mission("M-2")); await screen.findByText("Mission two"); + + const rejected = deferred>(); reconcileMission.mockReturnValueOnce(rejected.promise); + fireEvent.click(screen.getByTestId("mission-reconcile-now")); await waitFor(() => expect(reconcileMission).toHaveBeenCalledTimes(2)); + fireEvent.click(screen.getByRole("button", { name: "Open mission Mission one" })); + rejected.reject(new Error("stale preview")); await act(async () => {}); + expect(addToast).not.toHaveBeenCalledWith("stale preview", "error"); + }); + + it("refuses a same-batch retained-panel apply click so no write reaches the abandoned mission", async () => { + const m2 = deferred>(); + reconcileMission.mockResolvedValue(result()); + fetchMission.mockImplementation((id: string) => id === "M-2" ? m2.promise : Promise.resolve(mission(id))); + renderManager(); await screen.findByText("Feature title"); + fireEvent.click(screen.getByTestId("mission-reconcile-now")); await screen.findByTestId("mission-reconcile-apply"); + const row = screen.getByRole("button", { name: "Open mission Mission two" }); + const apply = screen.getByTestId("mission-reconcile-apply"); + // The panel is synchronously released after the row event; this same-batch click is the + // reachable production path that still invokes the old handler before React re-renders it. + await act(async () => { fireEvent.click(row); fireEvent.click(apply); }); + expect(reconcileMission).not.toHaveBeenCalledWith("M-1", { dryRun: false }, "p1"); + m2.resolve(mission("M-2")); await screen.findByText("Mission two"); + }); + + it("releases abandoned busy state but does not let a stale finally clobber a newer request", async () => { + const oldRequest = deferred>(); const newRequest = deferred>(); + reconcileMission.mockReturnValueOnce(oldRequest.promise).mockReturnValueOnce(newRequest.promise); + renderManager(); await screen.findByText("Feature title"); + fireEvent.click(screen.getByTestId("mission-reconcile-now")); + expect(screen.getByTestId("mission-reconcile-now")).toBeDisabled(); + await openM2(); + // The boundary, rather than the old request's finally, releases M-2 immediately. + expect(screen.getByTestId("mission-reconcile-now")).toBeEnabled(); + fireEvent.click(screen.getByTestId("mission-reconcile-now")); + expect(screen.getByTestId("mission-reconcile-now")).toBeDisabled(); + oldRequest.resolve(result()); await act(async () => {}); + expect(screen.getByTestId("mission-reconcile-now")).toBeDisabled(); + newRequest.resolve(result()); await screen.findByText(/Feature title — status/); + expect(screen.getByTestId("mission-reconcile-now")).toBeEnabled(); + }); + + it("does not refresh or toast when an abandoned apply resolves or rejects", async () => { + const apply = deferred>(); + reconcileMission.mockResolvedValueOnce(result()).mockReturnValueOnce(apply.promise); + const { addToast } = renderManager(); await screen.findByText("Feature title"); + fireEvent.click(screen.getByTestId("mission-reconcile-now")); await screen.findByTestId("mission-reconcile-apply"); + fireEvent.click(screen.getByTestId("mission-reconcile-apply")); await openM2(); + const callsBefore = fetchMission.mock.calls.filter(([id]) => id === "M-1").length; + apply.resolve({ ...result(), planned: undefined }); await act(async () => {}); + expect(addToast).not.toHaveBeenCalledWith(expect.stringContaining("Reconciled:"), "success"); + expect(fetchMission.mock.calls.filter(([id]) => id === "M-1")).toHaveLength(callsBefore); + + const rejected = deferred>(); reconcileMission.mockResolvedValueOnce(result()).mockReturnValueOnce(rejected.promise); + fireEvent.click(screen.getByTestId("mission-reconcile-now")); await screen.findByTestId("mission-reconcile-apply"); + fireEvent.click(screen.getByTestId("mission-reconcile-apply")); + fireEvent.click(screen.getByRole("button", { name: "Open mission Mission one" })); + rejected.reject(new Error("stale apply")); await act(async () => {}); + expect(addToast).not.toHaveBeenCalledWith("stale apply", "error"); + }); + + it("prevents duplicate preview and apply requests while either request is busy", async () => { + const preview = deferred>(); const apply = deferred>(); + reconcileMission.mockReturnValueOnce(preview.promise).mockReturnValueOnce(apply.promise); + renderManager(); await screen.findByText("Feature title"); const button = screen.getByTestId("mission-reconcile-now"); + fireEvent.click(button); fireEvent.click(button); fireEvent.click(button); + expect(reconcileMission).toHaveBeenCalledTimes(1); expect(button).toBeDisabled(); + preview.resolve(result()); await screen.findByTestId("mission-reconcile-apply"); + const applyButton = screen.getByTestId("mission-reconcile-apply"); fireEvent.click(applyButton); fireEvent.click(applyButton); fireEvent.click(applyButton); + expect(reconcileMission).toHaveBeenCalledTimes(2); expect(applyButton).toBeDisabled(); + apply.resolve({ ...result(), planned: undefined }); await waitFor(() => expect(screen.queryByTestId("mission-reconcile-apply")).toBeNull()); + }); + + it("renders unknown and duplicate planned features without key warnings", async () => { + const error = vi.spyOn(console, "error").mockImplementation(() => {}); + reconcileMission.mockResolvedValue(result([{ featureId: "F-missing", action: "status" }, { featureId: "F-1", action: "status" }, { featureId: "F-1", action: "badge-clear" }])); + renderManager(); await screen.findByText("Feature title"); fireEvent.click(screen.getByTestId("mission-reconcile-now")); + await screen.findByText(/F-missing — status/); expect(screen.getAllByText(/F-1|Feature title/).length).toBeGreaterThan(0); + expect(screen.getByText(/Feature title — badge-clear/)).toBeTruthy(); + expect(error).not.toHaveBeenCalledWith(expect.stringMatching(/unique "key"/i)); error.mockRestore(); + }); + + it("discards a pending reconcile after deselect and unmount without React warnings", async () => { + const request = deferred>(); reconcileMission.mockReturnValue(request.promise); + const error = vi.spyOn(console, "error").mockImplementation(() => {}); + const rendered = renderManager(); await screen.findByText("Feature title"); fireEvent.click(screen.getByTestId("mission-reconcile-now")); + fireEvent.click(screen.getByTestId("mission-back-btn")); request.resolve(result()); await act(async () => {}); + expect(screen.queryByTestId("mission-reconcile-apply")).toBeNull(); + rendered.unmount(); expect(error.mock.calls.join(" ")).not.toMatch(/state update on an unmounted|not wrapped in act/i); error.mockRestore(); + }); + + it("does not update or warn after preview or apply promises settle following unmount", async () => { + const preview = deferred>(); const error = vi.spyOn(console, "error").mockImplementation(() => {}); + reconcileMission.mockReturnValueOnce(preview.promise); const first = renderManager(); await screen.findByText("Feature title"); + fireEvent.click(screen.getByTestId("mission-reconcile-now")); first.unmount(); preview.reject(new Error("gone")); await act(async () => {}); + cleanup(); + const apply = deferred>(); + reconcileMission.mockResolvedValueOnce(result()).mockReturnValueOnce(apply.promise); + const second = renderManager(); await screen.findByText("Feature title"); fireEvent.click(screen.getByTestId("mission-reconcile-now")); await screen.findByTestId("mission-reconcile-apply"); + fireEvent.click(screen.getByTestId("mission-reconcile-apply")); second.unmount(); apply.resolve({ ...result(), planned: undefined }); await act(async () => {}); + expect(error.mock.calls.join(" ")).not.toMatch(/state update on an unmounted|not wrapped in act/i); error.mockRestore(); + }); +});