From 6df40430591348606d25e5924482dd098ea19f84 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Thu, 25 Jun 2026 06:17:51 -0700 Subject: [PATCH] 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 --- .../fn-6942-mission-workflow-selection.md | 7 + docs/dashboard-guide.md | 11 + .../core/src/__tests__/mission-store.test.ts | 114 +++++++++++ packages/core/src/mission-store.ts | 8 + packages/dashboard/app/api/legacy.ts | 10 +- .../components/HeaderWorkflowSwitcherSlot.tsx | 99 +++++++++ .../app/components/MissionManager.tsx | 23 ++- .../PlanningWorkflowSwitcherSlot.tsx | 81 +------- .../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(-) create mode 100644 .changeset/fn-6942-mission-workflow-selection.md create mode 100644 packages/dashboard/app/components/HeaderWorkflowSwitcherSlot.tsx create mode 100644 packages/dashboard/app/components/__tests__/HeaderWorkflowSwitcherSlot.test.tsx create mode 100644 packages/dashboard/app/components/__tests__/MissionManager.workflow-triage.test.tsx create mode 100644 packages/dashboard/src/routes/__tests__/mission-workflow-triage-route.test.ts diff --git a/.changeset/fn-6942-mission-workflow-selection.md b/.changeset/fn-6942-mission-workflow-selection.md new file mode 100644 index 0000000000..9bd20e355e --- /dev/null +++ b/.changeset/fn-6942-mission-workflow-selection.md @@ -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. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index 39d2a91006..2f7c2225aa 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -711,6 +711,17 @@ Features: 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. + + + +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 manages roadmap hierarchies (roadmaps, milestones, features) and planning handoff exports. diff --git a/packages/core/src/__tests__/mission-store.test.ts b/packages/core/src/__tests__/mission-store.test.ts index f6e3f63cd9..bbcf67d8a4 100644 --- a/packages/core/src/__tests__/mission-store.test.ts +++ b/packages/core/src/__tests__/mission-store.test.ts @@ -3,6 +3,7 @@ import { MissionStore, deriveMilestoneAcceptanceCriteriaFromFeatures } from "../ import { GoalStore } from "../goal-store.js"; import { Database, SCHEMA_VERSION } from "../db.js"; import type { MissionFeature } from "../mission-types.js"; +import type { WorkflowIr } from "../workflow-ir-types.js"; import { mkdtempSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; @@ -12,6 +13,22 @@ function makeTmpDir(): string { 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 */ function createTaskInDb( database: Database, @@ -2298,6 +2315,63 @@ describe("MissionStore", () => { 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 () => { const { TaskStore } = await import("../store.js"); const ts = new TaskStore(tmpDir, join(tmpDir, ".fusion-global-settings"), { inMemoryDb: true }); @@ -2562,6 +2636,46 @@ describe("MissionStore", () => { 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 () => { const { TaskStore } = await import("../store.js"); const ts = new TaskStore(tmpDir, join(tmpDir, ".fusion-global-settings"), { inMemoryDb: true }); diff --git a/packages/core/src/mission-store.ts b/packages/core/src/mission-store.ts index 250b69de3b..077fc03ede 100644 --- a/packages/core/src/mission-store.ts +++ b/packages/core/src/mission-store.ts @@ -3897,6 +3897,7 @@ export class MissionStore extends EventEmitter { branch?: string; baseBranch?: string; assignmentMode?: "shared" | "per-task-derived"; + workflowId?: string | null; }, ): Promise { if (!this.taskStore) { @@ -3987,6 +3988,11 @@ export class MissionStore extends EventEmitter { }, } : {}), + /* + 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) { @@ -4028,6 +4034,7 @@ export class MissionStore extends EventEmitter { branch?: string; baseBranch?: string; assignmentMode?: "shared" | "per-task-derived"; + workflowId?: string | null; }, ): Promise { if (!this.taskStore) { @@ -4055,6 +4062,7 @@ export class MissionStore extends EventEmitter { branch: strategyBranch, baseBranch: resolvedBaseBranch, assignmentMode: resolvedAssignmentMode, + ...(branchOptions?.workflowId !== undefined ? { workflowId: branchOptions.workflowId } : {}), }); triaged.push(updated); } diff --git a/packages/dashboard/app/api/legacy.ts b/packages/dashboard/app/api/legacy.ts index 2ceee04899..3dad90ec31 100644 --- a/packages/dashboard/app/api/legacy.ts +++ b/packages/dashboard/app/api/legacy.ts @@ -7980,18 +7980,19 @@ export function triageFeature( taskTitle?: string, taskDescription?: string, projectId?: string, - branchOptions?: { + options?: { branchSelection?: { mode: "project-default" | "auto-new" | "existing" | "custom-new"; branchName?: string; baseBranch?: string; }; branchAssignment?: { mode: "shared" | "per-task-derived" }; + workflowId?: string | null; }, ): Promise { return api(withProjectId(`/missions/features/${encodeURIComponent(featureId)}/triage`, projectId), { method: "POST", - body: JSON.stringify({ taskTitle, taskDescription, ...branchOptions }), + body: JSON.stringify({ taskTitle, taskDescription, ...options }), }); } @@ -7999,18 +8000,19 @@ export function triageFeature( export function triageAllSliceFeatures( sliceId: string, projectId?: string, - branchOptions?: { + options?: { branchSelection?: { mode: "project-default" | "auto-new" | "existing" | "custom-new"; branchName?: string; baseBranch?: string; }; branchAssignment?: { mode: "shared" | "per-task-derived" }; + workflowId?: string | null; }, ): Promise<{ triaged: MissionFeature[]; count: number }> { return api<{ triaged: MissionFeature[]; count: number }>(withProjectId(`/missions/slices/${encodeURIComponent(sliceId)}/triage-all`, projectId), { method: "POST", - body: JSON.stringify(branchOptions ?? {}), + body: JSON.stringify(options ?? {}), }); } diff --git a/packages/dashboard/app/components/HeaderWorkflowSwitcherSlot.tsx b/packages/dashboard/app/components/HeaderWorkflowSwitcherSlot.tsx new file mode 100644 index 0000000000..8f5636fc6f --- /dev/null +++ b/packages/dashboard/app/components/HeaderWorkflowSwitcherSlot.tsx @@ -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 = 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(() => { + 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(() => { + 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( +
+
+ +
+
, + headerWorkflowSlot, + ); +} diff --git a/packages/dashboard/app/components/MissionManager.tsx b/packages/dashboard/app/components/MissionManager.tsx index 3d01093cb3..91b985cfb7 100644 --- a/packages/dashboard/app/components/MissionManager.tsx +++ b/packages/dashboard/app/components/MissionManager.tsx @@ -119,6 +119,7 @@ interface MissionManagerProps { onClose: () => void; addToast: (message: string, type?: ToastType) => void; projectId?: string; + workflowId?: string | null; onSelectTask?: (taskId: string) => void; availableTasks?: Array<{ id: string; title?: 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 { confirm } = useConfirm(); const sessionTabId = useMemo(() => getSessionTabId(), []); @@ -2020,11 +2021,23 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr } }, [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[4]> & { workflowId?: string }; + }, [selectedMission, workflowId]); + // Triage a single feature — creates a task and links it const handleTriageFeature = useCallback(async (featureId: string) => { try { 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"); await loadMissionDetail(selectedMission!.id); } catch (err) { @@ -2032,7 +2045,7 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr } finally { setSaving(false); } - }, [addToast, loadMissionDetail, selectedMission, projectId]); + }, [addToast, loadMissionDetail, missionTriageOptions, selectedMission, projectId]); // Triage with preview — fetches enriched description first 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) => { try { 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"); await loadMissionDetail(selectedMission!.id); } catch (err) { @@ -2072,7 +2085,7 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr } finally { setSaving(false); } - }, [addToast, loadMissionDetail, selectedMission, projectId]); + }, [addToast, loadMissionDetail, missionTriageOptions, selectedMission, projectId]); // ── Assertion handlers ── diff --git a/packages/dashboard/app/components/PlanningWorkflowSwitcherSlot.tsx b/packages/dashboard/app/components/PlanningWorkflowSwitcherSlot.tsx index 281071a3c2..5d4bca8bbc 100644 --- a/packages/dashboard/app/components/PlanningWorkflowSwitcherSlot.tsx +++ b/packages/dashboard/app/components/PlanningWorkflowSwitcherSlot.tsx @@ -1,16 +1,8 @@ -import { useEffect, useState } from "react"; -import { createPortal } from "react-dom"; -import { WorkflowSwitcher } from "./WorkflowSwitcher"; -import type { WorkflowStatusCounts } from "./workflowStatusCounts"; -import { useBoardWorkflows } from "../hooks/useBoardWorkflows"; -import { useViewportMode } from "../hooks/useViewportMode"; +import { HeaderWorkflowSwitcherSlot } from "./HeaderWorkflowSwitcherSlot"; /* -FNXC:PlanningWorkflowSwitcher 2026-06-22-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). - -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). +FNXC:PlanningWorkflowSwitcher 2026-06-25-00:00: +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. */ interface PlanningWorkflowSwitcherSlotProps { @@ -19,69 +11,6 @@ interface PlanningWorkflowSwitcherSlotProps { onCreateWorkflow?: () => void; } -// Counts require live task/column data that Planning does not thread here. -// WorkflowSwitcher renders zero counts for an empty map, so pass a stable empty Map -// rather than threading tasks into the Planning view. -const EMPTY_COUNTS: Map = 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(() => { - 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 = ( -
-
- -
-
- ); - - return createPortal(workflowToolbar, headerWorkflowSlot); +export function PlanningWorkflowSwitcherSlot(props: PlanningWorkflowSwitcherSlotProps) { + return ; } diff --git a/packages/dashboard/app/components/__tests__/HeaderWorkflowSwitcherSlot.test.tsx b/packages/dashboard/app/components/__tests__/HeaderWorkflowSwitcherSlot.test.tsx new file mode 100644 index 0000000000..b5caeda23b --- /dev/null +++ b/packages/dashboard/app/components/__tests__/HeaderWorkflowSwitcherSlot.test.tsx @@ -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 { + return { + flagEnabled: true, + defaultWorkflowId: DEFAULT_WORKFLOW.id, + workflows: [DEFAULT_WORKFLOW, MISSION_WORKFLOW], + taskWorkflowIds: {}, + ...overrides, + }; +} + +function renderWithHeader(children: ReactNode) { + return render( + <> +
+ {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( + , + ); + + 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(); + + 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(); + 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(); + 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(); + + await waitFor(() => expect(fetchBoardWorkflowsMock).toHaveBeenCalledWith("project-mobile")); + expect(screen.queryByTestId("workflow-switcher")).toBeNull(); + }); +}); diff --git a/packages/dashboard/app/components/__tests__/MissionManager.workflow-triage.test.tsx b/packages/dashboard/app/components/__tests__/MissionManager.workflow-triage.test.tsx new file mode 100644 index 0000000000..c6cad6c9ce --- /dev/null +++ b/packages/dashboard/app/components/__tests__/MissionManager.workflow-triage.test.tsx @@ -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(); + return { + ...actual, + useNavigationHistoryContext: () => ({ pushNav: vi.fn(), replaceCurrent: vi.fn() }), + }; +}); + +vi.mock("../../api", async (importOriginal) => { + const actual = await importOriginal(); + 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: () => X, + Plus: () => +, + Pencil: () => Pencil, + Trash2: () => Trash, + ChevronRight: () => ChevronRight, + ChevronDown: () => ChevronDown, + ChevronLeft: () => ChevronLeft, + Target: () => Target, + Layers: () => Layers, + Package: () => Package, + Box: () => Box, + Check: () => Check, + Loader2: () => Loader, + Link: () => Link, + Unlink: () => Unlink, + Play: () => Play, + Square: () => Square, + Sparkles: () => Sparkles, + Zap: () => Zap, + Activity: () => Activity, + FileText: () => FileText, + RefreshCw: () => Refresh, +})); + +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( {}} 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" }, + }); + }); + }); +}); diff --git a/packages/dashboard/app/components/dashboard/MainContent.tsx b/packages/dashboard/app/components/dashboard/MainContent.tsx index c3aab3f1df..e154783086 100644 --- a/packages/dashboard/app/components/dashboard/MainContent.tsx +++ b/packages/dashboard/app/components/dashboard/MainContent.tsx @@ -2,7 +2,7 @@ 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 / 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 { Board } from "../Board"; import { TaskCard } from "../TaskCard"; @@ -16,6 +16,7 @@ import { BackendConnectionErrorPage } from "../BackendConnectionErrorPage"; import { CapacityRiskBanner } from "../CapacityRiskBanner"; import { PlanningModeModal } from "../PlanningModeModal"; import { PlanningWorkflowSwitcherSlot } from "../PlanningWorkflowSwitcherSlot"; +import { HeaderWorkflowSwitcherSlot } from "../HeaderWorkflowSwitcherSlot"; import { GraphWorkflowSwitcherSlot, filterTasksByGraphWorkflowSelection } from "../GraphWorkflowSwitcherSlot"; import { PluginDashboardViewHost } from "../../plugins/PluginDashboardViewHost"; import { isPluginViewId } from "../../plugins/pluginViewRegistry"; @@ -170,6 +171,8 @@ export function MainContent({ _SettingsView, _WorkflowEditorView, }: MainContentProps) { + const [missionWorkflowId, setMissionWorkflowId] = useState(null); + if (showBackendConnectionErrorPage) { return ( + {/* + 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. + */} + setMissionWorkflowId(selection?.selectedWorkflow.id ?? null)} + /> { const task = tasks.find((t) => t.id === taskId); if (task) openDetailTask(task as TaskDetail); diff --git a/packages/dashboard/src/mission-routes.ts b/packages/dashboard/src/mission-routes.ts index 2d2a832c38..9fb41a3265 100644 --- a/packages/dashboard/src/mission-routes.ts +++ b/packages/dashboard/src/mission-routes.ts @@ -80,6 +80,13 @@ function validateFeatureId(id: string): boolean { 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 { // Assertion IDs follow format: CA-{base36timestamp}-{random} // e.g., CA-A3B7CD-E9F2 @@ -2819,7 +2826,8 @@ export function createMissionRouter( "/features/:featureId/triage", catchTypedHandler(async (req, res) => { 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)) { throw badRequest("Invalid feature ID format"); @@ -2842,6 +2850,7 @@ export function createMissionRouter( branch: resolvedBranch, baseBranch: resolvedBaseBranch, assignmentMode: branchMode, + ...(validatedWorkflowId !== undefined ? { workflowId: validatedWorkflowId } : {}), }, ); res.json(feature); @@ -2853,6 +2862,9 @@ export function createMissionRouter( if (errMsg.includes("TaskStore")) { 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; } }) @@ -2867,7 +2879,8 @@ export function createMissionRouter( "/slices/:sliceId/triage-all", catchTypedHandler(async (req, res) => { 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)) { throw badRequest("Invalid slice ID format"); @@ -2886,6 +2899,7 @@ export function createMissionRouter( branch: resolvedBranch, baseBranch: resolvedBaseBranch, assignmentMode: branchMode, + ...(validatedWorkflowId !== undefined ? { workflowId: validatedWorkflowId } : {}), }); res.json({ triaged, count: triaged.length }); } catch (err: unknown) { @@ -2893,6 +2907,9 @@ export function createMissionRouter( if (errMsg.includes("TaskStore")) { 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; } }) diff --git a/packages/dashboard/src/routes/__tests__/mission-workflow-triage-route.test.ts b/packages/dashboard/src/routes/__tests__/mission-workflow-triage-route.test.ts new file mode 100644 index 0000000000..9de46776ea --- /dev/null +++ b/packages/dashboard/src/routes/__tests__/mission-workflow-triage-route.test.ts @@ -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); + }); +});