diff --git a/packages/core/src/__tests__/workflow-selection-store.test.ts b/packages/core/src/__tests__/workflow-selection-store.test.ts index cfc4b37157..840715be59 100644 --- a/packages/core/src/__tests__/workflow-selection-store.test.ts +++ b/packages/core/src/__tests__/workflow-selection-store.test.ts @@ -23,6 +23,23 @@ function linearIr(): WorkflowIr { }; } +/** A single-node fragment IR (start → one node → end). */ +function fragmentIr(): WorkflowIr { + return { + version: "v1", + name: "frag", + nodes: [ + { id: "start", kind: "start" }, + { id: "step-1", kind: "prompt", config: { name: "Doc", prompt: "doc it" } }, + { id: "end", kind: "end" }, + ], + edges: [ + { from: "start", to: "step-1", condition: "success" }, + { from: "step-1", to: "end", condition: "success" }, + ], + }; +} + function branchingIr(): WorkflowIr { return { version: "v1", @@ -172,4 +189,71 @@ describe("TaskStore workflow selection (U3)", () => { await store.setDefaultWorkflowId(null); expect(await store.getDefaultWorkflowId()).toBeUndefined(); }); + + // U6/R3/KTD-4: create-time `workflowId` materializes the selection atomically. + describe("create-time workflowId (U6/R3)", () => { + it("materializes enabledWorkflowSteps atomically when workflowId is given", async () => { + const wf = await store.createWorkflowDefinition({ name: "Pick", ir: linearIr() }); + + const task = await store.createTask({ description: "with workflow", workflowId: wf.id }); + // Reading the task right after create observes the populated steps — no + // intermediate empty state visible to the executor. + const detail = await store.getTask(task.id); + expect(detail.enabledWorkflowSteps).toHaveLength(2); + expect(store.getTaskWorkflowSelection(task.id)?.workflowId).toBe(wf.id); + expect(store.getTaskWorkflowSelection(task.id)?.stepIds).toEqual(detail.enabledWorkflowSteps); + }); + + it("explicit workflowId overrides the project default", async () => { + const def = await store.createWorkflowDefinition({ name: "Default", ir: linearIr() }); + const chosen = await store.createWorkflowDefinition({ name: "Chosen", ir: linearIr() }); + await store.setDefaultWorkflowId(def.id); + + const task = await store.createTask({ description: "override default", workflowId: chosen.id }); + expect(store.getTaskWorkflowSelection(task.id)?.workflowId).toBe(chosen.id); + }); + + it("workflowId: null skips default materialization (explicit No workflow)", async () => { + const def = await store.createWorkflowDefinition({ name: "Default", ir: linearIr() }); + await store.setDefaultWorkflowId(def.id); + + const task = await store.createTask({ description: "no workflow", workflowId: null }); + const detail = await store.getTask(task.id); + expect(detail.enabledWorkflowSteps ?? []).toHaveLength(0); + expect(store.getTaskWorkflowSelection(task.id)).toBeUndefined(); + }); + + it("undefined workflowId still inherits the project default (unchanged)", async () => { + const def = await store.createWorkflowDefinition({ name: "Default", ir: linearIr() }); + await store.setDefaultWorkflowId(def.id); + + const task = await store.createTask({ description: "inherit" }); + const detail = await store.getTask(task.id); + expect(detail.enabledWorkflowSteps).toHaveLength(2); + expect(store.getTaskWorkflowSelection(task.id)?.workflowId).toBe(def.id); + }); + + it("rejects a fragment id before creating the task row", async () => { + const frag = await store.createWorkflowDefinition({ name: "Frag", ir: fragmentIr(), kind: "fragment" }); + const before = (await store.listTasks({ includeArchived: true })).length; + + await expect( + store.createTask({ description: "frag pick", workflowId: frag.id }), + ).rejects.toThrow(/fragment/i); + + const after = (await store.listTasks({ includeArchived: true })).length; + expect(after).toBe(before); + }); + + it("rejects an unknown workflow id before creating the task row", async () => { + const before = (await store.listTasks({ includeArchived: true })).length; + + await expect( + store.createTask({ description: "bad pick", workflowId: "WF-404" }), + ).rejects.toThrow(/not found/i); + + const after = (await store.listTasks({ includeArchived: true })).length; + expect(after).toBe(before); + }); + }); }); diff --git a/packages/core/src/store.ts b/packages/core/src/store.ts index fb6f3d8543..bc04eee0e7 100644 --- a/packages/core/src/store.ts +++ b/packages/core/src/store.ts @@ -3975,7 +3975,24 @@ export class TaskStore extends EventEmitter { // When a project default workflow is configured, new tasks inherit it // (compiled to steps) ahead of the legacy default-on step behavior. let pendingWorkflowSelection: { workflowId: string; stepIds: string[] } | undefined; - if (input.enabledWorkflowSteps === undefined) { + // U6/R3/KTD-4: an explicit create-time workflowId beats the project default. + // `null` is an explicit opt-out (no workflow), `string` materializes that + // workflow, `undefined` falls through to the default-workflow behavior below. + // Explicit enabledWorkflowSteps still wins over workflowId for trusted callers. + const explicitWorkflowId = + input.enabledWorkflowSteps === undefined ? input.workflowId : undefined; + if (explicitWorkflowId !== undefined) { + if (explicitWorkflowId === null) { + // Explicit "No workflow": skip default materialization entirely. + resolvedWorkflowSteps = undefined; + } else { + // Compile + materialize up front so unknown/fragment ids throw BEFORE + // the task row is created (no orphaned steps, no half-created task). + const selected = await this.materializeExplicitWorkflowSteps(explicitWorkflowId); + resolvedWorkflowSteps = selected.stepIds; + pendingWorkflowSelection = selected; + } + } else if (input.enabledWorkflowSteps === undefined) { try { const inherited = await this.materializeDefaultWorkflowSteps(); if (inherited) { @@ -13448,6 +13465,25 @@ ${stepsSection}`; return { workflowId, stepIds }; } + /** Resolve an EXPLICITLY requested workflow id (U6/R3/KTD-4) into materialized + * step ids for the create-time `workflowId` parameter. Unlike + * `materializeDefaultWorkflowSteps`, unknown ids and fragments are hard errors + * (thrown BEFORE any task row is created) rather than silent fallbacks, since + * the caller asked for a specific workflow. Compilation happens up front so a + * non-compilable workflow aborts before any rows are written. */ + private async materializeExplicitWorkflowSteps( + workflowId: string, + ): Promise<{ workflowId: string; stepIds: string[] }> { + const def = await this.getWorkflowDefinition(workflowId); + if (!def) throw new Error(`Workflow '${workflowId}' not found`); + if (def.kind === "fragment") { + throw new Error(`Workflow '${workflowId}' is a fragment and cannot be selected for a task`); + } + const inputs = compileWorkflowToSteps(def.ir); + const stepIds = await this.materializeWorkflowSteps(workflowId, inputs); + return { workflowId, stepIds }; + } + /** * Select a workflow for a task: compile it, materialize its steps, and write * their ids into the task's enabledWorkflowSteps. Replaces any prior selection diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 4709197136..9ca818d3e6 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -2291,6 +2291,23 @@ export interface TaskCreateInput { noCommitsExpected?: boolean; /** IDs of workflow steps to enable for this task */ enabledWorkflowSteps?: string[]; + /** + * Workflow selection applied atomically at task creation (U6/R3/KTD-4). + * + * Semantics: + * - `undefined` → inherit the project default workflow (today's behavior: + * `materializeDefaultWorkflowSteps` runs, falling back to default-on steps). + * - `null` → explicitly NO workflow: skip default materialization entirely; + * the task is created with no custom workflow steps. + * - `string` → that workflow's compiled steps are materialized and selected + * inside the creation flow, overriding any project default. Fragment IDs + * and unknown IDs are rejected with a clear error BEFORE the task row is + * created. + * + * Mutually exclusive with `enabledWorkflowSteps`: when `enabledWorkflowSteps` + * is provided, it takes precedence and `workflowId` materialization is skipped. + */ + workflowId?: string | null; /** Model preset selected during task creation. Presets resolve to concrete model overrides at creation time. */ modelPresetId?: string; /** AI model provider override for the executor agent (e.g., "anthropic"). diff --git a/packages/dashboard/app/api/legacy.ts b/packages/dashboard/app/api/legacy.ts index 341eb13110..662397ecc1 100644 --- a/packages/dashboard/app/api/legacy.ts +++ b/packages/dashboard/app/api/legacy.ts @@ -371,6 +371,7 @@ export async function createTask( dependencies, breakIntoSubtasks, enabledWorkflowSteps, + workflowId, assignedAgentId, modelPresetId, modelProvider, @@ -407,6 +408,7 @@ export async function createTask( dependencies, breakIntoSubtasks, enabledWorkflowSteps, + workflowId, assignedAgentId, modelPresetId, modelProvider, diff --git a/packages/dashboard/app/components/NewTaskModal.tsx b/packages/dashboard/app/components/NewTaskModal.tsx index 574510a682..8214096e37 100644 --- a/packages/dashboard/app/components/NewTaskModal.tsx +++ b/packages/dashboard/app/components/NewTaskModal.tsx @@ -4,7 +4,7 @@ import { useTranslation } from "react-i18next"; import { DEFAULT_TASK_PRIORITY, type Task, type TaskCreateInput, type TaskPriority } from "@fusion/core"; import { getErrorMessage } from "@fusion/core"; import type { ToastType } from "../hooks/useToast"; -import { uploadAttachment, selectTaskWorkflow } from "../api"; +import { uploadAttachment } from "../api"; import { Bot } from "lucide-react"; import { useSetupReadiness } from "../hooks/useSetupReadiness"; import { SetupWarningBanner } from "./SetupWarningBanner"; @@ -16,7 +16,6 @@ import { useMobileScrollLock } from "../hooks/useMobileScrollLock"; import { useNodes } from "../hooks/useNodes"; import { useViewportMode } from "../hooks/useViewportMode"; import { useAgentsMapCache } from "../hooks/useAgentsMapCache"; -import { WorkflowSelector } from "./WorkflowSelector"; interface NewTaskModalProps { isOpen: boolean; @@ -56,9 +55,10 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask, const [selectedPresetId, setSelectedPresetId] = useState(""); const [presetMode, setPresetMode] = useState<"default" | "preset" | "custom">("default"); const [hasDirtyState, setHasDirtyState] = useState(false); - const [selectedWorkflowId, setSelectedWorkflowId] = useState(null); - const [selectedWorkflowSteps, setSelectedWorkflowSteps] = useState([]); - const [workflowStepsExplicitlySet, setWorkflowStepsExplicitlySet] = useState(false); + // U6/R3: tri-state workflow selection. `undefined` = inherit project default, + // `null` = explicit "No workflow", `string` = a specific workflow. Materialized + // atomically at create time via the `workflowId` create parameter. + const [selectedWorkflowId, setSelectedWorkflowId] = useState(undefined); const [reviewLevel, setReviewLevel] = useState(undefined); const [autoMerge, setAutoMerge] = useState(undefined); const [priority, setPriority] = useState(DEFAULT_TASK_PRIORITY); @@ -80,18 +80,6 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask, const { hasAiProvider, hasGithub, loading: setupReadinessLoading } = useSetupReadiness(projectId); const { nodes } = useNodes(); - // Handler for workflow step changes that detects explicit user interaction - const handleWorkflowStepsChange = useCallback((steps: string[]) => { - setWorkflowStepsExplicitlySet(true); - setSelectedWorkflowSteps(steps); - }, []); - - // Callback when defaultOn steps are auto-applied by TaskForm - const handleDefaultOnApplied = useCallback(() => { - // defaultOn auto-selection is not "explicit" user interaction - setWorkflowStepsExplicitlySet(false); - }, []); - // Load agents for agent picker const loadAgents = useCallback(() => { setShowAgentPicker(true); @@ -159,12 +147,11 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask, description.trim() !== "" || dependencies.length > 0 || pendingImages.length > 0 || - selectedWorkflowId !== null || + selectedWorkflowId !== undefined || executorModel !== "" || validatorModel !== "" || planningModel !== "" || thinkingLevel !== "" || - selectedWorkflowSteps.length > 0 || selectedAgentId !== null || reviewLevel !== undefined || autoMerge !== undefined || @@ -176,7 +163,7 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask, githubTrackingEnabled || githubRepoOverrideTrimmed !== ""; setHasDirtyState(isDirty); - }, [description, dependencies, pendingImages, selectedWorkflowId, executorModel, validatorModel, planningModel, thinkingLevel, selectedWorkflowSteps, selectedAgentId, reviewLevel, autoMerge, priority, nodeId, branchMode, branch, baseBranch, githubTrackingEnabled, githubRepoOverrideTrimmed]); + }, [description, dependencies, pendingImages, selectedWorkflowId, executorModel, validatorModel, planningModel, thinkingLevel, selectedAgentId, reviewLevel, autoMerge, priority, nodeId, branchMode, branch, baseBranch, githubTrackingEnabled, githubRepoOverrideTrimmed]); const handleClose = useCallback(async () => { if (hasDirtyState) { @@ -199,9 +186,7 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask, setThinkingLevel(""); setSelectedPresetId(""); setPresetMode("default"); - setSelectedWorkflowId(null); - setSelectedWorkflowSteps([]); - setWorkflowStepsExplicitlySet(false); + setSelectedWorkflowId(undefined); setSelectedAgentId(null); setShowAgentPicker(false); setReviewLevel(undefined); @@ -238,9 +223,11 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask, description: trimmedDesc, column: "triage", dependencies: dependencies.length ? dependencies : undefined, - // When user explicitly cleared all workflow steps, send empty array to prevent backend re-applying defaults. - // When user hasn't interacted with workflow steps (or left auto-selected defaults), send undefined to let backend apply defaults. - enabledWorkflowSteps: workflowStepsExplicitlySet ? (selectedWorkflowSteps.length > 0 ? selectedWorkflowSteps : []) : undefined, + // U6/R3: forward the workflow selection only when the user changed it. + // - undefined → omit (store inherits the project default, today's behavior) + // - null → explicit "No workflow" (store skips default materialization) + // - string → that workflow, materialized atomically at create time. + ...(selectedWorkflowId !== undefined ? { workflowId: selectedWorkflowId } : {}), ...(selectedAgentId ? { assignedAgentId: selectedAgentId } : {}), modelPresetId: presetMode === "preset" ? selectedPresetId || undefined : undefined, modelProvider: executorModel && executorSlashIdx !== -1 ? executorModel.slice(0, executorSlashIdx) : undefined, @@ -269,17 +256,11 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask, : {}), }; + // U6/R3: the workflow is now materialized atomically inside createTask via + // the `workflowId` parameter — no post-create selectTaskWorkflow call, so + // the executor can never observe the task with the wrong step set. const task = await onCreateTask(createInput); - // Apply custom workflow if selected (non-blocking — task already exists) - if (selectedWorkflowId) { - try { - await selectTaskWorkflow(task.id, selectedWorkflowId, projectId); - } catch (err) { - addToast(getErrorMessage(err) || "Failed to apply workflow", "error"); - } - } - // Upload pending images as attachments if (pendingImages.length > 0) { const failures: string[] = []; @@ -306,9 +287,7 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask, setThinkingLevel(""); setSelectedPresetId(""); setPresetMode("default"); - setSelectedWorkflowId(null); - setSelectedWorkflowSteps([]); - setWorkflowStepsExplicitlySet(false); + setSelectedWorkflowId(undefined); setSelectedAgentId(null); setShowAgentPicker(false); setReviewLevel(undefined); @@ -326,7 +305,7 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask, } finally { setIsSubmitting(false); } - }, [description, dependencies, pendingImages, executorModel, validatorModel, planningModel, thinkingLevel, isSubmitting, githubRepoOverrideInvalid, hasInvalidBranchSelection, onCreateTask, addToast, onClose, projectId, presetMode, selectedPresetId, selectedWorkflowId, selectedWorkflowSteps, workflowStepsExplicitlySet, selectedAgentId, reviewLevel, autoMerge, priority, nodeId, branchMode, isBranchNameRequired, branch, baseBranch, githubTrackingEnabled, githubRepoOverrideTrimmed, t]); + }, [description, dependencies, pendingImages, executorModel, validatorModel, planningModel, thinkingLevel, isSubmitting, githubRepoOverrideInvalid, hasInvalidBranchSelection, onCreateTask, addToast, onClose, projectId, presetMode, selectedPresetId, selectedWorkflowId, selectedAgentId, reviewLevel, autoMerge, priority, nodeId, branchMode, isBranchNameRequired, branch, baseBranch, githubTrackingEnabled, githubRepoOverrideTrimmed, t]); // Handle keyboard shortcuts const handleKeyDown = useCallback((e: React.KeyboardEvent) => { @@ -468,17 +447,9 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask, )} - {/* Custom Workflow */} -
- setSelectedWorkflowId(id)} - projectId={projectId} - addToast={addToast} - label="Custom workflow" - disabled={isSubmitting} - /> -
+ {/* U6/R3: the workflow picker now lives inside TaskForm (a whole-workflow + dropdown materialized atomically at create time), replacing the prior + standalone WorkflowSelector + post-create selectTaskWorkflow flow. */} ); @@ -520,9 +491,8 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask, onPresetModeChange={setPresetMode} selectedPresetId={selectedPresetId} onSelectedPresetIdChange={setSelectedPresetId} - selectedWorkflowSteps={selectedWorkflowSteps} - onWorkflowStepsChange={handleWorkflowStepsChange} - onDefaultOnApplied={handleDefaultOnApplied} + selectedWorkflowId={selectedWorkflowId} + onWorkflowIdChange={setSelectedWorkflowId} pendingImages={pendingImages} onImagesChange={setPendingImages} tasks={tasks} diff --git a/packages/dashboard/app/components/TaskDetailModal.tsx b/packages/dashboard/app/components/TaskDetailModal.tsx index 0d32ed7567..19f940b82f 100644 --- a/packages/dashboard/app/components/TaskDetailModal.tsx +++ b/packages/dashboard/app/components/TaskDetailModal.tsx @@ -2468,8 +2468,6 @@ export function TaskDetailContent({ onPresetModeChange={setEditPresetMode} selectedPresetId={editSelectedPresetId} onSelectedPresetIdChange={setEditSelectedPresetId} - selectedWorkflowSteps={editSelectedWorkflowSteps} - onWorkflowStepsChange={setEditSelectedWorkflowSteps} pendingImages={editPendingImages} onImagesChange={setEditPendingImages} tasks={tasks.filter((t) => t.id !== task.id)} diff --git a/packages/dashboard/app/components/TaskForm.tsx b/packages/dashboard/app/components/TaskForm.tsx index 8f7218824e..dad5a6395f 100644 --- a/packages/dashboard/app/components/TaskForm.tsx +++ b/packages/dashboard/app/components/TaskForm.tsx @@ -1,12 +1,12 @@ -import { useState, useCallback, useEffect, useRef, type ReactNode } from "react"; +import { useState, useCallback, useEffect, useRef } from "react"; import { useTranslation } from "react-i18next"; -import { DEFAULT_TASK_PRIORITY, TASK_PRIORITIES, type GlobalSettings, type Task, type TaskPriority, type Settings, type WorkflowStep } from "@fusion/core"; +import { DEFAULT_TASK_PRIORITY, TASK_PRIORITIES, type GlobalSettings, type Task, type TaskPriority, type Settings, type WorkflowDefinition } from "@fusion/core"; import type { ToastType } from "../hooks/useToast"; -import { fetchModels, fetchSettings, fetchWorkflowSteps, refineText, getRefineErrorMessage, updateGlobalSettings, fetchGlobalSettings, fetchGitBranches, type RefinementType, type ModelInfo, type NodeInfo } from "../api"; +import { fetchModels, fetchSettings, fetchWorkflows, refineText, getRefineErrorMessage, updateGlobalSettings, fetchGlobalSettings, fetchGitBranches, type RefinementType, type ModelInfo, type NodeInfo } from "../api"; import { applyPresetToSelection, getRecommendedPresetForSize } from "../utils/modelPresets"; import { CustomModelDropdown } from "./CustomModelDropdown"; import { NodeHealthDot } from "./NodeHealthDot"; -import { Sparkles, ChevronUp, ChevronDown, X, Maximize2, Minimize2 } from "lucide-react"; +import { Sparkles, ChevronUp, ChevronDown, Maximize2, Minimize2 } from "lucide-react"; import { REPO_OVERRIDE_RE, resolveEffectiveGithubRepoDefault } from "./githubTracking"; function getNodeStatusLabel(status: NodeInfo["status"], t: (key: string, defaultValue: string) => string): string { @@ -39,17 +39,6 @@ function sortBranchNames(branches: string[]): string[] { } /** Renders a phase badge using shared .phase-badge classes for consistency */ -function phaseBadge(phase: "pre-merge" | "post-merge", id: string, prefix: string, t: (key: string, defaultValue: string) => string): ReactNode { - const phaseClass = phase === "post-merge" ? "phase-badge--post-merge" : "phase-badge--pre-merge"; - return ( - - {phase === "post-merge" ? t("taskForm.phasePostMerge", "Post-merge") : t("taskForm.phasePreMerge", "Pre-merge")} - - ); -} export interface PendingImage { file: File; @@ -99,11 +88,15 @@ export interface TaskFormProps { selectedPresetId: string; onSelectedPresetIdChange: (id: string) => void; - // Workflow steps - selectedWorkflowSteps: string[]; - onWorkflowStepsChange: (steps: string[]) => void; - /** Callback fired when defaultOn steps have been preselected (create mode). Parent can use this to distinguish "no selection yet" from "user explicitly cleared". */ - onDefaultOnApplied?: (stepIds: string[]) => void; + // Workflow selection (U6/R3). The form picks a whole workflow (not individual + // steps), applied atomically at task creation via the create-time `workflowId`. + // - `undefined` → inherit the project default (preselected + "(default)" badge). + // - `null` → "No workflow" (listed first). + // - `string` → a specific workflow id. + // The dropdown only renders when `onWorkflowIdChange` is provided (create mode); + // edit-mode workflow management lives in the task detail Workflow tab. + selectedWorkflowId?: string | null; + onWorkflowIdChange?: (workflowId: string | null) => void; // Attachments pendingImages: PendingImage[]; @@ -179,9 +172,8 @@ export function TaskForm({ onPresetModeChange, selectedPresetId, onSelectedPresetIdChange, - selectedWorkflowSteps, - onWorkflowStepsChange, - onDefaultOnApplied, + selectedWorkflowId, + onWorkflowIdChange, pendingImages, onImagesChange, tasks, @@ -212,7 +204,6 @@ export function TaskForm({ const hasInitialMoreOptions = (hideDependencies ? false : dependencies.length > 0) || pendingImages.length > 0 || - selectedWorkflowSteps.length > 0 || presetMode !== "default" || (priority ?? DEFAULT_TASK_PRIORITY) !== DEFAULT_TASK_PRIORITY || executorModel !== "" || @@ -239,7 +230,9 @@ export function TaskForm({ const [modelsLoading, setModelsLoading] = useState(false); const [settings, setSettings] = useState(null); const [globalSettings, setGlobalSettings] = useState(null); - const [workflowSteps, setWorkflowSteps] = useState([]); + // U6/R3: full workflow definitions for the picker (fragments excluded below). + const [workflows, setWorkflows] = useState([]); + const [workflowsLoading, setWorkflowsLoading] = useState(false); const [autoSaveStatus, setAutoSaveStatus] = useState<"idle" | "saving" | "saved">("idle"); const [baseBranchOptions, setBaseBranchOptions] = useState([]); const [baseBranchCustomMode, setBaseBranchCustomMode] = useState(false); @@ -276,13 +269,19 @@ export function TaskForm({ fetchSettings(projectId) .then((nextSettings) => setSettings(nextSettings)) .catch(() => setSettings(null)); - fetchWorkflowSteps(projectId) - .then((steps) => setWorkflowSteps(steps.filter((s) => s.enabled))) - .catch(() => setWorkflowSteps([])); + // U6/R3: load selectable workflows for the picker. Fragments are excluded + // (KTD-1) so they never appear as selectable task workflows. + if (onWorkflowIdChange) { + setWorkflowsLoading(true); + fetchWorkflows(projectId) + .then((defs) => setWorkflows(defs.filter((d) => d.kind !== "fragment"))) + .catch(() => setWorkflows([])) + .finally(() => setWorkflowsLoading(false)); + } fetchGlobalSettings() .then((nextGlobalSettings) => setGlobalSettings(nextGlobalSettings)) .catch(() => setGlobalSettings(null)); - }, [isActive, projectId]); + }, [isActive, projectId, onWorkflowIdChange]); const availablePresets = settings?.modelPresets || []; const selectedPreset = availablePresets.find((preset) => preset.id === selectedPresetId); @@ -292,7 +291,6 @@ export function TaskForm({ const hasMoreOptionSelections = (hideDependencies ? false : dependencies.length > 0) || pendingImages.length > 0 || - selectedWorkflowSteps.length > 0 || presetMode !== "default" || (priority ?? DEFAULT_TASK_PRIORITY) !== DEFAULT_TASK_PRIORITY || executorModel !== "" || @@ -321,30 +319,9 @@ export function TaskForm({ } }, [isActive, settings, availablePresets, mode]); - // Auto-select defaultOn workflow steps (create mode, once per activation) - const defaultOnAppliedRef = useRef(false); + // U6/R3: the workflow picker preselects the project default (undefined → + // "(default)"); there is no longer a per-step defaultOn auto-select effect. const githubTrackingDefaultAppliedRef = useRef(false); - useEffect(() => { - if (mode !== "create" || !isActive) return; - if (defaultOnAppliedRef.current) return; - if (workflowSteps.length === 0) return; - - const defaultOnSteps = workflowSteps.filter((s) => s.defaultOn); - if (defaultOnSteps.length === 0) return; - - defaultOnAppliedRef.current = true; - const stepIds = defaultOnSteps.map((s) => s.id); - onWorkflowStepsChange(stepIds); - onDefaultOnApplied?.(stepIds); - }, [mode, isActive, workflowSteps]); - - // Reset defaultOn tracking when form deactivates - useEffect(() => { - if (!isActive) { - defaultOnAppliedRef.current = false; - } - }, [isActive]); - useEffect(() => { if (mode !== "create" || !isActive) return; if (!onGithubTrackingEnabledChange) return; @@ -637,30 +614,8 @@ export function TaskForm({ } }, [favoriteModels, favoriteProviders]); - // Workflow step reorder helpers - const moveWorkflowStepUp = useCallback((index: number) => { - if (index <= 0) return; - const updated = [...selectedWorkflowSteps]; - [updated[index - 1], updated[index]] = [updated[index], updated[index - 1]]; - onWorkflowStepsChange(updated); - }, [selectedWorkflowSteps, onWorkflowStepsChange]); - - const moveWorkflowStepDown = useCallback((index: number) => { - if (index >= selectedWorkflowSteps.length - 1) return; - const updated = [...selectedWorkflowSteps]; - [updated[index], updated[index + 1]] = [updated[index + 1], updated[index]]; - onWorkflowStepsChange(updated); - }, [selectedWorkflowSteps, onWorkflowStepsChange]); - - const removeWorkflowStep = useCallback((stepId: string) => { - onWorkflowStepsChange(selectedWorkflowSteps.filter((id) => id !== stepId)); - }, [selectedWorkflowSteps, onWorkflowStepsChange]); - - // Build a lookup for step names. - const workflowStepLookup = new Map(); - for (const step of workflowSteps) { - workflowStepLookup.set(step.id, { name: step.name, description: step.description }); - } + // U6/R3: the project default workflow id (preselected + "(default)" badged). + const defaultWorkflowId = settings?.defaultWorkflowId ?? null; const availableDeps = tasks .filter((t) => !dependencies.includes(t.id)) @@ -1326,94 +1281,63 @@ export function TaskForm({ {renderBelowModelConfiguration} - {/* Workflow Steps */} -
- -
- - {t("taskForm.workflowStepsDescription", "Select steps to run after task implementation completes")} + {/* Workflow picker (U6/R3). A task picks a whole workflow at creation; the + selection is materialized atomically server-side via `workflowId`. */} + {onWorkflowIdChange && ( +
+ + {workflowsLoading ? ( +
+ {t("taskForm.workflowsLoading", "Loading workflows…")} +
+ ) : workflows.length === 0 ? ( + // Built-ins are always present, so an empty list means the fetch + // failed. No editor-open prop is plumbed through TaskForm, so we + // surface a plain-text CTA rather than inventing new prop wiring. +
+ {t("taskForm.workflowsCta", "Set up workflows in the editor")} +
+ ) : ( + + )} + + {t("taskForm.workflowHelp", "The selected workflow's steps run automatically around this task's execution.")} -
- {workflowSteps.length > 0 && workflowSteps.map((step) => ( - - ))} -
- - {/* Selected steps — execution order with reorder controls */} - {selectedWorkflowSteps.length > 1 && ( -
- {t("taskForm.executionOrderLabel", "Execution order:")} - {selectedWorkflowSteps.map((stepId, index) => { - const stepInfo = workflowStepLookup.get(stepId); - return ( -
- {index + 1} - {stepInfo?.name || stepId} -
- - - -
-
- ); - })} -
- )} -
+ )} {(onGithubTrackingEnabledChange || onGithubRepoOverrideChange) && (
diff --git a/packages/dashboard/app/components/__tests__/NewTaskModal.test.tsx b/packages/dashboard/app/components/__tests__/NewTaskModal.test.tsx index 6c48d0825e..3d76c9f86c 100644 --- a/packages/dashboard/app/components/__tests__/NewTaskModal.test.tsx +++ b/packages/dashboard/app/components/__tests__/NewTaskModal.test.tsx @@ -16,11 +16,6 @@ vi.mock("lucide-react", () => ({ Workflow: () => null, })); -// Mock WorkflowSelector to avoid API calls in tests -vi.mock("../WorkflowSelector", () => ({ - WorkflowSelector: ({ label }: { label?: string }) =>
{label ?? "Workflow"}
, -})); - // Mock the api module vi.mock("../../api", () => ({ uploadAttachment: vi.fn().mockResolvedValue({}), @@ -33,7 +28,9 @@ vi.mock("../../api", () => ({ autoSelectModelPreset: false, defaultPresetBySize: {}, }), - fetchWorkflowSteps: vi.fn().mockResolvedValue([]), + // U6/R3: TaskForm's picker fetches whole workflows; the per-step + // fetchWorkflowSteps + post-create selectTaskWorkflow flow is gone. + fetchWorkflows: vi.fn().mockResolvedValue([]), fetchGlobalSettings: vi.fn().mockResolvedValue({}), fetchGitBranches: vi.fn().mockResolvedValue([]), fetchAgents: vi.fn().mockResolvedValue([]), @@ -41,7 +38,6 @@ vi.mock("../../api", () => ({ refineText: vi.fn(), getRefineErrorMessage: vi.fn((err) => err?.message || "Failed to refine text. Please try again."), updateGlobalSettings: vi.fn().mockResolvedValue({}), - selectTaskWorkflow: vi.fn().mockResolvedValue({ workflowId: null, enabledWorkflowSteps: [] }), })); const mockConfirm = vi.fn(); @@ -165,10 +161,10 @@ describe("NewTaskModal", () => { expect(toggle).toHaveAttribute("aria-expanded", "true"); expect(moreOptions).not.toHaveAttribute("hidden"); }); - // Model Configuration, Attachments, and Workflow Steps are revealed + // Model Configuration, Attachments, and the Workflow picker are revealed expect(screen.getByText(/Model Configuration/i)).toBeTruthy(); expect(screen.getByText(/Attachments/i)).toBeTruthy(); - expect(screen.getByText(/Workflow Steps/i)).toBeTruthy(); + expect(screen.getByText("Workflow")).toBeTruthy(); }); it("shows dependencies and agent picker by default without expanding More options", () => { @@ -595,227 +591,91 @@ describe("NewTaskModal", () => { }); // Workflow step ordering tests (FN-836) - describe("workflow step ordering", () => { - it("sends selected enabledWorkflowSteps in create payload", async () => { - const { fetchWorkflowSteps } = await import("../../api"); - vi.mocked(fetchWorkflowSteps).mockResolvedValueOnce([ - { id: "WS-001", name: "QA Check", description: "Run tests", prompt: "Check tests", mode: "prompt" as const, enabled: true, createdAt: "", updatedAt: "" }, - ]); + describe("workflow selection (U6/R3)", () => { + function mockWorkflows(defs: Array<{ id: string; name: string; kind?: "workflow" | "fragment" }>) { + return import("../../api").then(({ fetchWorkflows }) => { + vi.mocked(fetchWorkflows).mockResolvedValueOnce( + defs.map((d) => ({ + id: d.id, + name: d.name, + description: "", + kind: d.kind ?? "workflow", + ir: { version: "v1", name: d.name, nodes: [], edges: [] }, + layout: {}, + createdAt: "", + updatedAt: "", + })) as any, + ); + }); + } + it("omits workflowId from the payload when the picker is untouched (inherit default)", async () => { + await mockWorkflows([{ id: "WF-1", name: "QA" }]); const { props } = renderNewTaskModal(); await waitFor(() => { - expect(screen.getByTestId("workflow-step-checkbox-WS-001")).toBeTruthy(); + expect(screen.getByTestId("task-workflow-select")).toBeTruthy(); }); - const checkbox = screen.getByTestId("workflow-step-checkbox-WS-001").querySelector('input[type="checkbox"]') as HTMLInputElement; - fireEvent.click(checkbox); + fireEvent.change(screen.getByRole("textbox"), { target: { value: "Inherit default" } }); + fireEvent.click(screen.getByRole("button", { name: "Create Task" })); - fireEvent.change(screen.getByRole('textbox'), { target: { value: "Task with workflow step" } }); + await waitFor(() => { + expect(props.onCreateTask).toHaveBeenCalled(); + }); + const payload = vi.mocked(props.onCreateTask).mock.calls[0][0] as Record; + expect("workflowId" in payload).toBe(false); + }); + + it("sends the chosen workflowId in the create payload", async () => { + await mockWorkflows([{ id: "WF-1", name: "QA" }]); + const { props } = renderNewTaskModal(); + + await waitFor(() => { + expect(screen.getByTestId("task-workflow-select")).toBeTruthy(); + }); + + fireEvent.change(screen.getByTestId("task-workflow-select"), { target: { value: "WF-1" } }); + fireEvent.change(screen.getByRole("textbox"), { target: { value: "Pick a workflow" } }); fireEvent.click(screen.getByRole("button", { name: "Create Task" })); await waitFor(() => { expect(props.onCreateTask).toHaveBeenCalledWith( - expect.objectContaining({ - enabledWorkflowSteps: ["WS-001"], - }), + expect.objectContaining({ workflowId: "WF-1" }), ); }); }); - it("sends ordered enabledWorkflowSteps in create payload when steps are selected in order", async () => { - const { fetchWorkflowSteps } = await import("../../api"); - vi.mocked(fetchWorkflowSteps).mockResolvedValueOnce([ - { id: "WS-001", name: "QA Check", description: "Run tests", prompt: "Check tests", mode: "prompt" as const, enabled: true, createdAt: "", updatedAt: "" }, - { id: "WS-002", name: "Security Audit", description: "Check security", prompt: "Check security", mode: "prompt" as const, enabled: true, createdAt: "", updatedAt: "" }, - ]); - + it("sends workflowId: null when 'No workflow' is chosen", async () => { + await mockWorkflows([{ id: "WF-1", name: "QA" }]); const { props } = renderNewTaskModal(); await waitFor(() => { - expect(screen.getByTestId("workflow-step-checkbox-WS-001")).toBeTruthy(); + expect(screen.getByTestId("task-workflow-select")).toBeTruthy(); }); - // Select WS-001, then WS-002 — order should be preserved - const checkbox1 = screen.getByTestId("workflow-step-checkbox-WS-001").querySelector('input[type="checkbox"]') as HTMLInputElement; - fireEvent.click(checkbox1); - - const checkbox2 = screen.getByTestId("workflow-step-checkbox-WS-002").querySelector('input[type="checkbox"]') as HTMLInputElement; - fireEvent.click(checkbox2); - - // Type description and submit - fireEvent.change(screen.getByRole('textbox'), { target: { value: "Ordered task" } }); + // Pick a workflow, then switch to "No workflow" to register an explicit null. + fireEvent.change(screen.getByTestId("task-workflow-select"), { target: { value: "WF-1" } }); + fireEvent.change(screen.getByTestId("task-workflow-select"), { target: { value: "__none__" } }); + fireEvent.change(screen.getByRole("textbox"), { target: { value: "No workflow task" } }); fireEvent.click(screen.getByRole("button", { name: "Create Task" })); await waitFor(() => { expect(props.onCreateTask).toHaveBeenCalledWith( - expect.objectContaining({ - enabledWorkflowSteps: ["WS-001", "WS-002"], - }), + expect.objectContaining({ workflowId: null }), ); }); }); - it("sends reordered enabledWorkflowSteps after user reorders steps", async () => { - const { fetchWorkflowSteps } = await import("../../api"); - vi.mocked(fetchWorkflowSteps).mockResolvedValueOnce([ - { id: "WS-001", name: "QA Check", description: "Run tests", prompt: "Check tests", mode: "prompt" as const, enabled: true, createdAt: "", updatedAt: "" }, - { id: "WS-002", name: "Security Audit", description: "Check security", prompt: "Check security", mode: "prompt" as const, enabled: true, createdAt: "", updatedAt: "" }, - ]); - - const { props } = renderNewTaskModal(); - - await waitFor(() => { - expect(screen.getByTestId("workflow-step-checkbox-WS-001")).toBeTruthy(); - }); - - // Select WS-001, then WS-002 - const checkbox1 = screen.getByTestId("workflow-step-checkbox-WS-001").querySelector('input[type="checkbox"]') as HTMLInputElement; - fireEvent.click(checkbox1); - - const checkbox2 = screen.getByTestId("workflow-step-checkbox-WS-002").querySelector('input[type="checkbox"]') as HTMLInputElement; - fireEvent.click(checkbox2); - - // Now reorder: move WS-002 up - await waitFor(() => { - expect(screen.getByTestId("workflow-step-move-up-WS-002")).toBeTruthy(); - }); - fireEvent.click(screen.getByTestId("workflow-step-move-up-WS-002")); - - // Type description and submit - fireEvent.change(screen.getByRole('textbox'), { target: { value: "Reordered task" } }); - fireEvent.click(screen.getByRole("button", { name: "Create Task" })); - - await waitFor(() => { - expect(props.onCreateTask).toHaveBeenCalledWith( - expect.objectContaining({ - enabledWorkflowSteps: ["WS-002", "WS-001"], - }), - ); - }); - }); - - }); - - // DefaultOn workflow step handling (FN-883) - describe("defaultOn workflow step handling", () => { - it("keeps More options collapsed by default even when defaultOn workflow steps are auto-applied", async () => { - const { fetchWorkflowSteps } = await import("../../api"); - vi.mocked(fetchWorkflowSteps).mockResolvedValueOnce([ - { id: "WS-001", name: "QA Check", description: "Run tests", prompt: "Check tests", mode: "prompt" as const, enabled: true, defaultOn: true, createdAt: "", updatedAt: "" }, - ]); - + it("does not render the legacy per-step checkbox UI", async () => { + await mockWorkflows([{ id: "WF-1", name: "QA" }]); renderNewTaskModal(); await waitFor(() => { - expect(screen.getByTestId("workflow-step-checkbox-WS-001")).toBeTruthy(); - }); - - const toggle = screen.getByTestId("task-form-more-options-toggle"); - expect(toggle).toHaveAttribute("aria-expanded", "false"); - - fireEvent.click(toggle); - - await waitFor(() => { - expect(toggle).toHaveAttribute("aria-expanded", "true"); - }); - - expect(screen.getByText(/Model Configuration/i)).toBeTruthy(); - expect(screen.getByText(/Attachments/i)).toBeTruthy(); - expect(screen.getByText(/Workflow Steps/i)).toBeTruthy(); - }); - - it("sends undefined enabledWorkflowSteps when no defaultOn steps and user hasn't interacted", async () => { - const { fetchWorkflowSteps } = await import("../../api"); - vi.mocked(fetchWorkflowSteps).mockResolvedValueOnce([ - { id: "WS-001", name: "QA Check", description: "Run tests", prompt: "Check tests", mode: "prompt" as const, enabled: true, createdAt: "", updatedAt: "" }, - ]); - - const { props } = renderNewTaskModal(); - - await waitFor(() => { - expect(screen.getByTestId("workflow-step-checkbox-WS-001")).toBeTruthy(); - }); - - // Don't interact with workflow steps at all - fireEvent.change(screen.getByRole('textbox'), { target: { value: "No interaction task" } }); - fireEvent.click(screen.getByRole("button", { name: "Create Task" })); - - await waitFor(() => { - expect(props.onCreateTask).toHaveBeenCalledWith( - expect.objectContaining({ - enabledWorkflowSteps: undefined, - }), - ); - }); - }); - - it("sends empty array when user explicitly deselects all defaultOn steps", async () => { - const { fetchWorkflowSteps } = await import("../../api"); - vi.mocked(fetchWorkflowSteps).mockResolvedValueOnce([ - { id: "WS-001", name: "QA Check", description: "Run tests", prompt: "Check tests", mode: "prompt" as const, enabled: true, defaultOn: true, createdAt: "", updatedAt: "" }, - ]); - - const { props } = renderNewTaskModal(); - - await waitFor(() => { - expect(screen.getByTestId("workflow-step-checkbox-WS-001")).toBeTruthy(); - }); - - // Wait for auto-selection to happen - await waitFor(() => { - const checkbox = screen.getByTestId("workflow-step-checkbox-WS-001").querySelector('input[type="checkbox"]') as HTMLInputElement; - expect(checkbox.checked).toBe(true); - }); - - // User explicitly deselects the auto-selected step - const checkbox = screen.getByTestId("workflow-step-checkbox-WS-001").querySelector('input[type="checkbox"]') as HTMLInputElement; - fireEvent.click(checkbox); - - fireEvent.change(screen.getByRole('textbox'), { target: { value: "Deselected task" } }); - fireEvent.click(screen.getByRole("button", { name: "Create Task" })); - - await waitFor(() => { - expect(props.onCreateTask).toHaveBeenCalledWith( - expect.objectContaining({ - enabledWorkflowSteps: [], - }), - ); - }); - }); - - it("sends defaultOn step IDs when user doesn't modify the auto-selected steps", async () => { - const { fetchWorkflowSteps } = await import("../../api"); - vi.mocked(fetchWorkflowSteps).mockResolvedValueOnce([ - { id: "WS-001", name: "QA Check", description: "Run tests", prompt: "Check tests", mode: "prompt" as const, enabled: true, defaultOn: true, createdAt: "", updatedAt: "" }, - { id: "WS-002", name: "Security", description: "Check security", prompt: "Check", mode: "prompt" as const, enabled: true, defaultOn: false, createdAt: "", updatedAt: "" }, - ]); - - const { props } = renderNewTaskModal(); - - await waitFor(() => { - expect(screen.getByTestId("workflow-step-checkbox-WS-001")).toBeTruthy(); - }); - - // Wait for auto-selection to happen - await waitFor(() => { - const checkbox = screen.getByTestId("workflow-step-checkbox-WS-001").querySelector('input[type="checkbox"]') as HTMLInputElement; - expect(checkbox.checked).toBe(true); - }); - - // Don't modify the selection — just submit. - // Since user hasn't explicitly changed steps, the explicitlySet flag is false, - // so the modal sends undefined (backend applies its own defaults) - fireEvent.change(screen.getByRole('textbox'), { target: { value: "Auto-selected task" } }); - fireEvent.click(screen.getByRole("button", { name: "Create Task" })); - - await waitFor(() => { - expect(props.onCreateTask).toHaveBeenCalledWith( - expect.objectContaining({ - enabledWorkflowSteps: undefined, - }), - ); + expect(screen.getByTestId("task-workflow-select")).toBeTruthy(); }); + expect(screen.queryByTestId("workflow-step-order")).toBeNull(); + expect(document.querySelector('[data-testid^="workflow-step-checkbox-"]')).toBeNull(); }); }); @@ -1195,12 +1055,12 @@ describe("NewTaskModal", () => { }); describe("GitHub tracking", () => { - it("renders GitHub tracking after Workflow Steps in more options", async () => { + it("renders GitHub tracking after the Workflow picker in more options", async () => { renderNewTaskModal(); fireEvent.click(screen.getByTestId("task-form-more-options-toggle")); - const workflowLabel = await screen.findByText("Workflow Steps"); + const workflowLabel = await screen.findByText("Workflow"); const githubTrackingSection = screen.getByTestId("task-form-github-tracking"); expect( diff --git a/packages/dashboard/app/components/__tests__/TaskDetailModal.inline-editing-and-integrations.test.tsx b/packages/dashboard/app/components/__tests__/TaskDetailModal.inline-editing-and-integrations.test.tsx index 8442dd2331..791ecaf674 100644 --- a/packages/dashboard/app/components/__tests__/TaskDetailModal.inline-editing-and-integrations.test.tsx +++ b/packages/dashboard/app/components/__tests__/TaskDetailModal.inline-editing-and-integrations.test.tsx @@ -236,14 +236,12 @@ describe("TaskDetailModal", () => { await waitFor(() => { const modelLabel = screen.getByText("Model Configuration"); const sourceLabel = screen.getByText("Source Issue"); - const workflowSection = screen.getByTestId("workflow-steps-section"); + // U6/R3: the per-step workflow section no longer renders in edit mode, + // so we only assert Source Issue stays below Model Configuration. expect( modelLabel.compareDocumentPosition(sourceLabel) & Node.DOCUMENT_POSITION_FOLLOWING, ).toBe(Node.DOCUMENT_POSITION_FOLLOWING); - expect( - sourceLabel.compareDocumentPosition(workflowSection) & Node.DOCUMENT_POSITION_FOLLOWING, - ).toBe(Node.DOCUMENT_POSITION_FOLLOWING); }); }); @@ -734,9 +732,11 @@ describe("TaskDetailModal", () => { // Enter edit mode fireEvent.click(container.querySelector(".modal-edit-btn")!); - // Model configuration and workflow steps should be present via TaskForm + // Model configuration is present via TaskForm in edit mode. + // U6/R3: the per-step "Workflow Steps" section was removed from TaskForm; + // workflow management for an existing task lives in the Workflow tab. expect(screen.getByText(/Model Configuration/i)).toBeTruthy(); - expect(screen.getByText(/Workflow Steps/i)).toBeTruthy(); + expect(screen.queryByText(/Workflow Steps/i)).toBeNull(); }); it("save sends only changed fields via updateTask", async () => { @@ -1611,57 +1611,6 @@ describe("TaskDetailModal", () => { }); - describe("Workflow step ordering in edit mode (FN-836)", () => { - it("sends ordered enabledWorkflowSteps when saving with reordered steps", async () => { - const { updateTask, fetchWorkflowSteps } = await import("../../api"); - const mockUpdate = vi.mocked(updateTask); - mockUpdate.mockResolvedValueOnce({ id: "FN-001" } as Task); - vi.mocked(fetchWorkflowSteps).mockResolvedValueOnce([ - { id: "WS-001", name: "QA Check", description: "Run tests", prompt: "Check tests", mode: "prompt" as const, enabled: true, createdAt: "", updatedAt: "" }, - { id: "WS-002", name: "Security Audit", description: "Check security", prompt: "Check security", mode: "prompt" as const, enabled: true, createdAt: "", updatedAt: "" }, - ]); - - const { container } = render( - , - ); - - // Enter edit mode - fireEvent.click(container.querySelector(".modal-edit-btn")!); - - // Wait for workflow steps to load and reorder controls to appear - await waitFor(() => { - expect(screen.getByTestId("workflow-step-order")).toBeTruthy(); - }); - - // Move WS-002 up (swap with WS-001) - fireEvent.click(screen.getByTestId("workflow-step-move-up-WS-002")); - - // Save - fireEvent.click(screen.getByText("Save")); - - await waitFor(() => { - expect(mockUpdate).toHaveBeenCalledWith("FN-001", expect.objectContaining({ - enabledWorkflowSteps: ["WS-002", "WS-001"], - }), undefined); - }); - }); - }); - - describe("agent assignment", () => { it("shows Assign Agent button when task has no assigned agent", () => { render( diff --git a/packages/dashboard/app/components/__tests__/TaskDetailModal.models-progress-workflow.test.tsx b/packages/dashboard/app/components/__tests__/TaskDetailModal.models-progress-workflow.test.tsx index 27b95d5733..6d4bd5c0a0 100644 --- a/packages/dashboard/app/components/__tests__/TaskDetailModal.models-progress-workflow.test.tsx +++ b/packages/dashboard/app/components/__tests__/TaskDetailModal.models-progress-workflow.test.tsx @@ -916,57 +916,6 @@ describe("TaskDetailModal", () => { }); }); }); - - describe("Workflow step ordering in edit mode (FN-836)", () => { - it("sends ordered enabledWorkflowSteps when saving with reordered steps", async () => { - const { updateTask, fetchWorkflowSteps } = await import("../../api"); - const mockUpdate = vi.mocked(updateTask); - mockUpdate.mockResolvedValueOnce({ id: "FN-001" } as Task); - vi.mocked(fetchWorkflowSteps).mockResolvedValueOnce([ - { id: "WS-001", name: "QA Check", description: "Run tests", prompt: "Check tests", mode: "prompt" as const, enabled: true, createdAt: "", updatedAt: "" }, - { id: "WS-002", name: "Security Audit", description: "Check security", prompt: "Check security", mode: "prompt" as const, enabled: true, createdAt: "", updatedAt: "" }, - ]); - - const { container } = render( - , - ); - - // Enter edit mode - fireEvent.click(container.querySelector(".modal-edit-btn")!); - - // Wait for workflow steps to load and reorder controls to appear - await waitFor(() => { - expect(screen.getByTestId("workflow-step-order")).toBeTruthy(); - }); - - // Move WS-002 up (swap with WS-001) - fireEvent.click(screen.getByTestId("workflow-step-move-up-WS-002")); - - // Save - fireEvent.click(screen.getByText("Save")); - - await waitFor(() => { - expect(mockUpdate).toHaveBeenCalledWith("FN-001", expect.objectContaining({ - enabledWorkflowSteps: ["WS-002", "WS-001"], - }), undefined); - }); - }); - }); - describe("Workflow tab", () => { it.each<[string, Parameters[0]]>([ ["empty enabledWorkflowSteps", { enabledWorkflowSteps: [] }], diff --git a/packages/dashboard/app/components/__tests__/TaskForm.test.tsx b/packages/dashboard/app/components/__tests__/TaskForm.test.tsx index 2cf5578289..ae209f3dee 100644 --- a/packages/dashboard/app/components/__tests__/TaskForm.test.tsx +++ b/packages/dashboard/app/components/__tests__/TaskForm.test.tsx @@ -25,7 +25,8 @@ vi.mock("../../api", () => ({ autoSelectModelPreset: false, defaultPresetBySize: {}, }), - fetchWorkflowSteps: vi.fn().mockResolvedValue([]), + // U6/R3: TaskForm now fetches whole workflows (not steps) for the picker. + fetchWorkflows: vi.fn().mockResolvedValue([]), fetchGlobalSettings: vi.fn().mockResolvedValue({}), refineText: vi.fn().mockResolvedValue("Refined text"), getRefineErrorMessage: vi.fn((err) => err?.message || "Failed to refine text. Please try again."), @@ -64,8 +65,8 @@ function renderTaskForm(props: Partial> = onPresetModeChange: vi.fn(), selectedPresetId: "", onSelectedPresetIdChange: vi.fn(), - selectedWorkflowSteps: [], - onWorkflowStepsChange: vi.fn(), + selectedWorkflowId: undefined, + onWorkflowIdChange: vi.fn(), pendingImages: [], onImagesChange: vi.fn(), tasks: [], @@ -96,8 +97,6 @@ function renderTaskFormWithDescriptionState(props: Partial { expect(container.querySelector(".inline-create-previews")).toBeTruthy(); }); - it("calls onWorkflowStepsChange when a fetched workflow step is toggled", async () => { - const { fetchWorkflowSteps } = await import("../../api"); - vi.mocked(fetchWorkflowSteps).mockResolvedValueOnce([ + it("renders a fetched workflow as a dropdown option (U6/R3)", async () => { + const { fetchWorkflows } = await import("../../api"); + vi.mocked(fetchWorkflows).mockResolvedValueOnce([ { - id: "WS-005", + id: "WF-1", name: "Browser Verification", description: "Verify in browser", - prompt: "Run browser verification", - templateId: "browser-verification", - mode: "prompt" as const, - enabled: true, + kind: "workflow", + ir: { version: "v1", name: "Browser Verification", nodes: [], edges: [] }, + layout: {}, createdAt: "", updatedAt: "", }, - ]); + ] as any); - const onWorkflowStepsChange = vi.fn(); - renderTaskForm({ onWorkflowStepsChange }); + const onWorkflowIdChange = vi.fn(); + renderTaskForm({ onWorkflowIdChange }); await waitFor(() => { - expect(screen.getByTestId("workflow-step-checkbox-WS-005")).toBeTruthy(); + expect(screen.getByTestId("task-workflow-select")).toBeTruthy(); }); - const checkbox = screen.getByTestId("workflow-step-checkbox-WS-005").querySelector('input[type="checkbox"]') as HTMLInputElement; - fireEvent.click(checkbox); - - expect(onWorkflowStepsChange).toHaveBeenCalledWith(["WS-005"]); + const select = screen.getByTestId("task-workflow-select") as HTMLSelectElement; + fireEvent.change(select, { target: { value: "WF-1" } }); + expect(onWorkflowIdChange).toHaveBeenCalledWith("WF-1"); }); it("disables all inputs when disabled prop is true", () => { @@ -1004,405 +1001,129 @@ describe("TaskForm preset selection (FN-819)", () => { }); }); -describe("TaskForm workflow step reordering (FN-836)", () => { +describe("TaskForm workflow picker (U6/R3)", () => { beforeEach(() => { vi.clearAllMocks(); }); - it("does not show reorder controls when no steps are selected", () => { - renderTaskForm({ selectedWorkflowSteps: [] }); - expect(screen.queryByTestId("workflow-step-order")).toBeNull(); - }); - - it("does not show reorder controls when only one step is selected", () => { - renderTaskForm({ selectedWorkflowSteps: ["WS-001"] }); - expect(screen.queryByTestId("workflow-step-order")).toBeNull(); - }); - - it("shows reorder controls when two or more steps are selected", async () => { - const { fetchWorkflowSteps } = await import("../../api"); - vi.mocked(fetchWorkflowSteps).mockResolvedValueOnce([ - { id: "WS-001", name: "QA Check", description: "Run tests", prompt: "Check tests", mode: "prompt" as const, enabled: true, createdAt: "", updatedAt: "" }, - { id: "WS-002", name: "Security Audit", description: "Check security", prompt: "Check security", mode: "prompt" as const, enabled: true, createdAt: "", updatedAt: "" }, - ]); - - renderTaskForm({ selectedWorkflowSteps: ["WS-001", "WS-002"] }); - - await waitFor(() => { - expect(screen.getByTestId("workflow-step-order")).toBeTruthy(); - }); - - expect(screen.getByTestId("workflow-step-order-item-WS-001")).toBeTruthy(); - expect(screen.getByTestId("workflow-step-order-item-WS-002")).toBeTruthy(); - }); - - it("shows numbered execution order", async () => { - const { fetchWorkflowSteps } = await import("../../api"); - vi.mocked(fetchWorkflowSteps).mockResolvedValueOnce([ - { id: "WS-001", name: "QA Check", description: "Run tests", prompt: "Check tests", mode: "prompt" as const, enabled: true, createdAt: "", updatedAt: "" }, - { id: "WS-002", name: "Security Audit", description: "Check security", prompt: "Check security", mode: "prompt" as const, enabled: true, createdAt: "", updatedAt: "" }, - ]); - - renderTaskForm({ selectedWorkflowSteps: ["WS-001", "WS-002"] }); - - await waitFor(() => { - expect(screen.getByText("1")).toBeTruthy(); - expect(screen.getByText("2")).toBeTruthy(); - }); - }); - - it("disables move-up button on first step", async () => { - const { fetchWorkflowSteps } = await import("../../api"); - vi.mocked(fetchWorkflowSteps).mockResolvedValueOnce([ - { id: "WS-001", name: "QA Check", description: "Run tests", prompt: "Check tests", mode: "prompt" as const, enabled: true, createdAt: "", updatedAt: "" }, - { id: "WS-002", name: "Security Audit", description: "Check security", prompt: "Check security", mode: "prompt" as const, enabled: true, createdAt: "", updatedAt: "" }, - ]); - - renderTaskForm({ selectedWorkflowSteps: ["WS-001", "WS-002"] }); - - await waitFor(() => { - expect(screen.getByTestId("workflow-step-order")).toBeTruthy(); - }); - - const moveUpFirst = screen.getByTestId("workflow-step-move-up-WS-001") as HTMLButtonElement; - expect(moveUpFirst.disabled).toBe(true); - }); - - it("disables move-down button on last step", async () => { - const { fetchWorkflowSteps } = await import("../../api"); - vi.mocked(fetchWorkflowSteps).mockResolvedValueOnce([ - { id: "WS-001", name: "QA Check", description: "Run tests", prompt: "Check tests", mode: "prompt" as const, enabled: true, createdAt: "", updatedAt: "" }, - { id: "WS-002", name: "Security Audit", description: "Check security", prompt: "Check security", mode: "prompt" as const, enabled: true, createdAt: "", updatedAt: "" }, - ]); - - renderTaskForm({ selectedWorkflowSteps: ["WS-001", "WS-002"] }); - - await waitFor(() => { - expect(screen.getByTestId("workflow-step-order")).toBeTruthy(); - }); - - const moveDownLast = screen.getByTestId("workflow-step-move-down-WS-002") as HTMLButtonElement; - expect(moveDownLast.disabled).toBe(true); - }); - - it("calls onWorkflowStepsChange with swapped order when move-up is clicked", async () => { - const { fetchWorkflowSteps } = await import("../../api"); - vi.mocked(fetchWorkflowSteps).mockResolvedValueOnce([ - { id: "WS-001", name: "QA Check", description: "Run tests", prompt: "Check tests", mode: "prompt" as const, enabled: true, createdAt: "", updatedAt: "" }, - { id: "WS-002", name: "Security Audit", description: "Check security", prompt: "Check security", mode: "prompt" as const, enabled: true, createdAt: "", updatedAt: "" }, - ]); - - const onWorkflowStepsChange = vi.fn(); - renderTaskForm({ selectedWorkflowSteps: ["WS-001", "WS-002"], onWorkflowStepsChange }); - - await waitFor(() => { - expect(screen.getByTestId("workflow-step-order")).toBeTruthy(); - }); - - // Move WS-002 up (swap with WS-001) - fireEvent.click(screen.getByTestId("workflow-step-move-up-WS-002")); - expect(onWorkflowStepsChange).toHaveBeenCalledWith(["WS-002", "WS-001"]); - }); - - it("calls onWorkflowStepsChange with swapped order when move-down is clicked", async () => { - const { fetchWorkflowSteps } = await import("../../api"); - vi.mocked(fetchWorkflowSteps).mockResolvedValueOnce([ - { id: "WS-001", name: "QA Check", description: "Run tests", prompt: "Check tests", mode: "prompt" as const, enabled: true, createdAt: "", updatedAt: "" }, - { id: "WS-002", name: "Security Audit", description: "Check security", prompt: "Check security", mode: "prompt" as const, enabled: true, createdAt: "", updatedAt: "" }, - ]); - - const onWorkflowStepsChange = vi.fn(); - renderTaskForm({ selectedWorkflowSteps: ["WS-001", "WS-002"], onWorkflowStepsChange }); - - await waitFor(() => { - expect(screen.getByTestId("workflow-step-order")).toBeTruthy(); - }); - - // Move WS-001 down (swap with WS-002) - fireEvent.click(screen.getByTestId("workflow-step-move-down-WS-001")); - expect(onWorkflowStepsChange).toHaveBeenCalledWith(["WS-002", "WS-001"]); - }); - - it("calls onWorkflowStepsChange with step removed when remove button is clicked", async () => { - const { fetchWorkflowSteps } = await import("../../api"); - vi.mocked(fetchWorkflowSteps).mockResolvedValueOnce([ - { id: "WS-001", name: "QA Check", description: "Run tests", prompt: "Check tests", mode: "prompt" as const, enabled: true, createdAt: "", updatedAt: "" }, - { id: "WS-002", name: "Security Audit", description: "Check security", prompt: "Check security", mode: "prompt" as const, enabled: true, createdAt: "", updatedAt: "" }, - ]); - - const onWorkflowStepsChange = vi.fn(); - renderTaskForm({ selectedWorkflowSteps: ["WS-001", "WS-002"], onWorkflowStepsChange }); - - await waitFor(() => { - expect(screen.getByTestId("workflow-step-order")).toBeTruthy(); - }); - - // Remove WS-001 - fireEvent.click(screen.getByTestId("workflow-step-remove-WS-001")); - expect(onWorkflowStepsChange).toHaveBeenCalledWith(["WS-002"]); - }); - - it("falls back to raw step ID in reorder list when metadata is missing", async () => { - const { fetchWorkflowSteps } = await import("../../api"); - vi.mocked(fetchWorkflowSteps).mockResolvedValueOnce([ - { id: "WS-001", name: "QA Check", description: "Run tests", prompt: "Check tests", mode: "prompt" as const, enabled: true, createdAt: "", updatedAt: "" }, - ]); - - renderTaskForm({ selectedWorkflowSteps: ["WS-001", "WS-999"] }); - - await waitFor(() => { - expect(screen.getByTestId("workflow-step-order")).toBeTruthy(); - }); - - const orderItem1 = screen.getByTestId("workflow-step-order-item-WS-001"); - const orderItem2 = screen.getByTestId("workflow-step-order-item-WS-999"); - - // Step metadata can resolve slightly after initial render under heavy suite load. - // Accept either the friendly name (preferred) or raw ID fallback. - expect(orderItem1.textContent).toMatch(/QA Check|WS-001/); - expect(orderItem2.textContent).toContain("WS-999"); - }); - - it("preserves order when adding a new step via checkbox after reorder", async () => { - const { fetchWorkflowSteps } = await import("../../api"); - vi.mocked(fetchWorkflowSteps).mockResolvedValueOnce([ - { id: "WS-001", name: "QA Check", description: "Run tests", prompt: "Check tests", mode: "prompt" as const, enabled: true, createdAt: "", updatedAt: "" }, - { id: "WS-002", name: "Security Audit", description: "Check security", prompt: "Check security", mode: "prompt" as const, enabled: true, createdAt: "", updatedAt: "" }, - { id: "WS-003", name: "Doc Review", description: "Check docs", prompt: "Check docs", mode: "prompt" as const, enabled: true, createdAt: "", updatedAt: "" }, - ]); - - const onWorkflowStepsChange = vi.fn(); - // Start with WS-001, WS-002 - renderTaskForm({ selectedWorkflowSteps: ["WS-001", "WS-002"], onWorkflowStepsChange }); - - await waitFor(() => { - expect(screen.getByTestId("workflow-step-order")).toBeTruthy(); - }); - - // Click checkbox to add WS-003 — it should be appended - const checkboxRow = await screen.findByTestId("workflow-step-checkbox-WS-003"); - const checkbox = checkboxRow.querySelector('input[type="checkbox"]') as HTMLInputElement; - fireEvent.click(checkbox); - - expect(onWorkflowStepsChange).toHaveBeenCalledWith(["WS-001", "WS-002", "WS-003"]); - }); - - it("preserves order when removing a step via remove action (not reorder remove)", async () => { - const { fetchWorkflowSteps } = await import("../../api"); - vi.mocked(fetchWorkflowSteps).mockResolvedValueOnce([ - { id: "WS-001", name: "QA Check", description: "Run tests", prompt: "Check tests", mode: "prompt" as const, enabled: true, createdAt: "", updatedAt: "" }, - { id: "WS-002", name: "Security Audit", description: "Check security", prompt: "Check security", mode: "prompt" as const, enabled: true, createdAt: "", updatedAt: "" }, - { id: "WS-003", name: "Doc Review", description: "Check docs", prompt: "Check docs", mode: "prompt" as const, enabled: true, createdAt: "", updatedAt: "" }, - ]); - - const onWorkflowStepsChange = vi.fn(); - renderTaskForm({ selectedWorkflowSteps: ["WS-001", "WS-002", "WS-003"], onWorkflowStepsChange }); - - await waitFor(() => { - expect(screen.getByTestId("workflow-step-order")).toBeTruthy(); - }); - - // Remove WS-002 via explicit remove action - fireEvent.click(screen.getByTestId("workflow-step-remove-WS-002")); - - expect(onWorkflowStepsChange).toHaveBeenCalledWith(["WS-001", "WS-003"]); - }); - - it("shows phase badge for workflow steps with phase info", async () => { - const { fetchWorkflowSteps } = await import("../../api"); - vi.mocked(fetchWorkflowSteps).mockResolvedValueOnce([ - { id: "WS-001", name: "QA Check", description: "Run tests", prompt: "Check tests", mode: "prompt" as const, phase: "pre-merge", enabled: true, createdAt: "", updatedAt: "" }, - { id: "WS-002", name: "Post-merge Notify", description: "Notify team", prompt: "Notify", mode: "prompt" as const, phase: "post-merge", enabled: true, createdAt: "", updatedAt: "" }, - ]); - - renderTaskForm({ selectedWorkflowSteps: [] }); - - await waitFor(() => { - expect(screen.getByTestId("workflow-step-phase-WS-001")).toHaveTextContent("Pre-merge"); - expect(screen.getByTestId("workflow-step-phase-WS-002")).toHaveTextContent("Post-merge"); - }); - }); - - it("shows Pre-merge phase badge for legacy steps without phase", async () => { - const { fetchWorkflowSteps } = await import("../../api"); - vi.mocked(fetchWorkflowSteps).mockResolvedValueOnce([ - { id: "WS-001", name: "Legacy Check", description: "No phase field", prompt: "Check", mode: "prompt" as const, enabled: true, createdAt: "", updatedAt: "" }, - ]); - - renderTaskForm({ selectedWorkflowSteps: [] }); - - await waitFor(() => { - expect(screen.getByTestId("workflow-step-phase-WS-001")).toHaveTextContent("Pre-merge"); - }); - }); -}); - -describe("TaskForm defaultOn auto-selection (FN-883)", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it("auto-selects defaultOn workflow steps in create mode", async () => { - const { fetchWorkflowSteps } = await import("../../api"); - vi.mocked(fetchWorkflowSteps).mockResolvedValueOnce([ - { id: "WS-001", name: "QA Check", description: "Run tests", prompt: "Check tests", mode: "prompt" as const, enabled: true, defaultOn: true, createdAt: "", updatedAt: "" }, - { id: "WS-002", name: "Security Audit", description: "Check security", prompt: "Check security", mode: "prompt" as const, enabled: true, defaultOn: false, createdAt: "", updatedAt: "" }, - ]); - - const onWorkflowStepsChange = vi.fn(); - const onDefaultOnApplied = vi.fn(); - renderTaskForm({ - mode: "create", - onWorkflowStepsChange, - onDefaultOnApplied, - }); - - await waitFor(() => { - expect(onWorkflowStepsChange).toHaveBeenCalledWith(["WS-001"]); - }); - expect(onDefaultOnApplied).toHaveBeenCalledWith(["WS-001"]); - }); - - it("auto-expands More options by default when advanced selections are prefilled", () => { - renderTaskForm({ - mode: "create", - selectedWorkflowSteps: ["WS-001"], - }); - - expect(screen.getByTestId("task-form-more-options-toggle")).toHaveAttribute("aria-expanded", "true"); - }); - - it("stays collapsed when auto-expand is disabled even with prefilled advanced selections", () => { - renderTaskForm({ - mode: "create", - selectedWorkflowSteps: ["WS-001"], - autoExpandMoreOptionsOnSelection: false, - }); - - expect(screen.getByTestId("task-form-more-options-toggle")).toHaveAttribute("aria-expanded", "false"); - }); - - it("hides advanced controls from interaction when More options is collapsed", () => { - renderTaskForm({ mode: "create" }); - - const toggle = screen.getByTestId("task-form-more-options-toggle"); - const moreOptions = screen.getByTestId("task-form-more-options"); - - expect(toggle).toHaveAttribute("aria-expanded", "false"); - expect(moreOptions).toHaveAttribute("hidden"); - expect(moreOptions).toHaveAttribute("aria-hidden", "true"); - - fireEvent.click(toggle); - - expect(toggle).toHaveAttribute("aria-expanded", "true"); - expect(moreOptions).not.toHaveAttribute("hidden"); - expect(moreOptions).toHaveAttribute("aria-hidden", "false"); - }); - - it("does not auto-select workflow steps in edit mode", async () => { - const { fetchWorkflowSteps } = await import("../../api"); - vi.mocked(fetchWorkflowSteps).mockResolvedValueOnce([ - { id: "WS-001", name: "QA Check", description: "Run tests", prompt: "Check tests", mode: "prompt" as const, enabled: true, defaultOn: true, createdAt: "", updatedAt: "" }, - ]); - - const onWorkflowStepsChange = vi.fn(); - renderTaskForm({ - mode: "edit", - title: "Test", - onTitleChange: vi.fn(), - onWorkflowStepsChange, - }); - - // Wait for workflow steps to load - await waitFor(() => { - expect(fetchWorkflowSteps).toHaveBeenCalled(); - }); - - // Should NOT have called onWorkflowStepsChange with defaults - expect(onWorkflowStepsChange).not.toHaveBeenCalled(); - }); - - it("does not re-apply defaults after user changes workflow steps", async () => { - const { fetchWorkflowSteps } = await import("../../api"); - vi.mocked(fetchWorkflowSteps).mockResolvedValueOnce([ - { id: "WS-001", name: "QA Check", description: "Run tests", prompt: "Check tests", mode: "prompt" as const, enabled: true, defaultOn: true, createdAt: "", updatedAt: "" }, - { id: "WS-002", name: "Security Audit", description: "Check security", prompt: "Check security", mode: "prompt" as const, enabled: true, defaultOn: false, createdAt: "", updatedAt: "" }, - ]); - - let currentSteps: string[] = []; - const onWorkflowStepsChange = vi.fn((steps: string[]) => { - currentSteps = steps; - }); - - const { rerender } = renderTaskForm({ - mode: "create", - selectedWorkflowSteps: currentSteps, - onWorkflowStepsChange, - }); - - // Wait for auto-selection - await waitFor(() => { - expect(onWorkflowStepsChange).toHaveBeenCalledWith(["WS-001"]); - }); - - // Simulate parent state update by rerendering with new steps - rerender( - + async function mockWorkflows(defs: Array<{ id: string; name: string; kind?: "workflow" | "fragment" }>) { + const { fetchWorkflows } = await import("../../api"); + vi.mocked(fetchWorkflows).mockResolvedValueOnce( + defs.map((d) => ({ + id: d.id, + name: d.name, + description: "", + kind: d.kind ?? "workflow", + ir: { version: "v1", name: d.name, nodes: [], edges: [] }, + layout: {}, + createdAt: "", + updatedAt: "", + })) as any, ); + } - // Clear the mock to track further calls - onWorkflowStepsChange.mockClear(); - - // Simulate user toggling WS-002 checkbox - const checkbox = screen.getByTestId("workflow-step-checkbox-WS-002").querySelector('input[type="checkbox"]') as HTMLInputElement; - fireEvent.click(checkbox); - - // Should have been called with user action (adding WS-002 to existing WS-001) - expect(onWorkflowStepsChange).toHaveBeenCalledWith(["WS-001", "WS-002"]); - // No additional auto-selection calls - expect(onWorkflowStepsChange).toHaveBeenCalledTimes(1); - }); - - it("does not auto-select when no steps have defaultOn", async () => { - const { fetchWorkflowSteps } = await import("../../api"); - vi.mocked(fetchWorkflowSteps).mockResolvedValueOnce([ - { id: "WS-001", name: "QA Check", description: "Run tests", prompt: "Check tests", mode: "prompt" as const, enabled: true, createdAt: "", updatedAt: "" }, - { id: "WS-002", name: "Security Audit", description: "Check security", prompt: "Check security", mode: "prompt" as const, enabled: true, createdAt: "", updatedAt: "" }, - ]); - - const onWorkflowStepsChange = vi.fn(); - renderTaskForm({ - mode: "create", - onWorkflowStepsChange, - }); + it("renders the dropdown with 'No workflow' first and the help text", async () => { + await mockWorkflows([{ id: "WF-1", name: "QA" }]); + renderTaskForm({ onWorkflowIdChange: vi.fn() }); await waitFor(() => { - expect(fetchWorkflowSteps).toHaveBeenCalled(); + expect(screen.getByTestId("task-workflow-select")).toBeTruthy(); }); + const select = screen.getByTestId("task-workflow-select") as HTMLSelectElement; + expect(select.options[0].textContent).toBe("No workflow"); + expect(screen.getByTestId("task-workflow-help")).toBeTruthy(); + }); - // Should NOT have called onWorkflowStepsChange - expect(onWorkflowStepsChange).not.toHaveBeenCalled(); + it("badges the project default workflow with (default)", async () => { + const { fetchSettings } = await import("../../api"); + vi.mocked(fetchSettings).mockResolvedValueOnce({ + modelPresets: [], + autoSelectModelPreset: false, + defaultPresetBySize: {}, + defaultWorkflowId: "WF-1", + } as any); + await mockWorkflows([ + { id: "WF-1", name: "QA" }, + { id: "WF-2", name: "Docs" }, + ]); + + renderTaskForm({ onWorkflowIdChange: vi.fn() }); + + await waitFor(() => { + expect(screen.getByTestId("task-workflow-select")).toBeTruthy(); + }); + expect(screen.getByText("QA (default)")).toBeTruthy(); + }); + + it("excludes fragments from the dropdown", async () => { + await mockWorkflows([ + { id: "WF-1", name: "QA", kind: "workflow" }, + { id: "FRAG-1", name: "Doc Fragment", kind: "fragment" }, + ]); + renderTaskForm({ onWorkflowIdChange: vi.fn() }); + + await waitFor(() => { + expect(screen.getByTestId("task-workflow-select")).toBeTruthy(); + }); + const select = screen.getByTestId("task-workflow-select") as HTMLSelectElement; + const labels = Array.from(select.options).map((o) => o.textContent); + expect(labels).toContain("QA"); + expect(labels).not.toContain("Doc Fragment"); + }); + + it("passes the chosen workflow id via onWorkflowIdChange", async () => { + await mockWorkflows([{ id: "WF-1", name: "QA" }]); + const onWorkflowIdChange = vi.fn(); + renderTaskForm({ onWorkflowIdChange }); + + await waitFor(() => { + expect(screen.getByTestId("task-workflow-select")).toBeTruthy(); + }); + const select = screen.getByTestId("task-workflow-select") as HTMLSelectElement; + fireEvent.change(select, { target: { value: "WF-1" } }); + expect(onWorkflowIdChange).toHaveBeenCalledWith("WF-1"); + }); + + it("maps 'No workflow' to null", async () => { + await mockWorkflows([{ id: "WF-1", name: "QA" }]); + const onWorkflowIdChange = vi.fn(); + renderTaskForm({ onWorkflowIdChange, selectedWorkflowId: "WF-1" }); + + await waitFor(() => { + expect(screen.getByTestId("task-workflow-select")).toBeTruthy(); + }); + const select = screen.getByTestId("task-workflow-select") as HTMLSelectElement; + fireEvent.change(select, { target: { value: "__none__" } }); + expect(onWorkflowIdChange).toHaveBeenCalledWith(null); + }); + + it("shows a loading placeholder while workflows load", async () => { + const { fetchWorkflows } = await import("../../api"); + let resolveFn: (v: unknown) => void = () => {}; + vi.mocked(fetchWorkflows).mockReturnValueOnce( + new Promise((resolve) => { + resolveFn = resolve; + }) as any, + ); + renderTaskForm({ onWorkflowIdChange: vi.fn() }); + + expect(screen.getByTestId("task-workflow-loading")).toBeTruthy(); + resolveFn([]); + }); + + it("regression: no per-step checkboxes and no fetchWorkflowSteps usage", async () => { + await mockWorkflows([{ id: "WF-1", name: "QA" }]); + renderTaskForm({ onWorkflowIdChange: vi.fn() }); + + await waitFor(() => { + expect(screen.getByTestId("task-workflow-select")).toBeTruthy(); + }); + // The old per-step checkbox UI and execution-order controls are gone. + expect(screen.queryByTestId("workflow-step-order")).toBeNull(); + expect(document.querySelector('[data-testid^="workflow-step-checkbox-"]')).toBeNull(); + // The api mock no longer needs a fetchWorkflowSteps export — TaskForm never + // calls it. (If it still did, rendering above would have thrown on the + // missing mock export, so reaching this point is itself the regression proof.) }); }); @@ -1511,7 +1232,8 @@ describe("TaskForm focus behavior (FN-1459)", () => { }); const modelLabel = screen.getByText("Model Configuration"); - const workflowLabel = screen.getByText("Workflow Steps"); + // U6/R3: the per-step "Workflow Steps" section is now the "Workflow" picker. + const workflowLabel = screen.getByText("Workflow"); const injectedBottom = screen.getByTestId("injected-below-model"); const githubTrackingSection = screen.getByTestId("task-form-github-tracking"); @@ -1540,7 +1262,9 @@ describe("TaskForm focus behavior (FN-1459)", () => { // Dependencies label and dep-trigger should not be in the document expect(screen.queryByText("Dependencies")).toBeNull(); expect(screen.queryByRole("button", { name: /Add dependencies/i })).toBeNull(); - expect(screen.queryByText(/selected/i)).toBeNull(); + // The dependency "N selected" count must be absent (avoid matching the + // workflow picker's help copy, which also contains the word "selected"). + expect(screen.queryByText(/\d+ selected/i)).toBeNull(); }); it("does not auto-expand More options for dependency selections when hideDependencies is true", async () => { diff --git a/packages/dashboard/src/routes/__tests__/task-create-workflow-route.test.ts b/packages/dashboard/src/routes/__tests__/task-create-workflow-route.test.ts new file mode 100644 index 0000000000..c0ebb6cae6 --- /dev/null +++ b/packages/dashboard/src/routes/__tests__/task-create-workflow-route.test.ts @@ -0,0 +1,130 @@ +// @vitest-environment node +// +// U6/R3/KTD-4: HTTP integration coverage for the create-time `workflowId` +// parameter on POST /tasks. Exercises the route end-to-end against a REAL +// TaskStore via createApiRoutes: +// - workflowId → task's enabledWorkflowSteps populated atomically (the +// materialization happens inside createTask, not via a post-create select) +// - fragment id → 4xx (rejected before the task row is created) +// - unknown id → 4xx + +import { describe, it, expect, beforeEach, afterEach } 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"; + +/** Linear v1 workflow with two pre-merge steps that compiles + selects cleanly. */ +function linearIr(name: string): WorkflowIr { + return { + version: "v1", + name, + nodes: [ + { id: "start", kind: "start" }, + { id: "lint", kind: "gate", config: { name: "Lint", scriptName: "lint" } }, + { id: "spec", kind: "prompt", config: { name: "Spec", prompt: "check" } }, + { id: "end", kind: "end" }, + ], + edges: [ + { from: "start", to: "lint", condition: "success" }, + { from: "lint", to: "spec", condition: "success" }, + { from: "spec", to: "end", condition: "success" }, + ], + }; +} + +/** Single-node fragment IR (not selectable for a task). */ +function fragmentIr(): WorkflowIr { + return { + version: "v1", + name: "frag", + nodes: [ + { id: "start", kind: "start" }, + { id: "step-1", kind: "prompt", config: { name: "Doc", prompt: "doc it" } }, + { id: "end", kind: "end" }, + ], + edges: [ + { from: "start", to: "step-1", condition: "success" }, + { from: "step-1", to: "end", condition: "success" }, + ], + }; +} + +describe("POST /tasks workflowId (U6/R3)", () => { + let store: TaskStore; + let rootDir: string; + let globalDir: string; + let app: express.Express; + + beforeEach(async () => { + rootDir = mkdtempSync(join(tmpdir(), "task-wf-route-root-")); + globalDir = mkdtempSync(join(tmpdir(), "task-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" }); + + it("workflowId → created task has populated enabledWorkflowSteps", async () => { + const wf = await store.createWorkflowDefinition({ name: "QA", ir: linearIr("qa") }); + + const res = await post("/api/tasks", { description: "with workflow", workflowId: wf.id }); + expect(res.status).toBe(201); + const created = res.body as { id: string }; + + const detail = await store.getTask(created.id); + expect(detail.enabledWorkflowSteps).toHaveLength(2); + expect(store.getTaskWorkflowSelection(created.id)?.workflowId).toBe(wf.id); + }); + + it("workflowId: null → task created with no workflow steps", async () => { + const def = await store.createWorkflowDefinition({ name: "Default", ir: linearIr("def") }); + await store.setDefaultWorkflowId(def.id); + + const res = await post("/api/tasks", { description: "no workflow", workflowId: null }); + expect(res.status).toBe(201); + const created = res.body as { id: string }; + + const detail = await store.getTask(created.id); + expect(detail.enabledWorkflowSteps ?? []).toHaveLength(0); + expect(store.getTaskWorkflowSelection(created.id)).toBeUndefined(); + }); + + it("fragment id → 4xx, no task created", async () => { + const frag = await store.createWorkflowDefinition({ name: "Frag", ir: fragmentIr(), kind: "fragment" }); + const before = (await store.listTasks({ includeArchived: true })).length; + + const res = await post("/api/tasks", { description: "frag", workflowId: frag.id }); + expect(res.status).toBeGreaterThanOrEqual(400); + expect(res.status).toBeLessThan(500); + + const after = (await store.listTasks({ includeArchived: true })).length; + expect(after).toBe(before); + }); + + it("unknown id → 4xx, no task created", async () => { + const before = (await store.listTasks({ includeArchived: true })).length; + + const res = await post("/api/tasks", { description: "bad", workflowId: "WF-404" }); + expect(res.status).toBeGreaterThanOrEqual(400); + expect(res.status).toBeLessThan(500); + + const after = (await store.listTasks({ includeArchived: true })).length; + expect(after).toBe(before); + }); +}); diff --git a/packages/dashboard/src/routes/register-task-workflow-routes.ts b/packages/dashboard/src/routes/register-task-workflow-routes.ts index a876cc871e..683645dcc1 100644 --- a/packages/dashboard/src/routes/register-task-workflow-routes.ts +++ b/packages/dashboard/src/routes/register-task-workflow-routes.ts @@ -874,6 +874,7 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork dependencies, breakIntoSubtasks, enabledWorkflowSteps, + workflowId, modelPresetId, modelProvider, modelId, @@ -962,6 +963,13 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork } } + // Validate workflowId (U6/R3): undefined = inherit default, null = no + // workflow, string = that workflow. Unknown/fragment ids are rejected by + // the store below (mapped to 4xx in the catch handler). + if (workflowId !== undefined && workflowId !== null && typeof workflowId !== "string") { + throw badRequest("workflowId must be a string or null"); + } + // Check for summarize flag in request const summarize = req.body.summarize === true; @@ -1245,6 +1253,9 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork dependencies, breakIntoSubtasks, enabledWorkflowSteps, + // U6/R3: forward only when the client set it (string | null). Leaving it + // absent preserves the project-default inheritance behavior. + ...(workflowId !== undefined ? { workflowId: workflowId as string | null } : {}), modelPresetId: validateOptionalModelField(modelPresetId, "modelPresetId"), modelProvider: executorModel.provider ?? undefined, modelId: executorModel.modelId ?? undefined, @@ -1347,8 +1358,16 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork if (err instanceof ApiError) { throw err; } - const status = (err instanceof Error ? err.message : String(err)).includes("must be a string") || (err instanceof Error ? err.message : String(err)).includes("must be an array of strings") ? 400 : 500; - throw new ApiError(status, err instanceof Error ? err.message : String(err)); + const message = err instanceof Error ? err.message : String(err); + // U6/R3: workflowId validation failures from the store (unknown id / + // fragment id) are client errors, not server faults. + const isClientError = + message.includes("must be a string") + || message.includes("must be an array of strings") + || /^Workflow '.*' not found$/.test(message) + || /is a fragment and cannot be selected/.test(message); + const status = isClientError ? 400 : 500; + throw new ApiError(status, message); } }); diff --git a/packages/i18n/locales/en/app.json b/packages/i18n/locales/en/app.json index 4af97be9fa..f8f9f5bad9 100644 --- a/packages/i18n/locales/en/app.json +++ b/packages/i18n/locales/en/app.json @@ -6218,8 +6218,12 @@ "titlePlaceholder": "Task title", "useDropdown": "Use dropdown", "usingPreset": "Using preset: {{name}}", - "workflowStepsDescription": "Select steps to run after task implementation completes", - "workflowStepsLabel": "Workflow Steps", + "workflowDefaultBadge": "(default)", + "workflowHelp": "The selected workflow's steps run automatically around this task's execution.", + "workflowLabel": "Workflow", + "workflowNone": "No workflow", + "workflowsCta": "Set up workflows in the editor", + "workflowsLoading": "Loading workflows…", "workingBranchLabel": "Working branch" }, "taskHandlers": { diff --git a/packages/i18n/src/resources.d.ts b/packages/i18n/src/resources.d.ts index bd4df84d3e..5b0378badd 100644 --- a/packages/i18n/src/resources.d.ts +++ b/packages/i18n/src/resources.d.ts @@ -6220,8 +6220,12 @@ export default interface Resources { "titlePlaceholder": "Task title", "useDropdown": "Use dropdown", "usingPreset": "Using preset: {{name}}", - "workflowStepsDescription": "Select steps to run after task implementation completes", - "workflowStepsLabel": "Workflow Steps", + "workflowDefaultBadge": "(default)", + "workflowHelp": "The selected workflow's steps run automatically around this task's execution.", + "workflowLabel": "Workflow", + "workflowNone": "No workflow", + "workflowsCta": "Set up workflows in the editor", + "workflowsLoading": "Loading workflows…", "workingBranchLabel": "Working branch" }, "taskHandlers": {