FN-6942: preserve mission workflow selection

Missions now carries the selected header workflow through feature and slice triage so created tasks land in the intended lane.

- Add a reusable header workflow switcher slot and share it between Planning and Missions.
- Thread the selected workflow through mission triage UI, API client calls, routes, and MissionStore task creation.
- Cover mission workflow triage behavior with core, dashboard UI, and route tests.
- Document Missions workflow behavior and add a patch changeset.

Files changed:
 .changeset/fn-6942-mission-workflow-selection.md   |   7 +
 docs/dashboard-guide.md                            |  11 ++
 packages/core/src/__tests__/mission-store.test.ts  | 114 ++++++++++++
 packages/core/src/mission-store.ts                 |   8 +
 packages/dashboard/app/api/legacy.ts               |  10 +-
 .../app/components/HeaderWorkflowSwitcherSlot.tsx  |  99 +++++++++++
 .../dashboard/app/components/MissionManager.tsx    |  23 ++-
 .../components/PlanningWorkflowSwitcherSlot.tsx    |  81 +--------
 .../__tests__/HeaderWorkflowSwitcherSlot.test.tsx  | 124 +++++++++++++
 .../MissionManager.workflow-triage.test.tsx        | 191 +++++++++++++++++++++
 .../app/components/dashboard/MainContent.tsx       |  15 +-
 packages/dashboard/src/mission-routes.ts           |  21 ++-
 .../mission-workflow-triage-route.test.ts          | 149 ++++++++++++++++
 13 files changed, 765 insertions(+), 88 deletions(-)

Fusion-Task-Id: FN-6942

Fusion-Task-Lineage: 53d7b2ea-01cc-4eb6-bdda-988fb684602c
This commit is contained in:
gsxdsm
2026-06-25 06:17:51 -07:00
parent 9fabc9d750
commit 6df4043059
13 changed files with 765 additions and 88 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Preserve the selected workflow when Missions creates tasks.
category: fix
dev: Missions now shares the header workflow selector with Planning and passes workflowId through mission triage APIs.

View File

@@ -711,6 +711,17 @@ Features:
For full lifecycle behavior, runtime/heartbeat settings, and budgets, see [Agents guide](./agents.md). For full lifecycle behavior, runtime/heartbeat settings, and budgets, see [Agents guide](./agents.md).
## Missions View
Missions view manages mission hierarchies and task handoff from milestones, slices, and features.
<!-- FNXC:MissionWorkflows 2026-06-25-06:04: Missions creates tasks from feature and slice triage, so the user-facing guide must document that its header workflow selector matches Planning and carries the selected workflow into mission-created tasks. -->
Workflow behavior:
- When workflow columns are enabled and more than one workflow is available, Missions shows the same header workflow selector as Planning.
- Feature triage and slice **Triage all features** create new tasks on the selected workflow.
- If no workflow is selected, or workflow columns are unavailable, mission-created tasks continue to use the project default workflow.
## Roadmaps View ## Roadmaps View
Roadmaps view manages roadmap hierarchies (roadmaps, milestones, features) and planning handoff exports. Roadmaps view manages roadmap hierarchies (roadmaps, milestones, features) and planning handoff exports.

View File

