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:
7
.changeset/fn-6942-mission-workflow-selection.md
Normal file
7
.changeset/fn-6942-mission-workflow-selection.md
Normal 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.
|
||||
@@ -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.
|
||||
|
||||
<!-- 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 manages roadmap hierarchies (roadmaps, milestones, features) and planning handoff exports.
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -3897,6 +3897,7 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
|
||||
branch?: string;
|
||||
baseBranch?: string;
|
||||
assignmentMode?: "shared" | "per-task-derived";
|
||||
workflowId?: string | null;
|
||||
},
|
||||
): Promise<MissionFeature> {
|
||||
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) {
|
||||
@@ -4028,6 +4034,7 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
|
||||
branch?: string;
|
||||
baseBranch?: string;
|
||||
assignmentMode?: "shared" | "per-task-derived";
|
||||
workflowId?: string | null;
|
||||
},
|
||||
): Promise<MissionFeature[]> {
|
||||
if (!this.taskStore) {
|
||||
@@ -4055,6 +4062,7 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
|
||||
branch: strategyBranch,
|
||||
baseBranch: resolvedBaseBranch,
|
||||
assignmentMode: resolvedAssignmentMode,
|
||||
...(branchOptions?.workflowId !== undefined ? { workflowId: branchOptions.workflowId } : {}),
|
||||
});
|
||||
triaged.push(updated);
|
||||
}
|
||||
|
||||
@@ -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<MissionFeature> {
|
||||
return api<MissionFeature>(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 ?? {}),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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<Parameters<typeof triageFeature>[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 ──
|
||||
|
||||
|
||||
@@ -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<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);
|
||||
export function PlanningWorkflowSwitcherSlot(props: PlanningWorkflowSwitcherSlotProps) {
|
||||
return <HeaderWorkflowSwitcherSlot {...props} />;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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" },
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 <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 { 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<string | null>(null);
|
||||
|
||||
if (showBackendConnectionErrorPage) {
|
||||
return (
|
||||
<BackendConnectionErrorPage
|
||||
@@ -341,6 +344,15 @@ export function MainContent({
|
||||
if (taskView === "missions") {
|
||||
return (
|
||||
<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
|
||||
isInline={true}
|
||||
isOpen={true}
|
||||
@@ -352,6 +364,7 @@ export function MainContent({
|
||||
}}
|
||||
addToast={addToast}
|
||||
projectId={currentProject?.id}
|
||||
workflowId={missionWorkflowId}
|
||||
onSelectTask={(taskId) => {
|
||||
const task = tasks.find((t) => t.id === taskId);
|
||||
if (task) openDetailTask(task as TaskDetail);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user