From e5a0a199ea093039298cc73c24c8d3b3cfe34ec5 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Thu, 4 Jun 2026 01:46:51 -0700 Subject: [PATCH] feat(dashboard): node editor authors columns, traits, hold and split/join nodes with inline validation (U10) --- packages/dashboard/app/api/legacy.ts | 64 ++++ .../app/components/WorkflowColumnPanel.tsx | 205 +++++++++++ .../app/components/WorkflowNodeEditor.css | 127 +++++++ .../app/components/WorkflowNodeEditor.tsx | 319 +++++++++++++++--- .../__tests__/WorkflowNodeEditor.test.tsx | 164 ++++++++- .../__tests__/workflow-flow-mapping.test.ts | 211 +++++++++++- .../components/nodes/WorkflowNodeTypes.tsx | 60 +++- .../app/components/workflow-flow-mapping.ts | 308 +++++++++++++++-- .../src/__tests__/workflow-routes.test.ts | 17 + .../src/routes/register-workflow-routes.ts | 28 +- 10 files changed, 1416 insertions(+), 87 deletions(-) create mode 100644 packages/dashboard/app/components/WorkflowColumnPanel.tsx diff --git a/packages/dashboard/app/api/legacy.ts b/packages/dashboard/app/api/legacy.ts index 09e9b60a8a..a0a0b477f4 100644 --- a/packages/dashboard/app/api/legacy.ts +++ b/packages/dashboard/app/api/legacy.ts @@ -533,6 +533,49 @@ export function moveTask( }); } +/** Resolved trait flags for a board column (subset the client cares about). */ +export interface BoardWorkflowColumnFlags { + countsTowardWip?: boolean; + complete?: boolean; + archived?: boolean; + hiddenFromBoard?: boolean; + hold?: boolean; + intake?: boolean; + mergeBlocker?: boolean; + humanReview?: boolean; + [key: string]: boolean | undefined; +} + +export interface BoardWorkflowColumn { + id: string; + name: string; + flags: BoardWorkflowColumnFlags; +} + +export interface BoardWorkflowDefinition { + id: string; + name: string; + columns: BoardWorkflowColumn[]; +} + +export interface BoardWorkflowsPayload { + flagEnabled: boolean; + defaultWorkflowId: string; + workflows: BoardWorkflowDefinition[]; + taskWorkflowIds: Record; +} + +/** Fetch the multi-lane board metadata (U9). When the flag is OFF the server + * returns `{ flagEnabled: false }` and the board renders its legacy form. */ +export function fetchBoardWorkflows(projectId?: string): Promise { + return api(withProjectId("/tasks/board-workflows", projectId)); +} + +/** Manually promote a held card out of its hold column (U9). */ +export function promoteTask(id: string, projectId?: string): Promise { + return api(withProjectId(`/tasks/${id}/promote`, projectId), { method: "POST" }); +} + /** * Soft-deletes a task by setting `deletedAt` server-side while preserving the row/artifacts, * and keeping the task ID reserved. @@ -4958,6 +5001,27 @@ export function fetchWorkflows(projectId?: string): Promise api(path)); } +/** A trait catalog entry as returned by GET /api/traits (U10). Mirrors the + * registry's TraitDefinition projection (flags + hook descriptors + schema). */ +export interface TraitCatalogEntry { + id: string; + name: string; + description?: string; + builtin: boolean; + flags: import("@fusion/core").TraitFlags; + hooks?: import("@fusion/core").TraitHookDescriptors; + configSchema?: import("@fusion/core").TraitConfigSchema; +} + +/** Fetch the trait catalog (built-ins + registered plugin traits) for the + * workflow editor's trait picker. Registry-backed, read-only, session-scoped. */ +export function fetchTraits(projectId?: string): Promise { + const path = withProjectId("/traits", projectId); + return dedupe(path, () => + api<{ traits: TraitCatalogEntry[] }>(path).then((res) => res.traits), + ); +} + /** Fetch a single workflow definition. */ export function fetchWorkflow(id: string, projectId?: string): Promise { return api(withProjectId(`/workflows/${encodeURIComponent(id)}`, projectId)); diff --git a/packages/dashboard/app/components/WorkflowColumnPanel.tsx b/packages/dashboard/app/components/WorkflowColumnPanel.tsx new file mode 100644 index 0000000000..64a7ac6115 --- /dev/null +++ b/packages/dashboard/app/components/WorkflowColumnPanel.tsx @@ -0,0 +1,205 @@ +import { useCallback, useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Plus, Trash2, ChevronUp, ChevronDown, AlertTriangle } from "lucide-react"; +import type { WorkflowIrColumn, TraitViolation } from "@fusion/core"; +import { fetchTraits, type TraitCatalogEntry } from "../api"; +import { getErrorMessage } from "@fusion/core"; +import type { ToastType } from "../hooks/useToast"; + +interface WorkflowColumnPanelProps { + columns: WorkflowIrColumn[]; + onChange: (next: WorkflowIrColumn[]) => void; + /** Column-level composition violations (from validateColumnTraits) to surface + * on the offending column band. Keyed by column id; workflow-wide violations + * (columnId === null) are shown at the panel head. */ + violations: TraitViolation[]; + readOnly: boolean; + projectId?: string; + addToast: (message: string, type?: ToastType) => void; +} + +let columnSeq = 0; +function newColumnId(): string { + columnSeq += 1; + return `col-${Date.now().toString(36)}-${columnSeq}`; +} + +export function WorkflowColumnPanel({ + columns, + onChange, + violations, + readOnly, + projectId, + addToast, +}: WorkflowColumnPanelProps) { + const { t } = useTranslation("app"); + const [catalog, setCatalog] = useState([]); + + useEffect(() => { + fetchTraits(projectId) + .then(setCatalog) + .catch((err) => addToast(getErrorMessage(err) || t("workflowColumns.traitsLoadFailed", "Failed to load traits"), "error")); + }, [projectId, addToast, t]); + + const workflowWide = violations.filter((v) => v.columnId === null); + const violationsFor = useCallback( + (columnId: string) => violations.filter((v) => v.columnId === columnId), + [violations], + ); + + const addColumn = useCallback(() => { + const id = newColumnId(); + onChange([...columns, { id, name: t("workflowColumns.newColumnName", "New column"), traits: [] }]); + }, [columns, onChange, t]); + + const renameColumn = useCallback( + (id: string, name: string) => { + onChange(columns.map((c) => (c.id === id ? { ...c, name } : c))); + }, + [columns, onChange], + ); + + const removeColumn = useCallback( + (id: string) => { + onChange(columns.filter((c) => c.id !== id)); + }, + [columns, onChange], + ); + + const moveColumn = useCallback( + (index: number, dir: -1 | 1) => { + const target = index + dir; + if (target < 0 || target >= columns.length) return; + const next = [...columns]; + [next[index], next[target]] = [next[target], next[index]]; + onChange(next); + }, + [columns, onChange], + ); + + const toggleTrait = useCallback( + (columnId: string, traitId: string) => { + onChange( + columns.map((c) => { + if (c.id !== columnId) return c; + const has = c.traits.some((tr) => tr.trait === traitId); + return { + ...c, + traits: has + ? c.traits.filter((tr) => tr.trait !== traitId) + : [...c.traits, { trait: traitId }], + }; + }), + ); + }, + [columns, onChange], + ); + + return ( + + ); +} diff --git a/packages/dashboard/app/components/WorkflowNodeEditor.css b/packages/dashboard/app/components/WorkflowNodeEditor.css index fbd3c130b1..b8f2fcdee1 100644 --- a/packages/dashboard/app/components/WorkflowNodeEditor.css +++ b/packages/dashboard/app/components/WorkflowNodeEditor.css @@ -333,3 +333,130 @@ transform: rotate(360deg); } } + +/* ── U10: swimlane bands, column panel, error badges, read-only banner ── */ + +.wf-column-band { + border: 1px dashed var(--border); + background: var(--bg-secondary); + border-radius: var(--radius-md); + pointer-events: none; +} + +.wf-node--error { + border-color: var(--ws-error); +} + +.wf-node-error-badge { + display: inline-flex; + align-items: center; + gap: var(--space-xs); + font-size: 0.65rem; + padding: 1px var(--space-xs); + border-radius: var(--radius-sm); + background: var(--ws-error); + color: var(--bg); +} + +.wf-editor-readonly-banner { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-md); + padding: var(--space-sm) var(--space-md); + background: var(--bg-tertiary); + border-bottom: 1px solid var(--border); +} + +.wf-editor-duplicate-primary { + font-weight: 600; +} + +.wf-editor-banner--warn { + background: var(--ws-warning); + color: var(--bg); +} + +.wf-column-panel { + display: flex; + flex-direction: column; + gap: var(--space-sm); + width: 280px; + min-width: 260px; + padding: var(--space-md); + border-left: 1px solid var(--border); + overflow-y: auto; +} + +.wf-column-panel-header { + display: flex; + align-items: center; + justify-content: space-between; +} + +.wf-column-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: var(--space-sm); +} + +.wf-column-item { + border: 1px solid var(--border); + border-radius: var(--radius-md); + padding: var(--space-sm); + display: flex; + flex-direction: column; + gap: var(--space-xs); +} + +.wf-column-item--error { + border-color: var(--ws-error); +} + +.wf-column-item-head { + display: flex; + align-items: center; + gap: var(--space-xs); +} + +.wf-column-name { + flex: 1; + min-width: 0; +} + +.wf-column-item-actions { + display: flex; + gap: 2px; +} + +.wf-column-violation { + display: flex; + align-items: center; + gap: var(--space-xs); + font-size: 0.7rem; + color: var(--ws-error); + margin: 0; +} + +.wf-column-trait-options { + display: flex; + flex-wrap: wrap; + gap: var(--space-xs); +} + +.wf-column-trait { + display: inline-flex; + align-items: center; + gap: 2px; + font-size: 0.7rem; + color: var(--text-muted); +} + +.wf-column-traits-label { + font-size: 0.65rem; + text-transform: uppercase; + color: var(--text-tertiary); +} diff --git a/packages/dashboard/app/components/WorkflowNodeEditor.tsx b/packages/dashboard/app/components/WorkflowNodeEditor.tsx index c1f3512fb8..f440817993 100644 --- a/packages/dashboard/app/components/WorkflowNodeEditor.tsx +++ b/packages/dashboard/app/components/WorkflowNodeEditor.tsx @@ -14,8 +14,9 @@ import { type Node as FlowNode, type Edge as FlowEdge, } from "@xyflow/react"; -import { X, Plus, Trash2, Save, MessageSquare, Terminal, Shield, GitMerge, Loader2, HelpCircle } from "lucide-react"; -import type { WorkflowDefinition } from "@fusion/core"; +import { useTranslation } from "react-i18next"; +import { X, Plus, Trash2, Save, MessageSquare, Terminal, Shield, GitMerge, Loader2, HelpCircle, PauseCircle, Split, Merge } from "lucide-react"; +import type { WorkflowDefinition, WorkflowIrColumn, TraitViolation } from "@fusion/core"; import { getErrorMessage } from "@fusion/core"; import { fetchWorkflows, @@ -34,7 +35,20 @@ import type { ToastType } from "../hooks/useToast"; import { useOverlayDismiss } from "../hooks/useOverlayDismiss"; import { useModalResizePersist } from "../hooks/useModalResizePersist"; import { workflowNodeTypes, type WorkflowFlowNodeData, type WorkflowEditorNodeKind } from "./nodes/WorkflowNodeTypes"; -import { irToFlow, flowToIr, emptyWorkflowIr, emptyWorkflowLayout } from "./workflow-flow-mapping"; +import { + irToFlow, + flowToIr, + emptyWorkflowIr, + emptyWorkflowLayout, + columnsOf, + columnsToBandNodes, + columnForY, + validateColumnsClient, + unplacedNodeIds, + isColumnBandNode, +} from "./workflow-flow-mapping"; +import { fetchTraits, type TraitCatalogEntry } from "../api"; +import { WorkflowColumnPanel } from "./WorkflowColumnPanel"; import { CustomModelDropdown } from "./CustomModelDropdown"; type ExecutorKind = "model" | "agent" | "skill" | "cli"; @@ -76,6 +90,9 @@ const PALETTE: Array<{ kind: WorkflowEditorNodeKind; label: string; icon: typeof { kind: "script", label: "Script", icon: Terminal }, { kind: "gate", label: "Gate", icon: Shield }, { kind: "merge", label: "Merge boundary", icon: GitMerge }, + { kind: "hold", label: "Hold", icon: PauseCircle, presetConfig: { release: "manual" } }, + { kind: "split", label: "Split", icon: Split }, + { kind: "join", label: "Join", icon: Merge, presetConfig: { mode: "all", onBranchFailure: "collect" } }, ]; function InnerEditor({ @@ -92,10 +109,31 @@ function InnerEditor({ const [nodes, setNodes, onNodesChange] = useNodesState>([]); const [edges, setEdges, onEdgesChange] = useEdgesState([]); const [selectedNodeId, setSelectedNodeId] = useState(null); + const { t } = useTranslation("app"); + // v2 columns the editor is authoring for the active workflow. + const [columns, setColumns] = useState([]); + const [traitCatalog, setTraitCatalog] = useState([]); const activeWorkflow = useMemo(() => workflows.find((w) => w.id === activeId), [workflows, activeId]); const isBuiltin = !!activeWorkflow && isBuiltinWorkflowId(activeWorkflow.id); + // Trait catalog (for client-side composition validation; the panel fetches its + // own copy for the picker, but the editor needs the flags to validate). + useEffect(() => { + fetchTraits(projectId).then(setTraitCatalog).catch(() => { + // Non-fatal: validation degrades to server-side parse on save. + }); + }, [projectId]); + + // Composition violations (client mirror of validateColumnTraits). + const columnViolations: TraitViolation[] = useMemo( + () => (columns.length ? validateColumnsClient(columns, traitCatalog) : []), + [columns, traitCatalog], + ); + // Step nodes not placed in any column (v2 only). + const unplaced = useMemo(() => unplacedNodeIds(nodes, columns), [nodes, columns]); + const blockingViolationCount = columnViolations.filter((v) => v.severity === "error").length; + const loadWorkflows = useCallback(async () => { setLoading(true); try { @@ -118,15 +156,30 @@ function InnerEditor({ if (!activeWorkflow) { setNodes([]); setEdges([]); + setColumns([]); return; } const flow = irToFlow(activeWorkflow); setNodes(flow.nodes); setEdges(flow.edges); + setColumns(columnsOf(activeWorkflow)); setSelectedNodeId(null); setValidationError(null); }, [activeWorkflow, setNodes, setEdges]); + // Server-reported node error (e.g. seam-in-branch) attributed to a node id. + const [serverNodeError, setServerNodeError] = useState<{ nodeId: string; message: string } | null>(null); + + // Keep the swimlane band group nodes in sync with the authored columns + // (add/rename/reorder via the column panel). Step nodes are preserved; only + // the band nodes are replaced. + useEffect(() => { + setNodes((ns) => { + const stepNodes = ns.filter((n) => !isColumnBandNode(n.id) && n.type !== "group"); + return [...columnsToBandNodes(columns), ...stepNodes]; + }); + }, [columns, setNodes]); + const onConnect = useCallback( (connection: Connection) => { setEdges((eds) => @@ -136,6 +189,20 @@ function InnerEditor({ [setEdges], ); + // Dragging a step node into a column band sets node.column (position-based + // hit testing against the ordered bands — see workflow-flow-mapping). + const onNodeDragStop = useCallback( + (_evt: unknown, node: FlowNode) => { + if (isColumnBandNode(node.id) || columns.length === 0) return; + const column = columnForY(node.position.y, columns); + if (!column) return; + setNodes((ns) => + ns.map((n) => (n.id === node.id ? { ...n, data: { ...n.data, column } } : n)), + ); + }, + [columns, setNodes], + ); + const addNode = useCallback( (kind: WorkflowEditorNodeKind, nodeLabel?: string, presetConfig?: Record) => { const id = newNodeId(); @@ -245,27 +312,75 @@ function InnerEditor({ const handleSave = useCallback(async () => { if (!activeWorkflow) return; if (isBuiltinWorkflowId(activeWorkflow.id)) return; // built-ins are read-only + + // Block save on client-detected violations before any round-trip: + // - unplaced step nodes (rendered as inline node badges + summary count); + // - trait composition errors (rendered on the offending column band). + if (unplaced.length > 0) { + const message = t( + "workflowColumns.unplacedCount", + "{{count}} nodes not placed in a column", + { count: unplaced.length }, + ); + setValidationError(message); + addToast(message, "error"); + return; + } + if (blockingViolationCount > 0) { + const message = t( + "workflowColumns.compositionBlocked", + "Resolve trait conflicts on highlighted columns before saving", + ); + setValidationError(message); + addToast(message, "error"); + return; + } + setSaving(true); setValidationError(null); + setServerNodeError(null); try { - const { ir, layout } = flowToIr(activeWorkflow.name, nodes, edges); + const { ir, layout } = flowToIr(activeWorkflow.name, nodes, edges, columns.length ? columns : undefined); const updated = await updateWorkflow(activeWorkflow.id, { ir, layout }, projectId); setWorkflows((ws) => ws.map((w) => (w.id === updated.id ? updated : w))); // Validate by compiling — surfaces non-linear graphs as a banner. try { await compileWorkflow(updated.id, projectId); - addToast("Workflow saved", "success"); + addToast(t("workflows.saved", "Workflow saved"), "success"); } catch (compileErr) { - setValidationError(getErrorMessage(compileErr) || "Workflow saved but cannot be compiled"); + setValidationError( + getErrorMessage(compileErr) || t("workflows.savedNotCompilable", "Workflow saved but cannot be compiled"), + ); } } catch (err) { - const message = getErrorMessage(err) || "Failed to save workflow"; + const message = getErrorMessage(err) || t("workflows.saveFailed", "Failed to save workflow"); + // parseWorkflowIr (server) names the offending node for structural errors + // like seam-in-branch ("seam 'merge' node 'n-…' is forbidden inside …"). + // Attribute it to that node so the shared error badge renders on it. + const nodeMatch = /node '([^']+)'/.exec(message); + if (nodeMatch && nodes.some((n) => n.id === nodeMatch[1])) { + setServerNodeError({ nodeId: nodeMatch[1], message }); + } setValidationError(message); addToast(message, "error"); } finally { setSaving(false); } - }, [activeWorkflow, nodes, edges, projectId, addToast]); + }, [activeWorkflow, nodes, edges, columns, unplaced, blockingViolationCount, projectId, addToast, t]); + + // Stamp the shared error-state badge onto offending nodes: unplaced step + // nodes and any node the server flagged (seam-in-branch). One component + // (WorkflowNodeErrorBadge) renders both, keyed off data.errorBadge. + const nodesForRender = useMemo(() => { + const unplacedSet = new Set(unplaced); + return nodes.map((n) => { + let errorBadge: string | undefined; + if (unplacedSet.has(n.id)) errorBadge = t("workflowColumns.nodeUnplaced", "Not placed in a column"); + if (serverNodeError?.nodeId === n.id) errorBadge = serverNodeError.message; + if (errorBadge === n.data.errorBadge) return n; + return { ...n, data: { ...n.data, errorBadge } }; + }); + }, [nodes, unplaced, serverNodeError, t]); const selectedNode = nodes.find((n) => n.id === selectedNodeId) ?? null; @@ -344,57 +459,64 @@ function InnerEditor({
{activeWorkflow ? ( <> -
-
- {PALETTE.map(({ kind, label, icon: Icon, presetConfig }) => ( - +
+ ) : ( +
+
+ {PALETTE.map(({ kind, label, icon: Icon, presetConfig }) => ( + + ))} +
+
+ - ))} + +
-
- {isBuiltin ? ( - <> - - Read-only built-in - - - - ) : ( - <> - - - - )} -
-
+ )} {validationError && (
{validationError}
)} + {unplaced.length > 0 && ( +
+ {t("workflowColumns.unplacedCount", "{{count}} nodes not placed in a column", { + count: unplaced.length, + })} +
+ )}
setSelectedNodeId(node.id)} onPaneClick={() => setSelectedNodeId(null)} fitView @@ -407,11 +529,22 @@ function InnerEditor({ ) : (
- Select or create a workflow to start editing. + {t("workflows.selectOrCreate", "Select or create a workflow to start editing.")}
)}
+ {activeWorkflow && ( + + )} + {selectedNode && selectedNode.data.kind !== "start" && selectedNode.data.kind !== "end" && ( )} diff --git a/packages/dashboard/app/components/__tests__/WorkflowNodeEditor.test.tsx b/packages/dashboard/app/components/__tests__/WorkflowNodeEditor.test.tsx index 447afc9403..b713f40242 100644 --- a/packages/dashboard/app/components/__tests__/WorkflowNodeEditor.test.tsx +++ b/packages/dashboard/app/components/__tests__/WorkflowNodeEditor.test.tsx @@ -9,11 +9,61 @@ vi.mock("../../api", () => ({ updateWorkflow: vi.fn(), deleteWorkflow: vi.fn(), compileWorkflow: vi.fn(), + fetchTraits: vi.fn(), + fetchModels: vi.fn(), + fetchAgents: vi.fn(), + fetchDiscoveredSkills: vi.fn(), })); -import { fetchWorkflows } from "../../api"; +import { fireEvent } from "@testing-library/react"; +import { fetchWorkflows, fetchTraits, updateWorkflow, compileWorkflow, createWorkflow } from "../../api"; +import type { TraitCatalogEntry } from "../../api"; import { WorkflowNodeEditor } from "../WorkflowNodeEditor"; +const TRAIT_CATALOG: TraitCatalogEntry[] = [ + { id: "intake", name: "Intake", builtin: true, flags: { intake: true } }, + { id: "complete", name: "Complete", builtin: true, flags: { complete: true } }, + { id: "wip", name: "WIP", builtin: true, flags: { countsTowardWip: true } }, + { id: "hold", name: "Hold", builtin: true, flags: { hold: true } }, +]; + +function v2Def(): WorkflowDefinition { + return { + id: "WF-002", + name: "Custom", + description: "", + ir: { + version: "v2", + name: "Custom", + columns: [ + { id: "triage", name: "Triage", traits: [{ trait: "intake" }] }, + { id: "done", name: "Done", traits: [{ trait: "complete" }] }, + ], + nodes: [ + { id: "start", kind: "start", column: "triage" }, + { id: "step", kind: "prompt", column: "triage", config: { prompt: "do" } }, + { id: "end", kind: "end", column: "done" }, + ], + edges: [ + { from: "start", to: "step", condition: "success" }, + { from: "step", to: "end", condition: "success" }, + ], + }, + layout: { + start: { x: 0, y: 20 }, + step: { x: 120, y: 60 }, + end: { x: 360, y: 240 }, + }, + createdAt: "2026-06-03T00:00:00.000Z", + updatedAt: "2026-06-03T00:00:00.000Z", + }; +} + +function builtinDef(): WorkflowDefinition { + const d = v2Def(); + return { ...d, id: "builtin:coding", name: "Default coding workflow" }; +} + function def(): WorkflowDefinition { return { id: "WF-001", @@ -70,6 +120,7 @@ describe("workflow-flow-mapping", () => { describe("WorkflowNodeEditor", () => { beforeEach(() => { vi.mocked(fetchWorkflows).mockResolvedValue([]); + vi.mocked(fetchTraits).mockResolvedValue(TRAIT_CATALOG); }); afterEach(() => { @@ -89,3 +140,114 @@ describe("WorkflowNodeEditor", () => { expect(container).toBeEmptyDOMElement(); }); }); + +describe("WorkflowNodeEditor — U10 columns/traits/holds", () => { + beforeEach(() => { + vi.mocked(fetchTraits).mockResolvedValue(TRAIT_CATALOG); + }); + afterEach(() => { + cleanup(); + vi.clearAllMocks(); + }); + + it("shows the column panel with the workflow's columns and trait pickers", async () => { + vi.mocked(fetchWorkflows).mockResolvedValue([v2Def()]); + render( {}} addToast={() => {}} />); + expect(await screen.findByTestId("wf-column-panel")).toBeInTheDocument(); + expect(await screen.findByTestId("wf-column-triage")).toBeInTheDocument(); + expect(screen.getByTestId("wf-column-done")).toBeInTheDocument(); + // Trait picker fed by the catalog endpoint. + await waitFor(() => expect(screen.getAllByText("Complete").length).toBeGreaterThan(0)); + }); + + it("blocks save with a count summary when a node is unplaced", async () => { + const addToast = vi.fn(); + // A def whose 'step' node sits far below all bands → unplaced. + const d = v2Def(); + d.layout = { ...d.layout, step: { x: 120, y: 5000 } }; + // Strip the explicit column so placement is position-derived. + if (d.ir.version === "v2") d.ir.nodes = d.ir.nodes.map((n) => (n.id === "step" ? { ...n, column: undefined } : n)); + vi.mocked(fetchWorkflows).mockResolvedValue([d]); + + render( {}} addToast={addToast} />); + const saveBtn = await screen.findByText("Save"); + await waitFor(() => expect(screen.getByTestId("wf-unplaced-summary")).toBeInTheDocument()); + fireEvent.click(saveBtn.closest("button")!); + + await waitFor(() => + expect(addToast).toHaveBeenCalledWith(expect.stringMatching(/not placed in a column/i), "error"), + ); + expect(updateWorkflow).not.toHaveBeenCalled(); + // Inline node badge present. + expect(screen.getByTestId("wf-node-error-badge")).toBeInTheDocument(); + }); + + it("renders a trait conflict on the column and blocks save", async () => { + const addToast = vi.fn(); + const d = v2Def(); + // Make 'done' both complete and wip — a composition conflict. + if (d.ir.version === "v2") { + d.ir.columns = d.ir.columns.map((c) => + c.id === "done" ? { ...c, traits: [{ trait: "complete" }, { trait: "wip" }] } : c, + ); + } + vi.mocked(fetchWorkflows).mockResolvedValue([d]); + + render( {}} addToast={addToast} />); + const doneCol = await screen.findByTestId("wf-column-done"); + await waitFor(() => expect(doneCol).toHaveAttribute("data-column-error", "true")); + + fireEvent.click((await screen.findByText("Save")).closest("button")!); + await waitFor(() => + expect(addToast).toHaveBeenCalledWith(expect.stringMatching(/trait conflicts/i), "error"), + ); + expect(updateWorkflow).not.toHaveBeenCalled(); + }); + + it("surfaces a seam-in-branch server error as a node badge", async () => { + const addToast = vi.fn(); + vi.mocked(fetchWorkflows).mockResolvedValue([v2Def()]); + vi.mocked(updateWorkflow).mockRejectedValue( + new Error("seam 'merge' node 'step' is forbidden inside a parallel branch of split 's1'"), + ); + + render( {}} addToast={addToast} />); + fireEvent.click((await screen.findByText("Save")).closest("button")!); + + await waitFor(() => expect(updateWorkflow).toHaveBeenCalled()); + await waitFor(() => + expect(screen.getByTestId("wf-node-error-badge")).toHaveTextContent(/forbidden inside a parallel branch/i), + ); + }); + + it("opens a built-in read-only with a Duplicate to customize CTA replacing the toolbar", async () => { + vi.mocked(fetchWorkflows).mockResolvedValue([builtinDef()]); + vi.mocked(createWorkflow).mockResolvedValue({ ...v2Def(), id: "WF-copy", name: "Copy" }); + + render( {}} addToast={() => {}} />); + expect(await screen.findByTestId("wf-readonly-banner")).toBeInTheDocument(); + // No Save button (toolbar replaced). + expect(screen.queryByText("Save")).not.toBeInTheDocument(); + const dup = screen.getByText(/Duplicate to customize/i); + expect(dup).toBeInTheDocument(); + fireEvent.click(dup.closest("button")!); + await waitFor(() => expect(createWorkflow).toHaveBeenCalled()); + }); + + it("saves a valid v2 workflow round-tripping columns to the API", async () => { + vi.mocked(fetchWorkflows).mockResolvedValue([v2Def()]); + vi.mocked(updateWorkflow).mockImplementation(async (_id, updates) => ({ + ...v2Def(), + ...(updates as object), + })); + vi.mocked(compileWorkflow).mockResolvedValue({ steps: [] }); + + render( {}} addToast={() => {}} />); + fireEvent.click((await screen.findByText("Save")).closest("button")!); + + await waitFor(() => expect(updateWorkflow).toHaveBeenCalled()); + const [, updates] = vi.mocked(updateWorkflow).mock.calls[0]; + expect((updates as { ir: { version: string } }).ir.version).toBe("v2"); + expect((updates as { ir: { columns: unknown[] } }).ir.columns).toHaveLength(2); + }); +}); diff --git a/packages/dashboard/app/components/__tests__/workflow-flow-mapping.test.ts b/packages/dashboard/app/components/__tests__/workflow-flow-mapping.test.ts index 783f7c336d..f496ba0002 100644 --- a/packages/dashboard/app/components/__tests__/workflow-flow-mapping.test.ts +++ b/packages/dashboard/app/components/__tests__/workflow-flow-mapping.test.ts @@ -1,6 +1,20 @@ import { describe, expect, it } from "vitest"; import type { WorkflowDefinition } from "@fusion/core"; -import { irToFlow, flowToIr } from "../workflow-flow-mapping"; +import type { Node as FlowNode } from "@xyflow/react"; +import { + irToFlow, + flowToIr, + columnsOf, + columnForY, + bandTop, + columnsToBandNodes, + isColumnBandNode, + validateColumnsClient, + unplacedNodeIds, + COLUMN_BAND_HEIGHT, +} from "../workflow-flow-mapping"; +import type { WorkflowFlowNodeData } from "../nodes/WorkflowNodeTypes"; +import type { TraitCatalogEntry } from "../../api"; function makeDef(ir: WorkflowDefinition["ir"]): WorkflowDefinition { return { @@ -93,3 +107,198 @@ describe("workflow-flow-mapping name preservation", () => { expect(n1?.config?.name).toBe("Build feature"); }); }); + +// ── U10: v2 round-trip (columns, placement, hold, split/join) ──────────────── + +const CATALOG: TraitCatalogEntry[] = [ + { id: "intake", name: "Intake", builtin: true, flags: { intake: true } }, + { id: "complete", name: "Complete", builtin: true, flags: { complete: true } }, + { id: "archived", name: "Archived", builtin: true, flags: { archived: true, hiddenFromBoard: true } }, + { id: "wip", name: "WIP", builtin: true, flags: { countsTowardWip: true } }, + { id: "hold", name: "Hold", builtin: true, flags: { hold: true } }, +]; + +function v2Def(ir: WorkflowDefinition["ir"], layout: WorkflowDefinition["layout"] = {}): WorkflowDefinition { + return { ...makeDef(ir), layout }; +} + +describe("workflow-flow-mapping v2 round-trip", () => { + const ir: WorkflowDefinition["ir"] = { + version: "v2", + name: "wf2", + columns: [ + { id: "triage", name: "Triage", traits: [{ trait: "intake" }] }, + { id: "in-progress", name: "In progress", traits: [{ trait: "wip", config: { limit: 2 } }] }, + { id: "done", name: "Done", traits: [{ trait: "complete" }] }, + ], + nodes: [ + { id: "start", kind: "start", column: "triage" }, + { id: "h1", kind: "hold", column: "triage", config: { release: "manual" } }, + { id: "s1", kind: "split", column: "in-progress" }, + { id: "b1", kind: "prompt", column: "in-progress", config: { prompt: "lint" } }, + { id: "b2", kind: "prompt", column: "in-progress", config: { prompt: "test" } }, + { id: "j1", kind: "join", column: "in-progress", config: { mode: { quorum: 2 }, onBranchFailure: "fail-fast" } }, + { id: "end", kind: "end", column: "done" }, + ], + edges: [ + { from: "start", to: "h1", condition: "success" }, + { from: "h1", to: "s1", condition: "success" }, + { from: "s1", to: "b1", condition: "success" }, + { from: "s1", to: "b2", condition: "success" }, + { from: "b1", to: "j1", condition: "success" }, + { from: "b2", to: "j1", condition: "success" }, + { from: "j1", to: "end", condition: "success" }, + ], + }; + + it("round-trips columns, placement, hold, and split/join config losslessly", () => { + const { nodes, edges } = irToFlow(v2Def(ir)); + const columns = columnsOf(v2Def(ir)); + const { ir: out } = flowToIr("wf2", nodes, edges, columns); + + expect(out.version).toBe("v2"); + if (out.version !== "v2") return; + + // Columns preserved in order with their traits. + expect(out.columns.map((c) => c.id)).toEqual(["triage", "in-progress", "done"]); + expect(out.columns[1].traits).toEqual([{ trait: "wip", config: { limit: 2 } }]); + + const byId = Object.fromEntries(out.nodes.map((n) => [n.id, n])); + // Placement preserved for every node. + expect(byId.h1.column).toBe("triage"); + expect(byId.s1.column).toBe("in-progress"); + expect(byId.j1.column).toBe("in-progress"); + expect(byId.end.column).toBe("done"); + // Hold release config preserved. + expect(byId.h1.config?.release).toBe("manual"); + // Split/join shape preserved. + expect(byId.s1.kind).toBe("split"); + expect(byId.j1.kind).toBe("join"); + expect(byId.j1.config?.mode).toEqual({ quorum: 2 }); + expect(byId.j1.config?.onBranchFailure).toBe("fail-fast"); + }); + + it("emits swimlane band group nodes that flowToIr strips back out", () => { + const { nodes } = irToFlow(v2Def(ir)); + const bands = nodes.filter((n) => isColumnBandNode(n.id)); + expect(bands).toHaveLength(3); + expect(bands.every((b) => b.type === "group")).toBe(true); + // flowToIr must not emit band group nodes as IR nodes. + const { ir: out } = flowToIr("wf2", nodes, [], columnsOf(v2Def(ir))); + expect(out.nodes.some((n) => isColumnBandNode(n.id))).toBe(false); + }); + + it("derives node.column by position when a node is dropped into a band", () => { + const columns = columnsOf(v2Def(ir)); + // Band index 2 = "done"; a node dragged to that band's y resolves to it. + const yInDone = bandTop(2) + 40; + expect(columnForY(yInDone, columns)).toBe("done"); + + // Simulate a node moved into the "done" band with no explicit data.column. + const stepNode: FlowNode = { + id: "n9", + type: "prompt", + position: { x: 100, y: yInDone }, + data: { kind: "prompt", label: "ship", config: {} }, + }; + const bandNodes = columnsToBandNodes(columns); + const { ir: out } = flowToIr("wf2", [...bandNodes, stepNode], [], columns); + const n9 = out.version === "v2" ? out.nodes.find((n) => n.id === "n9") : undefined; + expect(n9?.column).toBe("done"); + }); + + it("v1 definitions map to empty columns (legacy round-trip stays v1)", () => { + const v1: WorkflowDefinition["ir"] = { + version: "v1", + name: "wf", + nodes: [ + { id: "start", kind: "start" }, + { id: "end", kind: "end" }, + ], + edges: [{ from: "start", to: "end", condition: "success" }], + }; + const def = makeDef(v1); + expect(columnsOf(def)).toEqual([]); + const { nodes, edges } = irToFlow(def); + const { ir: out } = flowToIr("wf", nodes, edges, columnsOf(def)); + expect(out.version).toBe("v1"); + }); +}); + +describe("workflow-flow-mapping validation helpers", () => { + it("flags a trait conflict on the offending column", () => { + const columns = [ + { id: "done", name: "Done", traits: [{ trait: "complete" }, { trait: "wip" }] }, + ]; + const violations = validateColumnsClient(columns, CATALOG); + const conflict = violations.find((v) => v.code === "complete-with-wip"); + expect(conflict).toBeTruthy(); + expect(conflict?.columnId).toBe("done"); + expect(conflict?.severity).toBe("error"); + }); + + it("flags more than one intake column workflow-wide", () => { + const columns = [ + { id: "a", name: "A", traits: [{ trait: "intake" }] }, + { id: "b", name: "B", traits: [{ trait: "intake" }] }, + ]; + const v = validateColumnsClient(columns, CATALOG).find((x) => x.code === "multiple-intake-columns"); + expect(v?.columnId).toBeNull(); + }); + + it("reports unplaced step nodes (not start/end, not bands)", () => { + const columns = columnsOf( + v2Def({ + version: "v2", + name: "w", + columns: [{ id: "c1", name: "C1", traits: [] }], + nodes: [ + { id: "start", kind: "start" }, + { id: "end", kind: "end" }, + ], + edges: [{ from: "start", to: "end", condition: "success" }], + }), + ); + const placed: FlowNode = { + id: "p1", + type: "prompt", + position: { x: 0, y: bandTop(0) + 20 }, + data: { kind: "prompt", label: "x", config: {}, column: "c1" }, + }; + // A fresh node parked far below the single band (no explicit column) is + // strictly outside every band → unplaced. + const floating: FlowNode = { + id: "float", + type: "prompt", + position: { x: 0, y: bandTop(0) + COLUMN_BAND_HEIGHT * 5 }, + data: { kind: "prompt", label: "y", config: {} }, + }; + const ids = unplacedNodeIds( + [...columnsToBandNodes(columns), placed, floating, + { id: "start", type: "start", position: { x: 0, y: 0 }, data: { kind: "start", label: "" } }, + { id: "end", type: "end", position: { x: 0, y: 0 }, data: { kind: "end", label: "" } }, + ], + columns, + ); + expect(ids).not.toContain("p1"); + expect(ids).not.toContain("start"); + expect(ids).not.toContain("end"); + expect(ids).toContain("float"); + }); + + it("treats a node with an unknown column id as unplaced", () => { + const columns = [{ id: "c1", name: "C1", traits: [] }]; + const ghost: FlowNode = { + id: "ghost", + type: "prompt", + position: { x: 0, y: bandTop(0) }, + data: { kind: "prompt", label: "x", config: {}, column: "no-such-column" }, + }; + const ids = unplacedNodeIds([ghost], columns); + expect(ids).toContain("ghost"); + }); + + it("band height stays positive (geometry sanity)", () => { + expect(COLUMN_BAND_HEIGHT).toBeGreaterThan(0); + }); +}); diff --git a/packages/dashboard/app/components/nodes/WorkflowNodeTypes.tsx b/packages/dashboard/app/components/nodes/WorkflowNodeTypes.tsx index dec02dc6b1..2f83e337bb 100644 --- a/packages/dashboard/app/components/nodes/WorkflowNodeTypes.tsx +++ b/packages/dashboard/app/components/nodes/WorkflowNodeTypes.tsx @@ -1,14 +1,31 @@ import { Handle, Position, type NodeProps } from "@xyflow/react"; -import { Play, Flag, MessageSquare, Terminal, Shield, GitMerge } from "lucide-react"; +import { Play, Flag, MessageSquare, Terminal, Shield, GitMerge, PauseCircle, Split, Merge, AlertTriangle } from "lucide-react"; -/** Node kinds the editor can render. "merge" is the pre/post-merge seam marker. */ -export type WorkflowEditorNodeKind = "start" | "end" | "prompt" | "script" | "gate" | "merge"; +/** Node kinds the editor can render. "merge" is the pre/post-merge seam marker. + * v2 adds "hold" (passive dwell), "split"/"join" (parallel fan-out). */ +export type WorkflowEditorNodeKind = + | "start" + | "end" + | "prompt" + | "script" + | "gate" + | "merge" + | "hold" + | "split" + | "join"; export interface WorkflowFlowNodeData { kind: WorkflowEditorNodeKind; label: string; - /** Mirrors the IR node config (prompt, scriptName, gateMode, model…). */ + /** Mirrors the IR node config (prompt, scriptName, gateMode, model, release, + * join mode/failure policy…). */ config?: Record; + /** v2: the workflow column this node is placed in (derived from the swimlane + * band it sits in). Surfaced for the unplaced-node error badge. */ + column?: string; + /** When true, render the shared error-state badge on the node (unplaced node + * or seam-in-branch). Set by the editor from validation. */ + errorBadge?: string; [key: string]: unknown; } @@ -19,20 +36,50 @@ const KIND_ICON: Record = { script: Terminal, gate: Shield, merge: GitMerge, + hold: PauseCircle, + split: Split, + join: Merge, }; +/** Shared error-state component (U10): one component renders both the + * unplaced-node and the seam-in-branch error as an inline badge on the node. */ +export function WorkflowNodeErrorBadge({ message }: { message: string }) { + return ( + + {message} + + ); +} + function NodeShell({ data, kind }: { data: WorkflowFlowNodeData; kind: WorkflowEditorNodeKind }) { const Icon = KIND_ICON[kind]; const showTarget = kind !== "start"; const showSource = kind !== "end"; + const release = kind === "hold" ? (data.config?.release as string | undefined) : undefined; + const joinMode = + kind === "join" + ? (() => { + const m = data.config?.mode as unknown; + if (m && typeof m === "object" && "quorum" in (m as object)) { + return `quorum(${(m as { quorum: number }).quorum})`; + } + return typeof m === "string" ? m : "all"; + })() + : undefined; return ( -
+
{showTarget && } {data.label || kind} {kind === "gate" && gate} + {release && {release}} + {joinMode && {joinMode}} + {data.errorBadge && } {showSource && }
); @@ -45,4 +92,7 @@ export const workflowNodeTypes = { script: ({ data }: NodeProps) => , gate: ({ data }: NodeProps) => , merge: ({ data }: NodeProps) => , + hold: ({ data }: NodeProps) => , + split: ({ data }: NodeProps) => , + join: ({ data }: NodeProps) => , }; diff --git a/packages/dashboard/app/components/workflow-flow-mapping.ts b/packages/dashboard/app/components/workflow-flow-mapping.ts index cb0a7e975b..2aaa22b8f0 100644 --- a/packages/dashboard/app/components/workflow-flow-mapping.ts +++ b/packages/dashboard/app/components/workflow-flow-mapping.ts @@ -1,7 +1,56 @@ import type { Node as FlowNode, Edge as FlowEdge } from "@xyflow/react"; -import type { WorkflowIr, WorkflowDefinition } from "@fusion/core"; +import type { + WorkflowIr, + WorkflowIrV2, + WorkflowIrColumn, + WorkflowDefinition, +} from "@fusion/core"; import type { WorkflowFlowNodeData, WorkflowEditorNodeKind } from "./nodes/WorkflowNodeTypes"; +/** Layout geometry for column swimlane bands. Bands stack vertically; each band + * is full-width and a node's `column` is derived by hit-testing the node's y + * against the band rows (position-based, so the editor's existing absolute + * layout persistence carries over unchanged — see flowToIr). */ +export const COLUMN_BAND_HEIGHT = 220; +export const COLUMN_BAND_WIDTH = 5000; +export const COLUMN_BAND_X = -40; +export const COLUMN_BAND_TOP = 0; +/** React Flow node id for a column band group node. */ +export const columnBandNodeId = (columnId: string): string => `__col__:${columnId}`; +export const isColumnBandNode = (id: string): boolean => id.startsWith("__col__:"); +export const columnIdFromBandNode = (id: string): string => id.slice("__col__:".length); + +/** The y-origin of the band for the column at `index`. */ +export function bandTop(index: number): number { + return COLUMN_BAND_TOP + index * COLUMN_BAND_HEIGHT; +} + +/** Hit-test a y coordinate against the ordered column bands, returning the + * column id whose band contains it (clamped to the first/last band). Returns + * undefined when there are no columns. Use for drag placement (a dropped node + * always snaps to the nearest band). */ +export function columnForY(y: number, columns: WorkflowIrColumn[]): string | undefined { + if (columns.length === 0) return undefined; + const idx = Math.floor((y - COLUMN_BAND_TOP) / COLUMN_BAND_HEIGHT); + const clamped = Math.max(0, Math.min(columns.length - 1, idx)); + return columns[clamped]?.id; +} + +/** Strict (non-clamping) hit test: returns the column id whose band vertically + * contains `y`, or undefined when `y` falls outside every band. Use for + * unplaced-node detection (a node parked above/below all bands is unplaced). */ +export function strictColumnForY(y: number, columns: WorkflowIrColumn[]): string | undefined { + if (columns.length === 0) return undefined; + const idx = Math.floor((y - COLUMN_BAND_TOP) / COLUMN_BAND_HEIGHT); + if (idx < 0 || idx >= columns.length) return undefined; + return columns[idx]?.id; +} + +/** True when the IR is v2 (has columns). */ +function isV2(ir: WorkflowIr): ir is WorkflowIrV2 { + return ir.version === "v2"; +} + /** Resolve the editor node "type" for an IR node (merge seam → "merge"). */ function editorKind(node: WorkflowIr["nodes"][number]): WorkflowEditorNodeKind { const seam = node.config?.seam; @@ -16,19 +65,53 @@ function nodeLabel(node: WorkflowIr["nodes"][number]): string { return node.id; } -/** Build React Flow nodes/edges from a stored workflow definition. */ +/** Build React Flow swimlane band group nodes from the workflow's columns. */ +export function columnsToBandNodes(columns: WorkflowIrColumn[]): FlowNode[] { + return columns.map((col, index): FlowNode => ({ + id: columnBandNodeId(col.id), + type: "group", + position: { x: COLUMN_BAND_X, y: bandTop(index) }, + data: { kind: "start", label: col.name, column: col.id } as unknown as WorkflowFlowNodeData, + draggable: false, + selectable: false, + deletable: false, + // Bands sit behind step nodes so steps remain clickable/draggable. + zIndex: -1, + style: { + width: COLUMN_BAND_WIDTH, + height: COLUMN_BAND_HEIGHT, + }, + className: "wf-column-band", + })); +} + +/** Build React Flow nodes/edges from a stored workflow definition. v2 columns + * render as swimlane band group nodes; step nodes carry their `column`. */ export function irToFlow(def: WorkflowDefinition): { nodes: FlowNode[]; edges: FlowEdge[]; } { - const nodes = def.ir.nodes.map((node, index): FlowNode => { + const columns = isV2(def.ir) ? def.ir.columns : []; + const bandNodes = columnsToBandNodes(columns); + + const stepNodes = def.ir.nodes.map((node, index): FlowNode => { const pos = def.layout?.[node.id]; const kind = editorKind(node); + const column = isV2(def.ir) ? node.column : undefined; + const colIndex = column ? columns.findIndex((c) => c.id === column) : -1; + // Default placement seeds the node inside its column band when no persisted + // layout exists; otherwise we honor the saved absolute position. + const fallbackY = colIndex >= 0 ? bandTop(colIndex) + 70 : 120; return { id: node.id, type: kind, - position: pos ?? { x: 80 + index * 180, y: 120 }, - data: { kind, label: nodeLabel(node), config: { ...(node.config ?? {}) } }, + position: pos ?? { x: 80 + index * 180, y: fallbackY }, + data: { + kind, + label: nodeLabel(node), + config: { ...(node.config ?? {}) }, + column, + }, deletable: node.kind !== "start" && node.kind !== "end", }; }); @@ -44,38 +127,55 @@ export function irToFlow(def: WorkflowDefinition): { }; }); - return { nodes, edges }; + return { nodes: [...bandNodes, ...stepNodes], edges }; } -/** Project React Flow nodes/edges back into a WorkflowIr plus a layout map. */ +/** Sanitize a node config, applying the v1 round-trip name rules. */ +function nodeConfig(node: FlowNode): Record | undefined { + const data = node.data; + const config: Record = { ...(data.config ?? {}) }; + const fallbackLabel = data.kind === "merge" ? "Merge boundary" : node.id; + if (data.kind !== "start" && data.kind !== "end" && data.label && data.label !== fallbackLabel) { + config.name = data.label; + } else { + delete config.name; + } + return config; +} + +/** + * Project React Flow nodes/edges back into a WorkflowIr plus a layout map. + * + * When `columns` is provided (the editor manages columns via WorkflowColumnPanel) + * the result is a **v2** IR: column bands are dropped, each step node's `column` + * is derived by hit-testing its y against the ordered bands, and split/join/hold + * config is preserved verbatim. With no columns the result is a v1 IR (legacy + * round-trip, byte-compatible with the pre-U10 mapping). + */ export function flowToIr( name: string, nodes: FlowNode[], edges: FlowEdge[], + columns?: WorkflowIrColumn[], ): { ir: WorkflowIr; layout: Record } { - const irNodes: WorkflowIr["nodes"] = nodes.map((node) => { + const stepNodes = nodes.filter((n) => !isColumnBandNode(n.id) && n.type !== "group"); + const v2 = Array.isArray(columns) && columns.length > 0; + + const irNodes: WorkflowIr["nodes"] = stepNodes.map((node) => { const data = node.data; - const config: Record = { ...(data.config ?? {}) }; - // `irToFlow` synthesizes display labels for unnamed nodes (matching the - // fallback below), so only persist a label that the user actually set — - // otherwise saving an untouched workflow injects synthetic names like - // "start"/"end"/"Merge boundary" and breaks IR round-trips. - const fallbackLabel = data.kind === "merge" ? "Merge boundary" : node.id; - if ( - data.kind !== "start" && - data.kind !== "end" && - data.label && - data.label !== fallbackLabel - ) { - config.name = data.label; - } else { - delete config.name; - } + const config = nodeConfig(node); + // Derive column placement from the node's y position relative to the bands. + const column = v2 ? data.column ?? columnForY(node.position.y, columns!) : undefined; if (data.kind === "merge") { - config.seam = "merge"; - return { id: node.id, kind: "prompt", config }; + const cfg = { ...(config ?? {}), seam: "merge" }; + return { id: node.id, kind: "prompt" as const, ...(column ? { column } : {}), config: cfg }; } - return { id: node.id, kind: data.kind, config: Object.keys(config).length ? config : undefined }; + return { + id: node.id, + kind: data.kind, + ...(column ? { column } : {}), + config: config && Object.keys(config).length ? config : undefined, + }; }); const irEdges: WorkflowIr["edges"] = edges.map((edge) => { @@ -83,14 +183,166 @@ export function flowToIr( return { from: edge.source, to: edge.target, condition }; }); - const layout = nodes.reduce>((acc, node) => { + const layout = stepNodes.reduce>((acc, node) => { acc[node.id] = { x: Math.round(node.position.x), y: Math.round(node.position.y) }; return acc; }, {}); + if (v2) { + const ir: WorkflowIrV2 = { + version: "v2", + name, + columns: columns!.map((c) => ({ id: c.id, name: c.name, traits: c.traits })), + nodes: irNodes, + edges: irEdges, + }; + return { ir, layout }; + } + return { ir: { version: "v1", name, nodes: irNodes, edges: irEdges }, layout }; } +// ── Client-side validation (U10) ───────────────────────────────────────────── +// +// The server's parseWorkflowIr (run on PATCH) is the authority for structural +// errors (undefined-column references, seam-in-branch, duplicate column ids). +// These two helpers run client-side so the editor can render precise inline +// badges and block the save before a round-trip: +// - composition violations attributed to the offending column band; +// - unplaced-node errors attributed to the offending step node. +// They mirror @fusion/core's validateColumnTraits rules using the catalog flags +// (the catalog endpoint ships the same flags the registry validates against). + +import type { TraitViolation } from "@fusion/core"; +import type { TraitCatalogEntry } from "../api"; + +type CatalogFlags = TraitCatalogEntry["flags"]; + +function mergedFlags( + traits: WorkflowIrColumn["traits"], + catalog: Map, +): { flags: CatalogFlags; capacityTraitIds: string[]; unknown: string[] } { + const flags: CatalogFlags = {}; + const capacityTraitIds: string[] = []; + const unknown: string[] = []; + for (const ct of traits) { + const def = catalog.get(ct.trait); + if (!def) { + unknown.push(ct.trait); + continue; + } + for (const [k, v] of Object.entries(def.flags)) { + if (v) (flags as Record)[k] = true; + } + if (def.flags.countsTowardWip) capacityTraitIds.push(def.id); + } + return { flags, capacityTraitIds, unknown }; +} + +/** Client mirror of core's validateColumnTraits, driven by the trait catalog. */ +export function validateColumnsClient( + columns: WorkflowIrColumn[], + catalog: TraitCatalogEntry[], +): TraitViolation[] { + const byId = new Map(catalog.map((c) => [c.id, c])); + const violations: TraitViolation[] = []; + let intakeCount = 0; + + for (const col of columns) { + const { flags, capacityTraitIds, unknown } = mergedFlags(col.traits, byId); + for (const u of unknown) { + violations.push({ + code: "unknown-trait", + severity: "error", + columnId: col.id, + traitIds: [u], + message: `Column '${col.id}' references unknown trait '${u}'`, + }); + } + if (flags.complete && flags.countsTowardWip) { + violations.push({ + code: "complete-with-wip", + severity: "error", + columnId: col.id, + traitIds: capacityTraitIds, + message: `Column '${col.name || col.id}' is both a completion column and counts toward WIP`, + }); + } + if (capacityTraitIds.length > 1) { + violations.push({ + code: "two-capacity-traits", + severity: "error", + columnId: col.id, + traitIds: capacityTraitIds, + message: `Column '${col.name || col.id}' has more than one capacity (WIP) trait`, + }); + } + if (flags.complete && flags.intake) { + violations.push({ + code: "complete-with-intake", + severity: "error", + columnId: col.id, + traitIds: [], + message: `Column '${col.name || col.id}' is both a completion column and an intake column`, + }); + } + if (flags.archived && flags.countsTowardWip) { + violations.push({ + code: "archived-with-wip", + severity: "error", + columnId: col.id, + traitIds: [], + message: `Column '${col.name || col.id}' is archived but counts toward WIP`, + }); + } + if (flags.intake) intakeCount += 1; + } + + if (intakeCount > 1) { + violations.push({ + code: "multiple-intake-columns", + severity: "error", + columnId: null, + traitIds: [], + message: `Workflow has ${intakeCount} intake columns; exactly one is allowed`, + }); + } + + return violations; +} + +/** Step node ids that are not placed in any column (v2 only). Bands and + * start/end are exempt — start/end are structural and need no column. */ +export function unplacedNodeIds( + nodes: FlowNode[], + columns: WorkflowIrColumn[], +): string[] { + if (columns.length === 0) return []; + const ids: string[] = []; + for (const node of nodes) { + if (isColumnBandNode(node.id) || node.type === "group") continue; + if (node.data.kind === "start" || node.data.kind === "end") continue; + // A node is placed if it carries a valid column id, or if its y falls + // strictly within a band's extent. A node parked outside every band with + // no explicit column is unplaced (blocks save with an inline badge). + const explicit = node.data.column; + if (explicit && columns.some((c) => c.id === explicit)) continue; + if (explicit && !columns.some((c) => c.id === explicit)) { + ids.push(node.id); + continue; + } + const byPosition = strictColumnForY(node.position.y, columns); + if (!byPosition) ids.push(node.id); + } + return ids; +} + +/** Extract the editor's working column list from a definition (v2 → its + * columns; v1 → empty, meaning "no custom columns authored yet"). */ +export function columnsOf(def: WorkflowDefinition): WorkflowIrColumn[] { + return isV2(def.ir) ? def.ir.columns.map((c) => ({ ...c, traits: [...c.traits] })) : []; +} + /** Seed graph for a brand-new workflow: start → end with room to insert steps. */ export function emptyWorkflowIr(name: string): WorkflowIr { return { diff --git a/packages/dashboard/src/__tests__/workflow-routes.test.ts b/packages/dashboard/src/__tests__/workflow-routes.test.ts index 944e7bb45a..26dd8b9365 100644 --- a/packages/dashboard/src/__tests__/workflow-routes.test.ts +++ b/packages/dashboard/src/__tests__/workflow-routes.test.ts @@ -107,6 +107,23 @@ describe("workflow routes (U4)", () => { expect(list.some((w) => isBuiltinWorkflowId(w.id))).toBe(true); }); + it("GET /traits returns the registry trait catalog (built-ins, with flags + schema)", async () => { + const res = await get("/api/traits"); + expect(res.status).toBe(200); + const { traits } = res.body as { + traits: Array<{ id: string; name: string; builtin: boolean; flags: Record; configSchema?: unknown }>; + }; + // The 14 built-in traits are registered on import. + expect(traits.length).toBeGreaterThanOrEqual(14); + const intake = traits.find((t) => t.id === "intake"); + expect(intake?.builtin).toBe(true); + expect(intake?.flags.intake).toBe(true); + const wip = traits.find((t) => t.id === "wip"); + expect(wip?.configSchema).toBeTruthy(); + const complete = traits.find((t) => t.id === "complete"); + expect(complete?.flags.complete).toBe(true); + }); + it("POST /workflows/:id/compile returns steps for linear and 422 for branching", async () => { const linear = await post("/api/workflows", { name: "L", ir: linearIr() }); const linearId = (linear.body as { id: string }).id; diff --git a/packages/dashboard/src/routes/register-workflow-routes.ts b/packages/dashboard/src/routes/register-workflow-routes.ts index eedfae4ec0..2a0a5c1cdf 100644 --- a/packages/dashboard/src/routes/register-workflow-routes.ts +++ b/packages/dashboard/src/routes/register-workflow-routes.ts @@ -1,5 +1,5 @@ import type { WorkflowIr } from "@fusion/core"; -import { OccupiedColumnsError, WorkflowCompileError, WorkflowIrError, compileWorkflowToSteps } from "@fusion/core"; +import { OccupiedColumnsError, WorkflowCompileError, WorkflowIrError, compileWorkflowToSteps, listTraits } from "@fusion/core"; import { ApiError, badRequest, conflict, notFound } from "../api-error.js"; import type { ApiRoutesContext } from "./types.js"; @@ -19,6 +19,32 @@ export function registerWorkflowRoutes(ctx: ApiRoutesContext): void { return ir as WorkflowIr; } + // GET /api/traits — trait catalog for the node editor's trait picker (U10). + // Returns the registry's listTraits() (built-ins + any registered plugin + // traits): id, name, description, flags, hook descriptors, and config schema. + // Session-scoped via getProjectContext exactly like the other workflow routes; + // no new auth surface. The catalog is registry-backed and read-only, so it + // does not depend on the project store beyond confirming the session. + router.get("/traits", async (req, res) => { + try { + await getProjectContext(req); + res.json({ + traits: listTraits().map((t) => ({ + id: t.id, + name: t.name, + description: t.description, + builtin: t.builtin === true, + flags: t.flags, + hooks: t.hooks, + configSchema: t.configSchema, + })), + }); + } catch (err: unknown) { + if (err instanceof ApiError) throw err; + rethrowAsApiError(err); + } + }); + // GET /api/workflows — list all workflow definitions for the project. router.get("/workflows", async (req, res) => { try {