@@ -3,6 +3,7 @@ import { MissionStore, deriveMilestoneAcceptanceCriteriaFromFeatures } from "../
import { GoalStore } from "../goal-store.js"; import { GoalStore } from "../goal-store.js";
import { Database, SCHEMA_VERSION } from "../db.js"; import { Database, SCHEMA_VERSION } from "../db.js";
import type { MissionFeature } from "../mission-types.js"; import type { MissionFeature } from "../mission-types.js";
import type { WorkflowIr } from "../workflow-ir-types.js";
import { mkdtempSync } from "node:fs"; import { mkdtempSync } from "node:fs";
import { join } from "node:path"; import { join } from "node:path";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
@@ -12,6 +13,22 @@ function makeTmpDir(): string {
return mkdtempSync(join(tmpdir(), "kb-mission-test-")); return mkdtempSync(join(tmpdir(), "kb-mission-test-"));
} }
function linearIr(name: string): WorkflowIr {
return {
version: "v1",
name,
nodes: [
{ id: "start", kind: "start" },
{ id: "triage", kind: "prompt", config: { name: "Triage", prompt: "review" } },
{ id: "end", kind: "end" },
],
edges: [
{ from: "start", to: "triage", condition: "success" },
{ from: "triage", to: "end", condition: "success" },
],
};
}
/** Helper to create a task in the database for foreign key validation */ /** Helper to create a task in the database for foreign key validation */
function createTaskInDb( function createTaskInDb(
database: Database, database: Database,
@@ -2298,6 +2315,63 @@ describe("MissionStore", () => {
expect(task!.missionId).toBe(mission.id); expect(task!.missionId).toBe(mission.id);
}); });
/*
FNXC:MissionWorkflows 2026-06-25-00:00:
MissionStore tests pin the storage invariant: workflowId is applied only to newly created mission-triage tasks, while default inheritance and duplicate-task reuse keep their existing workflow behavior.
*/
it("assigns selected workflow when triaging a new feature task", async () => {
const { TaskStore } = await import("../store.js");
const ts = new TaskStore(tmpDir, join(tmpDir, ".fusion-global-settings"), { inMemoryDb: true });
const msWithTs = ts.getMissionStore();
const workflow = await ts.createWorkflowDefinition({ name: "Mission QA", ir: linearIr("mission-qa") });
const mission = msWithTs.createMission({ title: "Mission" });
const milestone = msWithTs.addMilestone(mission.id, { title: "Milestone" });
const slice = msWithTs.addSlice(milestone.id, { title: "Slice" });
const feature = msWithTs.addFeature(slice.id, { title: "Workflow Feature" });
const triaged = await msWithTs.triageFeature(feature.id, undefined, undefined, { workflowId: workflow.id });
expect(ts.getTaskWorkflowSelection(triaged.taskId!)?.workflowId).toBe(workflow.id);
});
it("omitting workflowId preserves default workflow inheritance during feature triage", async () => {
const { TaskStore } = await import("../store.js");
const ts = new TaskStore(tmpDir, join(tmpDir, ".fusion-global-settings"), { inMemoryDb: true });
const msWithTs = ts.getMissionStore();
const workflow = await ts.createWorkflowDefinition({ name: "Mission Default", ir: linearIr("mission-default") });
await ts.setDefaultWorkflowId(workflow.id);
const mission = msWithTs.createMission({ title: "Mission" });
const milestone = msWithTs.addMilestone(mission.id, { title: "Milestone" });
const slice = msWithTs.addSlice(milestone.id, { title: "Slice" });
const feature = msWithTs.addFeature(slice.id, { title: "Default Workflow Feature" });
const triaged = await msWithTs.triageFeature(feature.id);
expect(ts.getTaskWorkflowSelection(triaged.taskId!)?.workflowId).toBe(workflow.id);
});
it("does not rewrite an existing duplicate task workflow selection", async () => {
const { TaskStore } = await import("../store.js");
const ts = new TaskStore(tmpDir, join(tmpDir, ".fusion-global-settings"), { inMemoryDb: true });
const msWithTs = ts.getMissionStore();
const firstWorkflow = await ts.createWorkflowDefinition({ name: "First Mission Workflow", ir: linearIr("mission-first") });
const secondWorkflow = await ts.createWorkflowDefinition({ name: "Second Mission Workflow", ir: linearIr("mission-second") });
const mission = msWithTs.createMission({ title: "Mission" });
const milestone = msWithTs.addMilestone(mission.id, { title: "Milestone" });
const slice = msWithTs.addSlice(milestone.id, { title: "Slice" });
const featureA = msWithTs.addFeature(slice.id, { title: "Feature A" });
const featureB = msWithTs.addFeature(slice.id, { title: "Feature B" });
const first = await msWithTs.triageFeature(featureA.id, "Same Task", "Same deterministic description", { workflowId: firstWorkflow.id });
const second = await msWithTs.triageFeature(featureB.id, "Same Task", "Same deterministic description", { workflowId: secondWorkflow.id });
expect(second.taskId).toBe(first.taskId);
expect(ts.getTaskWorkflowSelection(first.taskId!)?.workflowId).toBe(firstWorkflow.id);
});
it("inherits mission baseBranch when no explicit override is provided", async () => { it("inherits mission baseBranch when no explicit override is provided", async () => {
const { TaskStore } = await import("../store.js"); const { TaskStore } = await import("../store.js");
const ts = new TaskStore(tmpDir, join(tmpDir, ".fusion-global-settings"), { inMemoryDb: true }); const ts = new TaskStore(tmpDir, join(tmpDir, ".fusion-global-settings"), { inMemoryDb: true });
@@ -2562,6 +2636,46 @@ describe("MissionStore", () => {
expect(triaged[0].status).toBe("triaged"); expect(triaged[0].status).toBe("triaged");
}); });
it("assigns selected workflow to every newly triaged slice feature", async () => {
const { TaskStore } = await import("../store.js");
const ts = new TaskStore(tmpDir, join(tmpDir, ".fusion-global-settings"), { inMemoryDb: true });
const msWithTs = ts.getMissionStore();
const workflow = await ts.createWorkflowDefinition({ name: "Slice Mission QA", ir: linearIr("slice-mission-qa") });
const mission = msWithTs.createMission({ title: "Mission" });
const milestone = msWithTs.addMilestone(mission.id, { title: "Milestone" });
const slice = msWithTs.addSlice(milestone.id, { title: "Slice" });
msWithTs.addFeature(slice.id, { title: "Feature 1" });
msWithTs.addFeature(slice.id, { title: "Feature 2" });
const triaged = await msWithTs.triageSlice(slice.id, { workflowId: workflow.id });
expect(triaged).toHaveLength(2);
expect(triaged.map((feature) => ts.getTaskWorkflowSelection(feature.taskId!)?.workflowId)).toEqual([workflow.id, workflow.id]);
});
it("assigns selected workflow only to newly created slice tasks while skipping already-triaged features", async () => {
const { TaskStore } = await import("../store.js");
const ts = new TaskStore(tmpDir, join(tmpDir, ".fusion-global-settings"), { inMemoryDb: true });
const msWithTs = ts.getMissionStore();
const existingWorkflow = await ts.createWorkflowDefinition({ name: "Existing Slice Workflow", ir: linearIr("slice-existing") });
const selectedWorkflow = await ts.createWorkflowDefinition({ name: "Selected Slice Workflow", ir: linearIr("slice-selected") });
const mission = msWithTs.createMission({ title: "Mission" });
const milestone = msWithTs.addMilestone(mission.id, { title: "Milestone" });
const slice = msWithTs.addSlice(milestone.id, { title: "Slice" });
const f1 = msWithTs.addFeature(slice.id, { title: "Feature 1" });
const f2 = msWithTs.addFeature(slice.id, { title: "Feature 2" });
const first = await msWithTs.triageFeature(f1.id, undefined, undefined, { workflowId: existingWorkflow.id });
const triaged = await msWithTs.triageSlice(slice.id, { workflowId: selectedWorkflow.id });
expect(triaged).toHaveLength(1);
expect(triaged[0].id).toBe(f2.id);
expect(ts.getTaskWorkflowSelection(first.taskId!)?.workflowId).toBe(existingWorkflow.id);
expect(ts.getTaskWorkflowSelection(triaged[0].taskId!)?.workflowId).toBe(selectedWorkflow.id);
});
it("triageSlice inherits mission baseBranch when no override is provided", async () => { it("triageSlice inherits mission baseBranch when no override is provided", async () => {
const { TaskStore } = await import("../store.js"); const { TaskStore } = await import("../store.js");
const ts = new TaskStore(tmpDir, join(tmpDir, ".fusion-global-settings"), { inMemoryDb: true }); const ts = new TaskStore(tmpDir, join(tmpDir, ".fusion-global-settings"), { inMemoryDb: true });

View File

@@ -3897,6 +3897,7 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
branch?: string; branch?: string;
baseBranch?: string; baseBranch?: string;
assignmentMode?: "shared" | "per-task-derived"; assignmentMode?: "shared" | "per-task-derived";
workflowId?: string | null;
}, },
): Promise<MissionFeature> { ): Promise<MissionFeature> {
if (!this.taskStore) { if (!this.taskStore) {
@@ -3987,6 +3988,11 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
}, },
} }
: {}), : {}),
/*
FNXC:MissionWorkflows 2026-06-25-00:00:
Apply the selected Missions header workflow atomically during TaskStore.createTask so newly triaged features land in the intended workflow lane. Duplicate-guard reuses skip this create path, preserving existing duplicate tasks without workflow mutation.
*/
...(branchOptions?.workflowId !== undefined ? { workflowId: branchOptions.workflowId } : {}),
}); });
if (guard.fingerprint) { if (guard.fingerprint) {
@@ -4028,6 +4034,7 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
branch?: string; branch?: string;
baseBranch?: string; baseBranch?: string;
assignmentMode?: "shared" | "per-task-derived"; assignmentMode?: "shared" | "per-task-derived";
workflowId?: string | null;
}, },
): Promise<MissionFeature[]> { ): Promise<MissionFeature[]> {
if (!this.taskStore) { if (!this.taskStore) {
@@ -4055,6 +4062,7 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
branch: strategyBranch, branch: strategyBranch,
baseBranch: resolvedBaseBranch, baseBranch: resolvedBaseBranch,
assignmentMode: resolvedAssignmentMode, assignmentMode: resolvedAssignmentMode,
...(branchOptions?.workflowId !== undefined ? { workflowId: branchOptions.workflowId } : {}),
}); });
triaged.push(updated); triaged.push(updated);
} }

View File

@@ -7980,18 +7980,19 @@ export function triageFeature(
taskTitle?: string, taskTitle?: string,
taskDescription?: string, taskDescription?: string,
projectId?: string, projectId?: string,
branchOptions?: { options?: {
branchSelection?: { branchSelection?: {
mode: "project-default" | "auto-new" | "existing" | "custom-new"; mode: "project-default" | "auto-new" | "existing" | "custom-new";
branchName?: string; branchName?: string;
baseBranch?: string; baseBranch?: string;
}; };
branchAssignment?: { mode: "shared" | "per-task-derived" }; branchAssignment?: { mode: "shared" | "per-task-derived" };
workflowId?: string | null;
}, },
): Promise<MissionFeature> { ): Promise<MissionFeature> {
return api<MissionFeature>(withProjectId(`/missions/features/${encodeURIComponent(featureId)}/triage`, projectId), { return api<MissionFeature>(withProjectId(`/missions/features/${encodeURIComponent(featureId)}/triage`, projectId), {
method: "POST", method: "POST",
body: JSON.stringify({ taskTitle, taskDescription, ...branchOptions }), body: JSON.stringify({ taskTitle, taskDescription, ...options }),
}); });
} }
@@ -7999,18 +8000,19 @@ export function triageFeature(
export function triageAllSliceFeatures( export function triageAllSliceFeatures(
sliceId: string, sliceId: string,
projectId?: string, projectId?: string,
branchOptions?: { options?: {
branchSelection?: { branchSelection?: {
mode: "project-default" | "auto-new" | "existing" | "custom-new"; mode: "project-default" | "auto-new" | "existing" | "custom-new";
branchName?: string; branchName?: string;
baseBranch?: string; baseBranch?: string;
}; };
branchAssignment?: { mode: "shared" | "per-task-derived" }; branchAssignment?: { mode: "shared" | "per-task-derived" };
workflowId?: string | null;
}, },
): Promise<{ triaged: MissionFeature[]; count: number }> { ): Promise<{ triaged: MissionFeature[]; count: number }> {
return api<{ triaged: MissionFeature[]; count: number }>(withProjectId(`/missions/slices/${encodeURIComponent(sliceId)}/triage-all`, projectId), { return api<{ triaged: MissionFeature[]; count: number }>(withProjectId(`/missions/slices/${encodeURIComponent(sliceId)}/triage-all`, projectId), {
method: "POST", method: "POST",
body: JSON.stringify(branchOptions ?? {}), body: JSON.stringify(options ?? {}),
}); });
} }

View File

@@ -0,0 +1,99 @@
import { useEffect, useMemo, useState } from "react";
import { createPortal } from "react-dom";
import type { BoardWorkflowDefinition, BoardWorkflowsPayload } from "../api";
import { useBoardWorkflows } from "../hooks/useBoardWorkflows";
import { useViewportMode } from "../hooks/useViewportMode";
import { WorkflowSwitcher } from "./WorkflowSwitcher";
import type { WorkflowStatusCounts } from "./workflowStatusCounts";
export interface HeaderWorkflowSelection {
boardWorkflows: BoardWorkflowsPayload;
selectedWorkflow: BoardWorkflowDefinition;
}
interface HeaderWorkflowSwitcherSlotProps {
projectId?: string;
onOpenWorkflowEditor?: () => void;
onCreateWorkflow?: () => void;
onWorkflowSelectionChange?: (selection: HeaderWorkflowSelection | null) => void;
}
// Counts require live task/column data that non-board header slots do not thread here.
// WorkflowSwitcher renders zero counts for an empty map, so pass a stable empty Map.
const EMPTY_COUNTS: Map<string, WorkflowStatusCounts> = new Map();
export function HeaderWorkflowSwitcherSlot({
projectId,
onOpenWorkflowEditor,
onCreateWorkflow,
onWorkflowSelectionChange,
}: HeaderWorkflowSwitcherSlotProps) {
const {
boardWorkflows,
workflowMode,
workflowOptions,
selectedWorkflow,
setSelectedWorkflowId,
refreshBoardWorkflows,
} = useBoardWorkflows({ projectId });
const viewportMode = useViewportMode();
const [headerWorkflowSlot, setHeaderWorkflowSlot] = useState<HTMLElement | null>(() => {
if (typeof document === "undefined") return null;
return document.getElementById("header-workflow-slot");
});
useEffect(() => {
if (typeof document === "undefined") return;
const resolve = () => {
const slot = document.getElementById("header-workflow-slot");
setHeaderWorkflowSlot((previous) => (previous === slot ? previous : slot));
return slot;
};
if (resolve()) return;
/*
FNXC:MissionWorkflows 2026-06-25-00:00:
Missions shares Planning's header workflow dropdown because mission triage creates tasks. The header slot can be absent on mobile or during layout swaps, so poll only briefly and re-resolve on viewport changes to avoid an empty toolbar shell or an unbounded timer.
*/
let attempts = 0;
const interval = window.setInterval(() => {
attempts += 1;
if (resolve() || attempts >= 20) window.clearInterval(interval);
}, 250);
return () => window.clearInterval(interval);
}, [viewportMode]);
const selection = useMemo<HeaderWorkflowSelection | null>(() => {
if (!workflowMode || !boardWorkflows || !selectedWorkflow) return null;
return { boardWorkflows, selectedWorkflow };
}, [boardWorkflows, selectedWorkflow, workflowMode]);
useEffect(() => {
onWorkflowSelectionChange?.(selection);
}, [onWorkflowSelectionChange, selection]);
useEffect(() => {
return () => onWorkflowSelectionChange?.(null);
}, [onWorkflowSelectionChange]);
if (!workflowMode || !selectedWorkflow || workflowOptions.length < 2 || !headerWorkflowSlot) {
return null;
}
return createPortal(
<div className="board-workflow-toolbar">
<div className="board-workflow-selector">
<WorkflowSwitcher
workflows={workflowOptions}
value={selectedWorkflow.id}
onChange={setSelectedWorkflowId}
counts={EMPTY_COUNTS}
onOpen={refreshBoardWorkflows}
onEditWorkflow={onOpenWorkflowEditor}
onCreateWorkflow={onCreateWorkflow}
/>
</div>
</div>,
headerWorkflowSlot,
);
}

View File

@@ -119,6 +119,7 @@ interface MissionManagerProps {
onClose: () => void; onClose: () => void;
addToast: (message: string, type?: ToastType) => void; addToast: (message: string, type?: ToastType) => void;
projectId?: string; projectId?: string;
workflowId?: string | null;
onSelectTask?: (taskId: string) => void; onSelectTask?: (taskId: string) => void;
availableTasks?: Array<{ id: string; title?: string }>; availableTasks?: Array<{ id: string; title?: string }>;
resumeSessionId?: string; resumeSessionId?: string;
@@ -614,7 +615,7 @@ function normalizeMissionHierarchy(mission: MissionWithHierarchy): MissionWithHi
}; };
} }
export function MissionManager({ isOpen, isInline = false, onClose, addToast, projectId, onSelectTask, availableTasks = [], resumeSessionId, targetMissionId, milestoneSliceResumeSessionId, onMilestoneSliceResumeFetchError, onNavigateToGoal }: MissionManagerProps) { export function MissionManager({ isOpen, isInline = false, onClose, addToast, projectId, workflowId, onSelectTask, availableTasks = [], resumeSessionId, targetMissionId, milestoneSliceResumeSessionId, onMilestoneSliceResumeFetchError, onNavigateToGoal }: MissionManagerProps) {
const { t } = useTranslation("app"); const { t } = useTranslation("app");
const { confirm } = useConfirm(); const { confirm } = useConfirm();
const sessionTabId = useMemo(() => getSessionTabId(), []); const sessionTabId = useMemo(() => getSessionTabId(), []);
@@ -2020,11 +2021,23 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
} }
}, [addToast, loadMissionDetail, selectedMission, projectId]); }, [addToast, loadMissionDetail, selectedMission, projectId]);
const missionTriageOptions = useMemo(() => {
const branchOptions = toMissionBranchOptions(selectedMission ?? undefined);
/*
FNXC:MissionWorkflows 2026-06-25-00:00:
Mission feature and slice triage must carry the active header workflow into task creation just like Planning. Omit the field when the switcher has no resolved selection so API/tool/autopilot paths continue inheriting the project default.
*/
return {
...branchOptions,
...(workflowId ? { workflowId } : {}),
} as NonNullable<Parameters<typeof triageFeature>[4]> & { workflowId?: string };
}, [selectedMission, workflowId]);
// Triage a single feature — creates a task and links it // Triage a single feature — creates a task and links it
const handleTriageFeature = useCallback(async (featureId: string) => { const handleTriageFeature = useCallback(async (featureId: string) => {
try { try {
setSaving(true); setSaving(true);
await triageFeature(featureId, undefined, undefined, projectId, toMissionBranchOptions(selectedMission ?? undefined)); await triageFeature(featureId, undefined, undefined, projectId, missionTriageOptions);
addToast(t("missions.featureTriaged", "Feature triaged — task created"), "success"); addToast(t("missions.featureTriaged", "Feature triaged — task created"), "success");
await loadMissionDetail(selectedMission!.id); await loadMissionDetail(selectedMission!.id);
} catch (err) { } catch (err) {
@@ -2032,7 +2045,7 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
} finally { } finally {
setSaving(false); setSaving(false);
} }
}, [addToast, loadMissionDetail, selectedMission, projectId]); }, [addToast, loadMissionDetail, missionTriageOptions, selectedMission, projectId]);
// Triage with preview — fetches enriched description first // Triage with preview — fetches enriched description first
const handleTriageFeatureWithPreview = useCallback(async (featureId: string) => { const handleTriageFeatureWithPreview = useCallback(async (featureId: string) => {
@@ -2064,7 +2077,7 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
const handleTriageAllSliceFeatures = useCallback(async (sliceId: string) => { const handleTriageAllSliceFeatures = useCallback(async (sliceId: string) => {
try { try {
setSaving(true); setSaving(true);
const result = await triageAllSliceFeatures(sliceId, projectId, toMissionBranchOptions(selectedMission ?? undefined)); const result = await triageAllSliceFeatures(sliceId, projectId, missionTriageOptions);
addToast(t("missions.sliceTriaged", { count: result.count, defaultValue_one: "Triaged {{count}} feature", defaultValue_other: "Triaged {{count}} features" }), "success"); addToast(t("missions.sliceTriaged", { count: result.count, defaultValue_one: "Triaged {{count}} feature", defaultValue_other: "Triaged {{count}} features" }), "success");
await loadMissionDetail(selectedMission!.id); await loadMissionDetail(selectedMission!.id);
} catch (err) { } catch (err) {
@@ -2072,7 +2085,7 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
} finally { } finally {
setSaving(false); setSaving(false);
} }
}, [addToast, loadMissionDetail, selectedMission, projectId]); }, [addToast, loadMissionDetail, missionTriageOptions, selectedMission, projectId]);
// ── Assertion handlers ── // ── Assertion handlers ──

View File

@@ -1,16 +1,8 @@
import { useEffect, useState } from "react"; import { HeaderWorkflowSwitcherSlot } from "./HeaderWorkflowSwitcherSlot";
import { createPortal } from "react-dom";
import { WorkflowSwitcher } from "./WorkflowSwitcher";
import type { WorkflowStatusCounts } from "./workflowStatusCounts";
import { useBoardWorkflows } from "../hooks/useBoardWorkflows";
import { useViewportMode } from "../hooks/useViewportMode";
/* /*
FNXC:PlanningWorkflowSwitcher 2026-06-22-00:00: FNXC:PlanningWorkflowSwitcher 2026-06-25-00:00:
The Planning view must surface the SAME workflow dropdown as the Board, in the SAME location (the Header `#header-workflow-slot`). Board owns its own switcher only while the board is active, so Planning needs a self-contained mirror that tracks local selection and portals the identical `board-workflow-toolbar > board-workflow-selector > WorkflowSwitcher` markup into the header slot. We intentionally do NOT import Board (the board switcher is tied to board lifecycle/state). Planning keeps this compatibility wrapper while the neutral HeaderWorkflowSwitcherSlot owns the shared header portal behavior. Missions uses the same slot so task-creating views share one workflow-selection affordance without duplicating polling or WorkflowSwitcher markup.
FNXC:Workflows 2026-06-22-17:00:
The board-workflows fetch/cache/SSE-refresh path (refresh on mount, visibility/focus, and `workflow:created|updated|deleted` SSE, sequence-guarded and session-cached) now lives in the shared `useBoardWorkflows` hook used by Board too. This slot keeps only its header-portal poll and the render gate: only show when there is something to switch (workflow mode on AND >= 2 workflow options).
*/ */
interface PlanningWorkflowSwitcherSlotProps { interface PlanningWorkflowSwitcherSlotProps {
@@ -19,69 +11,6 @@ interface PlanningWorkflowSwitcherSlotProps {
onCreateWorkflow?: () => void; onCreateWorkflow?: () => void;
} }
// Counts require live task/column data that Planning does not thread here. export function PlanningWorkflowSwitcherSlot(props: PlanningWorkflowSwitcherSlotProps) {
// WorkflowSwitcher renders zero counts for an empty map, so pass a stable empty Map return <HeaderWorkflowSwitcherSlot {...props} />;
// rather than threading tasks into the Planning view.
const EMPTY_COUNTS: Map<string, WorkflowStatusCounts> = new Map();
export function PlanningWorkflowSwitcherSlot({ projectId, onOpenWorkflowEditor, onCreateWorkflow }: PlanningWorkflowSwitcherSlotProps) {
const {
workflowMode,
workflowOptions,
selectedWorkflow,
setSelectedWorkflowId,
refreshBoardWorkflows,
} = useBoardWorkflows({ projectId });
const viewportMode = useViewportMode();
// Header may mount its workflow slot after this component, so resolve it on mount
// and re-resolve via a short polling effect until it attaches. Render only via portal.
const [headerWorkflowSlot, setHeaderWorkflowSlot] = useState<HTMLElement | null>(() => {
if (typeof document === "undefined") return null;
return document.getElementById("header-workflow-slot");
});
// Attach to the header slot once the Header mounts it. Poll briefly until present.
useEffect(() => {
if (typeof document === "undefined") return;
const resolve = () => {
const slot = document.getElementById("header-workflow-slot");
setHeaderWorkflowSlot((prev) => (prev === slot ? prev : slot));
return slot;
};
if (resolve()) return;
/*
FNXC:PlanningWorkflowSwitcher 2026-06-23-20:05:
Header swaps the workflow portal slot between mobile and non-mobile placements as the viewport changes. Re-resolve the DOM node on viewport-mode changes and cap polling so the Planning selector never stays attached to a removed slot after resizing.
*/
let attempts = 0;
const interval = window.setInterval(() => {
attempts += 1;
if (resolve() || attempts >= 20) window.clearInterval(interval);
}, 250);
return () => window.clearInterval(interval);
}, [viewportMode]);
// Gate: only render when there is something to switch (>= 2 options), matching Board's "show only when switchable" intent.
if (!workflowMode || !selectedWorkflow || workflowOptions.length < 2 || !headerWorkflowSlot) {
return null;
}
const workflowToolbar = (
<div className="board-workflow-toolbar">
<div className="board-workflow-selector">
<WorkflowSwitcher
workflows={workflowOptions}
value={selectedWorkflow.id}
onChange={setSelectedWorkflowId}
counts={EMPTY_COUNTS}
onOpen={refreshBoardWorkflows}
onEditWorkflow={onOpenWorkflowEditor}
onCreateWorkflow={onCreateWorkflow}
/>
</div>
</div>
);
return createPortal(workflowToolbar, headerWorkflowSlot);
} }

View File

@@ -0,0 +1,124 @@
/*
FNXC:MissionWorkflows 2026-06-25-00:00:
The shared header workflow slot is the canonical desktop workflow selector for Planning and Missions, and it must render no leftover toolbar shell when workflow mode cannot be switched.
*/
import type { ReactNode } from "react";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { BoardWorkflowDefinition, BoardWorkflowsPayload } from "../../api";
import { HeaderWorkflowSwitcherSlot, type HeaderWorkflowSelection } from "../HeaderWorkflowSwitcherSlot";
import { PlanningWorkflowSwitcherSlot } from "../PlanningWorkflowSwitcherSlot";
const fetchBoardWorkflowsMock = vi.fn();
const subscribeSseMock = vi.fn(() => vi.fn());
vi.mock("../../api", () => ({
fetchBoardWorkflows: (...args: unknown[]) => fetchBoardWorkflowsMock(...args),
}));
vi.mock("../../sse-bus", () => ({
subscribeSse: (...args: unknown[]) => subscribeSseMock(...args),
}));
const DEFAULT_WORKFLOW: BoardWorkflowDefinition = {
id: "builtin:coding",
name: "Coding",
columns: [],
};
const MISSION_WORKFLOW: BoardWorkflowDefinition = {
id: "wf-missions",
name: "Missions",
columns: [],
};
function workflowPayload(overrides: Partial<BoardWorkflowsPayload> = {}): BoardWorkflowsPayload {
return {
flagEnabled: true,
defaultWorkflowId: DEFAULT_WORKFLOW.id,
workflows: [DEFAULT_WORKFLOW, MISSION_WORKFLOW],
taskWorkflowIds: {},
...overrides,
};
}
function renderWithHeader(children: ReactNode) {
return render(
<>
<div id="header-workflow-slot" data-testid="header-workflow-slot" />
{children}
</>,
);
}
beforeEach(() => {
sessionStorage.clear();
fetchBoardWorkflowsMock.mockReset();
subscribeSseMock.mockClear();
fetchBoardWorkflowsMock.mockResolvedValue(workflowPayload());
});
afterEach(() => {
vi.restoreAllMocks();
});
describe("HeaderWorkflowSwitcherSlot", () => {
it("ports the workflow switcher into the desktop header slot and reports selection changes", async () => {
const onWorkflowSelectionChange = vi.fn<(selection: HeaderWorkflowSelection | null) => void>();
renderWithHeader(
<HeaderWorkflowSwitcherSlot projectId="project-missions" onWorkflowSelectionChange={onWorkflowSelectionChange} />,
);
const headerSlot = screen.getByTestId("header-workflow-slot");
const selector = await screen.findByTestId("workflow-switcher");
expect(headerSlot.contains(selector)).toBe(true);
await waitFor(() => {
expect(onWorkflowSelectionChange).toHaveBeenLastCalledWith(expect.objectContaining({
selectedWorkflow: expect.objectContaining({ id: DEFAULT_WORKFLOW.id }),
}));
});
fireEvent.click(selector);
fireEvent.click(screen.getByTestId("workflow-switcher-option-wf-missions"));
await waitFor(() => {
expect(onWorkflowSelectionChange).toHaveBeenLastCalledWith(expect.objectContaining({
selectedWorkflow: expect.objectContaining({ id: MISSION_WORKFLOW.id }),
}));
});
});
it("keeps the Planning wrapper rendering the shared header switcher", async () => {
renderWithHeader(<PlanningWorkflowSwitcherSlot projectId="project-planning" />);
const headerSlot = screen.getByTestId("header-workflow-slot");
const selector = await screen.findByTestId("workflow-switcher");
expect(headerSlot.contains(selector)).toBe(true);
});
it("renders no toolbar shell when workflow mode is off or only one workflow exists", async () => {
fetchBoardWorkflowsMock.mockResolvedValueOnce(workflowPayload({ flagEnabled: false, workflows: [] }));
const { unmount } = renderWithHeader(<HeaderWorkflowSwitcherSlot projectId="project-off" />);
await waitFor(() => expect(fetchBoardWorkflowsMock).toHaveBeenCalledWith("project-off"));
expect(screen.queryByTestId("workflow-switcher")).toBeNull();
expect(screen.getByTestId("header-workflow-slot")).toBeEmptyDOMElement();
unmount();
sessionStorage.clear();
fetchBoardWorkflowsMock.mockResolvedValueOnce(workflowPayload({ workflows: [DEFAULT_WORKFLOW] }));
renderWithHeader(<HeaderWorkflowSwitcherSlot projectId="project-one" />);
await waitFor(() => expect(fetchBoardWorkflowsMock).toHaveBeenCalledWith("project-one"));
expect(screen.queryByTestId("workflow-switcher")).toBeNull();
expect(screen.getByTestId("header-workflow-slot")).toBeEmptyDOMElement();
});
it("renders no toolbar shell when the header slot is absent", async () => {
render(<HeaderWorkflowSwitcherSlot projectId="project-mobile" />);
await waitFor(() => expect(fetchBoardWorkflowsMock).toHaveBeenCalledWith("project-mobile"));
expect(screen.queryByTestId("workflow-switcher")).toBeNull();
});
});

View File

@@ -0,0 +1,191 @@
/*
FNXC:MissionWorkflows 2026-06-25-00:00:
MissionManager must include the active Missions header workflow in every UI triage entry point: preview-confirm, preview fallback, slice bulk triage, and no-selection omission.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { MissionManager } from "../MissionManager";
const mockFetchMissions = vi.fn();
const mockFetchMission = vi.fn();
const mockFetchMissionsHealth = vi.fn();
const mockFetchAiSessions = vi.fn();
const mockFetchMissionInterviewDrafts = vi.fn();
const mockTriageFeature = vi.fn();
const mockTriageAllSliceFeatures = vi.fn();
const mockPreviewEnrichedDescription = vi.fn();
const mockApi = vi.fn();
vi.mock("../../hooks/useNavigationHistory", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../hooks/useNavigationHistory")>();
return {
...actual,
useNavigationHistoryContext: () => ({ pushNav: vi.fn(), replaceCurrent: vi.fn() }),
};
});
vi.mock("../../api", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../api")>();
return {
...actual,
api: (...args: unknown[]) => mockApi(...args),
fetchMissions: (...args: unknown[]) => mockFetchMissions(...args),
fetchMission: (...args: unknown[]) => mockFetchMission(...args),
fetchMissionsHealth: (...args: unknown[]) => mockFetchMissionsHealth(...args),
fetchAiSessions: (...args: unknown[]) => mockFetchAiSessions(...args),
fetchMissionInterviewDrafts: (...args: unknown[]) => mockFetchMissionInterviewDrafts(...args),
triageFeature: (...args: unknown[]) => mockTriageFeature(...args),
triageAllSliceFeatures: (...args: unknown[]) => mockTriageAllSliceFeatures(...args),
previewEnrichedDescription: (...args: unknown[]) => mockPreviewEnrichedDescription(...args),
};
});
vi.mock("lucide-react", () => ({
X: () => <span>X</span>,
Plus: () => <span>+</span>,
Pencil: () => <span>Pencil</span>,
Trash2: () => <span>Trash</span>,
ChevronRight: () => <span>ChevronRight</span>,
ChevronDown: () => <span>ChevronDown</span>,
ChevronLeft: () => <span>ChevronLeft</span>,
Target: () => <span>Target</span>,
Layers: () => <span>Layers</span>,
Package: () => <span>Package</span>,
Box: () => <span>Box</span>,
Check: () => <span>Check</span>,
Loader2: () => <span>Loader</span>,
Link: () => <span>Link</span>,
Unlink: () => <span>Unlink</span>,
Play: () => <span>Play</span>,
Square: () => <span>Square</span>,
Sparkles: () => <span>Sparkles</span>,
Zap: () => <span>Zap</span>,
Activity: () => <span>Activity</span>,
FileText: () => <span>FileText</span>,
RefreshCw: () => <span>Refresh</span>,
}));
const now = "2026-06-25T00:00:00.000Z";
function missionDetail() {
return {
id: "M-001",
title: "Mission One",
description: "",
status: "active",
baseBranch: "main",
linkedGoals: [],
milestones: [
{
id: "MS-001",
missionId: "M-001",
title: "Milestone One",
description: "",
status: "active",
slices: [
{
id: "SL-001",
milestoneId: "MS-001",
title: "Slice One",
description: "",
status: "active",
features: [
{
id: "F-001",
sliceId: "SL-001",
title: "Feature One",
description: "",
status: "defined",
createdAt: now,
updatedAt: now,
},
],
createdAt: now,
updatedAt: now,
},
],
createdAt: now,
updatedAt: now,
},
],
createdAt: now,
updatedAt: now,
};
}
async function openMission(workflowId?: string | null) {
render(<MissionManager isInline isOpen onClose={() => {}} addToast={() => {}} projectId="project-a" workflowId={workflowId} />);
fireEvent.click(await screen.findByText("Mission One"));
await screen.findByText("Feature One");
}
beforeEach(() => {
vi.clearAllMocks();
localStorage.clear();
mockFetchMissions.mockResolvedValue([
{ id: "M-001", title: "Mission One", description: "", status: "active", summary: { linkedGoalCount: 0 }, milestones: [] },
]);
mockFetchMission.mockResolvedValue(missionDetail());
mockFetchMissionsHealth.mockResolvedValue({});
mockFetchAiSessions.mockResolvedValue([]);
mockFetchMissionInterviewDrafts.mockResolvedValue([]);
mockPreviewEnrichedDescription.mockResolvedValue({ description: "Enriched mission description" });
mockTriageFeature.mockResolvedValue({ id: "F-001", taskId: "FN-001", status: "triaged" });
mockTriageAllSliceFeatures.mockResolvedValue({ triaged: [{ id: "F-001", taskId: "FN-001" }], count: 1 });
});
describe("MissionManager workflow triage", () => {
it("passes the selected workflow to preview-confirm feature triage", async () => {
await openMission("wf-missions");
fireEvent.click(screen.getByTitle("Triage — create task"));
fireEvent.click(await screen.findByText("Create Task"));
await waitFor(() => {
expect(mockTriageFeature).toHaveBeenCalledWith("F-001", undefined, undefined, "project-a", {
branchSelection: { mode: "project-default", baseBranch: "main" },
workflowId: "wf-missions",
});
});
});
it("passes the selected workflow to direct fallback feature triage", async () => {
mockPreviewEnrichedDescription.mockRejectedValueOnce(new Error("preview unavailable"));
await openMission("wf-missions");
fireEvent.click(screen.getByTitle("Triage — create task"));
await waitFor(() => {
expect(mockTriageFeature).toHaveBeenCalledWith("F-001", undefined, undefined, "project-a", {
branchSelection: { mode: "project-default", baseBranch: "main" },
workflowId: "wf-missions",
});
});
});
it("passes the selected workflow to slice bulk triage", async () => {
await openMission("wf-missions");
fireEvent.click(screen.getByTitle("Triage all features"));
await waitFor(() => {
expect(mockTriageAllSliceFeatures).toHaveBeenCalledWith("SL-001", "project-a", {
branchSelection: { mode: "project-default", baseBranch: "main" },
workflowId: "wf-missions",
});
});
});
it("omits workflowId from mission triage calls when no workflow is selected", async () => {
await openMission(null);
fireEvent.click(screen.getByTitle("Triage all features"));
await waitFor(() => {
expect(mockTriageAllSliceFeatures).toHaveBeenCalledWith("SL-001", "project-a", {
branchSelection: { mode: "project-default", baseBranch: "main" },
});
});
});
});

View File

@@ -2,7 +2,7 @@
FNXC:MainContent 2026-06-24-00:00: FNXC:MainContent 2026-06-24-00:00:
MainContent is the presentational switch for the dashboard's main content area, extracted verbatim from AppInner's renderMainContent(). It is a pure switch on taskView/viewMode returning the existing <PageErrorBoundary>/<Suspense> subtrees unchanged. The lazy view chunks (and their leading-underscore inventory convention) stay declared in App.tsx per the docs guard and are threaded in as props; the eager ChatView.css import remains in App.tsx so the styles bundle into the main CSS file. MainContent is the presentational switch for the dashboard's main content area, extracted verbatim from AppInner's renderMainContent(). It is a pure switch on taskView/viewMode returning the existing <PageErrorBoundary>/<Suspense> subtrees unchanged. The lazy view chunks (and their leading-underscore inventory convention) stay declared in App.tsx per the docs guard and are threaded in as props; the eager ChatView.css import remains in App.tsx so the styles bundle into the main CSS file.
*/ */
import { Suspense } from "react"; import { Suspense, useState } from "react";
import type { Task, TaskDetail } from "@fusion/core"; import type { Task, TaskDetail } from "@fusion/core";
import { Board } from "../Board"; import { Board } from "../Board";
import { TaskCard } from "../TaskCard"; import { TaskCard } from "../TaskCard";
@@ -16,6 +16,7 @@ import { BackendConnectionErrorPage } from "../BackendConnectionErrorPage";
import { CapacityRiskBanner } from "../CapacityRiskBanner"; import { CapacityRiskBanner } from "../CapacityRiskBanner";
import { PlanningModeModal } from "../PlanningModeModal"; import { PlanningModeModal } from "../PlanningModeModal";
import { PlanningWorkflowSwitcherSlot } from "../PlanningWorkflowSwitcherSlot"; import { PlanningWorkflowSwitcherSlot } from "../PlanningWorkflowSwitcherSlot";
import { HeaderWorkflowSwitcherSlot } from "../HeaderWorkflowSwitcherSlot";
import { GraphWorkflowSwitcherSlot, filterTasksByGraphWorkflowSelection } from "../GraphWorkflowSwitcherSlot"; import { GraphWorkflowSwitcherSlot, filterTasksByGraphWorkflowSelection } from "../GraphWorkflowSwitcherSlot";
import { PluginDashboardViewHost } from "../../plugins/PluginDashboardViewHost"; import { PluginDashboardViewHost } from "../../plugins/PluginDashboardViewHost";
import { isPluginViewId } from "../../plugins/pluginViewRegistry"; import { isPluginViewId } from "../../plugins/pluginViewRegistry";
@@ -170,6 +171,8 @@ export function MainContent({
_SettingsView, _SettingsView,
_WorkflowEditorView, _WorkflowEditorView,
}: MainContentProps) { }: MainContentProps) {
const [missionWorkflowId, setMissionWorkflowId] = useState<string | null>(null);
if (showBackendConnectionErrorPage) { if (showBackendConnectionErrorPage) {
return ( return (
<BackendConnectionErrorPage <BackendConnectionErrorPage
@@ -341,6 +344,15 @@ export function MainContent({
if (taskView === "missions") { if (taskView === "missions") {
return ( return (
<PageErrorBoundary> <PageErrorBoundary>
{/*
FNXC:MissionWorkflows 2026-06-25-00:00:
Missions intentionally shares Planning's header workflow-selection surface because feature and slice triage create tasks. Keep the selected workflow local to this project view and thread only the resolved id into mission task creation.
*/}
<HeaderWorkflowSwitcherSlot
projectId={currentProject?.id}
onOpenWorkflowEditor={openWorkflowEditorWithNav}
onWorkflowSelectionChange={(selection) => setMissionWorkflowId(selection?.selectedWorkflow.id ?? null)}
/>
<MissionManager <MissionManager
isInline={true} isInline={true}
isOpen={true} isOpen={true}
@@ -352,6 +364,7 @@ export function MainContent({
}} }}
addToast={addToast} addToast={addToast}
projectId={currentProject?.id} projectId={currentProject?.id}
workflowId={missionWorkflowId}
onSelectTask={(taskId) => { onSelectTask={(taskId) => {
const task = tasks.find((t) => t.id === taskId); const task = tasks.find((t) => t.id === taskId);
if (task) openDetailTask(task as TaskDetail); if (task) openDetailTask(task as TaskDetail);

View File

@@ -80,6 +80,13 @@ function validateFeatureId(id: string): boolean {
return /^F-[A-Z0-9]+(?:-[A-Z0-9]+)*$/i.test(id); return /^F-[A-Z0-9]+(?:-[A-Z0-9]+)*$/i.test(id);
} }
function validateOptionalWorkflowId(workflowId: unknown): string | null | undefined {
if (workflowId === undefined || workflowId === null || typeof workflowId === "string") {
return workflowId as string | null | undefined;
}
throw badRequest("workflowId must be a string or null");
}
function validateAssertionId(id: string): boolean { function validateAssertionId(id: string): boolean {
// Assertion IDs follow format: CA-{base36timestamp}-{random} // Assertion IDs follow format: CA-{base36timestamp}-{random}
// e.g., CA-A3B7CD-E9F2 // e.g., CA-A3B7CD-E9F2
@@ -2819,7 +2826,8 @@ export function createMissionRouter(
"/features/:featureId/triage", "/features/:featureId/triage",
catchTypedHandler(async (req, res) => { catchTypedHandler(async (req, res) => {
const { featureId } = req.params; const { featureId } = req.params;
const { taskTitle, taskDescription, branch, baseBranch, branchSelection, branchAssignment } = req.body || {}; const { taskTitle, taskDescription, branch, baseBranch, branchSelection, branchAssignment, workflowId } = req.body || {};
const validatedWorkflowId = validateOptionalWorkflowId(workflowId);
if (!validateFeatureId(featureId)) { if (!validateFeatureId(featureId)) {
throw badRequest("Invalid feature ID format"); throw badRequest("Invalid feature ID format");
@@ -2842,6 +2850,7 @@ export function createMissionRouter(
branch: resolvedBranch, branch: resolvedBranch,
baseBranch: resolvedBaseBranch, baseBranch: resolvedBaseBranch,
assignmentMode: branchMode, assignmentMode: branchMode,
...(validatedWorkflowId !== undefined ? { workflowId: validatedWorkflowId } : {}),
}, },
); );
res.json(feature); res.json(feature);
@@ -2853,6 +2862,9 @@ export function createMissionRouter(
if (errMsg.includes("TaskStore")) { if (errMsg.includes("TaskStore")) {
throw new ApiError(503, "TaskStore not available for triage operations"); throw new ApiError(503, "TaskStore not available for triage operations");
} }
if (/workflow/i.test(errMsg) && /not found/i.test(errMsg)) {
throw notFound(errMsg);
}
throw err; throw err;
} }
}) })
@@ -2867,7 +2879,8 @@ export function createMissionRouter(
"/slices/:sliceId/triage-all", "/slices/:sliceId/triage-all",
catchTypedHandler(async (req, res) => { catchTypedHandler(async (req, res) => {
const { sliceId } = req.params; const { sliceId } = req.params;
const { branch, baseBranch, branchSelection, branchAssignment } = req.body || {}; const { branch, baseBranch, branchSelection, branchAssignment, workflowId } = req.body || {};
const validatedWorkflowId = validateOptionalWorkflowId(workflowId);
if (!validateSliceId(sliceId)) { if (!validateSliceId(sliceId)) {
throw badRequest("Invalid slice ID format"); throw badRequest("Invalid slice ID format");
@@ -2886,6 +2899,7 @@ export function createMissionRouter(
branch: resolvedBranch, branch: resolvedBranch,
baseBranch: resolvedBaseBranch, baseBranch: resolvedBaseBranch,
assignmentMode: branchMode, assignmentMode: branchMode,
...(validatedWorkflowId !== undefined ? { workflowId: validatedWorkflowId } : {}),
}); });
res.json({ triaged, count: triaged.length }); res.json({ triaged, count: triaged.length });
} catch (err: unknown) { } catch (err: unknown) {
@@ -2893,6 +2907,9 @@ export function createMissionRouter(
if (errMsg.includes("TaskStore")) { if (errMsg.includes("TaskStore")) {
throw new ApiError(503, "TaskStore not available for triage operations"); throw new ApiError(503, "TaskStore not available for triage operations");
} }
if (/workflow/i.test(errMsg) && /not found/i.test(errMsg)) {
throw notFound(errMsg);
}
throw err; throw err;
} }
}) })

View File

@@ -0,0 +1,149 @@
// @vitest-environment node
/*
FNXC:MissionWorkflows 2026-06-25-00:00:
Mission route tests encode the invariant that both single-feature and slice bulk triage honor a supplied workflowId, preserve default inheritance when it is omitted, and reject invalid workflow selections before linking features.
*/
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import express from "express";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { TaskStore } from "@fusion/core";
import type { WorkflowIr } from "@fusion/core";
import { createApiRoutes } from "../../routes.js";
import { request as REQUEST } from "../../test-request.js";
function linearIr(name: string): WorkflowIr {
return {
version: "v1",
name,
nodes: [
{ id: "start", kind: "start" },
{ id: "triage", kind: "prompt", config: { name: "Triage", prompt: "review" } },
{ id: "end", kind: "end" },
],
edges: [
{ from: "start", to: "triage", condition: "success" },
{ from: "triage", to: "end", condition: "success" },
],
};
}
describe("mission triage routes workflowId", () => {
let store: TaskStore;
let rootDir: string;
let globalDir: string;
let app: express.Express;
beforeEach(async () => {
rootDir = mkdtempSync(join(tmpdir(), "mission-wf-route-root-"));
globalDir = mkdtempSync(join(tmpdir(), "mission-wf-route-global-"));
store = new TaskStore(rootDir, globalDir, { inMemoryDb: true });
await store.init();
app = express();
app.use(express.json());
app.use("/api", createApiRoutes(store));
});
afterEach(() => {
store.close();
rmSync(rootDir, { recursive: true, force: true });
rmSync(globalDir, { recursive: true, force: true });
});
const post = (path: string, body: unknown) =>
REQUEST(app, "POST", path, JSON.stringify(body), { "content-type": "application/json" });
function createMissionFeature(title = "Feature") {
const missionStore = store.getMissionStore();
const mission = missionStore.createMission({ title: "Mission" });
const milestone = missionStore.addMilestone(mission.id, { title: "Milestone" });
const slice = missionStore.addSlice(milestone.id, { title: "Slice" });
const feature = missionStore.addFeature(slice.id, { title });
return { missionStore, mission, milestone, slice, feature };
}
it("POST /missions/features/:featureId/triage assigns the supplied workflowId", async () => {
const workflow = await store.createWorkflowDefinition({ name: "Mission Feature Route", ir: linearIr("mission-feature-route") });
const { feature } = createMissionFeature("Route Feature");
const res = await post(`/api/missions/features/${feature.id}/triage`, { workflowId: workflow.id });
expect(res.status).toBe(200);
const taskId = (res.body as { taskId: string }).taskId;
expect(store.getTaskWorkflowSelection(taskId)?.workflowId).toBe(workflow.id);
});
it("POST /missions/slices/:sliceId/triage-all assigns the supplied workflowId to all created tasks", async () => {
const workflow = await store.createWorkflowDefinition({ name: "Mission Slice Route", ir: linearIr("mission-slice-route") });
const { missionStore, slice } = createMissionFeature("Route Feature 1");
missionStore.addFeature(slice.id, { title: "Route Feature 2" });
const res = await post(`/api/missions/slices/${slice.id}/triage-all`, { workflowId: workflow.id });
expect(res.status).toBe(200);
const triaged = (res.body as { triaged: Array<{ taskId: string }> }).triaged;
expect(triaged).toHaveLength(2);
expect(triaged.map((feature) => store.getTaskWorkflowSelection(feature.taskId)?.workflowId)).toEqual([workflow.id, workflow.id]);
});
it("omitting workflowId preserves default workflow inheritance for feature triage", async () => {
const workflow = await store.createWorkflowDefinition({ name: "Mission Route Default", ir: linearIr("mission-route-default") });
await store.setDefaultWorkflowId(workflow.id);
const { feature } = createMissionFeature("Default Route Feature");
const res = await post(`/api/missions/features/${feature.id}/triage`, {});
expect(res.status).toBe(200);
expect(store.getTaskWorkflowSelection((res.body as { taskId: string }).taskId)?.workflowId).toBe(workflow.id);
});
it("omitting workflowId preserves default workflow inheritance for slice bulk triage", async () => {
const workflow = await store.createWorkflowDefinition({ name: "Mission Bulk Route Default", ir: linearIr("mission-bulk-route-default") });
await store.setDefaultWorkflowId(workflow.id);
const { slice } = createMissionFeature("Default Bulk Route Feature");
const res = await post(`/api/missions/slices/${slice.id}/triage-all`, {});
expect(res.status).toBe(200);
const triaged = (res.body as { triaged: Array<{ taskId: string }> }).triaged;
expect(triaged).toHaveLength(1);
expect(store.getTaskWorkflowSelection(triaged[0].taskId)?.workflowId).toBe(workflow.id);
});
it("invalid workflowId type returns 400 without linking the feature", async () => {
const { missionStore, feature } = createMissionFeature("Invalid Workflow Feature");
const res = await post(`/api/missions/features/${feature.id}/triage`, { workflowId: 42 });
expect(res.status).toBe(400);
expect(missionStore.getFeature(feature.id)?.taskId).toBeUndefined();
});
it("invalid workflowId type for slice bulk triage returns 400 without linking features", async () => {
const { missionStore, slice } = createMissionFeature("Invalid Bulk Workflow Feature");
const res = await post(`/api/missions/slices/${slice.id}/triage-all`, { workflowId: 42 });
expect(res.status).toBe(400);
expect(missionStore.listFeatures(slice.id).every((candidate) => !candidate.taskId)).toBe(true);
});
it("unknown workflowId returns a 4xx without linking feature or slice tasks", async () => {
const { missionStore, slice, feature } = createMissionFeature("Unknown Workflow Feature");
missionStore.addFeature(slice.id, { title: "Unknown Workflow Slice Feature" });
const single = await post(`/api/missions/features/${feature.id}/triage`, { workflowId: "WF-MISSING" });
expect(single.status).toBeGreaterThanOrEqual(400);
expect(single.status).toBeLessThan(500);
expect(missionStore.getFeature(feature.id)?.taskId).toBeUndefined();
const bulk = await post(`/api/missions/slices/${slice.id}/triage-all`, { workflowId: "WF-MISSING" });
expect(bulk.status).toBeGreaterThanOrEqual(400);
expect(bulk.status).toBeLessThan(500);
expect(missionStore.listFeatures(slice.id).every((candidate) => !candidate.taskId)).toBe(true);
});
});