feat(dashboard): node editor authors columns, traits, hold and split/join nodes with inline validation (U10)

This commit is contained in:
gsxdsm
2026-06-04 01:46:51 -07:00
parent 26718a31cc
commit e5a0a199ea
10 changed files with 1416 additions and 87 deletions

View File

@@ -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<string, string>;
}
/** 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<BoardWorkflowsPayload> {
return api<BoardWorkflowsPayload>(withProjectId("/tasks/board-workflows", projectId));
}
/** Manually promote a held card out of its hold column (U9). */
export function promoteTask(id: string, projectId?: string): Promise<Task> {
return api<Task>(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<import("@fusion/core
return dedupe(path, () => api<import("@fusion/core").WorkflowDefinition[]>(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<TraitCatalogEntry[]> {
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<import("@fusion/core").WorkflowDefinition> {
return api<import("@fusion/core").WorkflowDefinition>(withProjectId(`/workflows/${encodeURIComponent(id)}`, projectId));

View File

@@ -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<TraitCatalogEntry[]>([]);
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 (
<aside className="wf-column-panel" data-testid="wf-column-panel">
<header className="wf-column-panel-header">
<h3>{t("workflowColumns.title", "Columns")}</h3>
<button
className="wf-column-add"
onClick={addColumn}
disabled={readOnly}
title={readOnly ? t("workflowColumns.readOnlyHint", "Built-in workflows are read-only — duplicate to edit") : undefined}
>
<Plus size={13} /> {t("workflowColumns.add", "Add column")}
</button>
</header>
{workflowWide.length > 0 && (
<div className="wf-column-panel-errors" role="alert">
{workflowWide.map((v, i) => (
<p key={`${v.code}-${i}`} className="wf-column-violation">
<AlertTriangle size={12} aria-hidden /> {v.message}
</p>
))}
</div>
)}
{columns.length === 0 ? (
<p className="wf-column-panel-empty">
{t("workflowColumns.empty", "No columns yet. Add a column to place nodes into board lanes.")}
</p>
) : (
<ul className="wf-column-list">
{columns.map((col, index) => {
const colViolations = violationsFor(col.id);
return (
<li
key={col.id}
className={`wf-column-item${colViolations.length ? " wf-column-item--error" : ""}`}
data-testid={`wf-column-${col.id}`}
data-column-error={colViolations.length ? "true" : undefined}
>
<div className="wf-column-item-head">
<input
className="wf-column-name"
aria-label={t("workflowColumns.nameLabel", "Column name")}
value={col.name}
disabled={readOnly}
onChange={(e) => renameColumn(col.id, e.target.value)}
/>
<div className="wf-column-item-actions">
<button
className="wf-column-move"
aria-label={t("workflowColumns.moveUp", "Move column up")}
disabled={readOnly || index === 0}
onClick={() => moveColumn(index, -1)}
>
<ChevronUp size={13} />
</button>
<button
className="wf-column-move"
aria-label={t("workflowColumns.moveDown", "Move column down")}
disabled={readOnly || index === columns.length - 1}
onClick={() => moveColumn(index, 1)}
>
<ChevronDown size={13} />
</button>
<button
className="wf-column-remove"
aria-label={t("workflowColumns.remove", "Remove column")}
disabled={readOnly}
onClick={() => removeColumn(col.id)}
>
<Trash2 size={13} />
</button>
</div>
</div>
{colViolations.map((v, i) => (
<p key={`${v.code}-${i}`} className="wf-column-violation" role="alert">
<AlertTriangle size={12} aria-hidden /> {v.message}
</p>
))}
<div className="wf-column-traits">
<span className="wf-column-traits-label">{t("workflowColumns.traits", "Traits")}</span>
<div className="wf-column-trait-options">
{catalog.map((trait) => {
const checked = col.traits.some((tr) => tr.trait === trait.id);
return (
<label key={trait.id} className="wf-column-trait" title={trait.description}>
<input
type="checkbox"
checked={checked}
disabled={readOnly}
onChange={() => toggleTrait(col.id, trait.id)}
/>
<span>{trait.name}</span>
</label>
);
})}
</div>
</div>
</li>
);
})}
</ul>
)}
</aside>
);
}

View File

@@ -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);
}

View File

@@ -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<FlowNode<WorkflowFlowNodeData>>([]);
const [edges, setEdges, onEdgesChange] = useEdgesState<FlowEdge>([]);
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
const { t } = useTranslation("app");
// v2 columns the editor is authoring for the active workflow.
const [columns, setColumns] = useState<WorkflowIrColumn[]>([]);
const [traitCatalog, setTraitCatalog] = useState<TraitCatalogEntry[]>([]);
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<WorkflowFlowNodeData>) => {
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<string, unknown>) => {
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({
<section className="wf-editor-canvas-wrap">
{activeWorkflow ? (
<>
<div className="wf-editor-toolbar">
<div className="wf-editor-palette">
{PALETTE.map(({ kind, label, icon: Icon, presetConfig }) => (
<button
key={label}
className="wf-palette-btn"
onClick={() => addNode(kind, label, presetConfig)}
disabled={isBuiltin}
title={isBuiltin ? "Built-in workflows are read-only — duplicate to edit" : undefined}
>
<Icon size={13} /> {label}
{isBuiltin ? (
// Read-only built-in: a banner *replaces* the save/edit toolbar
// (not an overlay); the canvas below stays inspectable.
<div className="wf-editor-readonly-banner" role="status" data-testid="wf-readonly-banner">
<span className="wf-editor-readonly-note">
{t("workflows.readOnlyBuiltin", "Read-only built-in workflow")}
</span>
<button className="wf-editor-save wf-editor-duplicate-primary" onClick={handleDuplicate}>
<Plus size={13} /> {t("workflows.duplicateToCustomize", "Duplicate to customize")}
</button>
</div>
) : (
<div className="wf-editor-toolbar">
<div className="wf-editor-palette">
{PALETTE.map(({ kind, label, icon: Icon, presetConfig }) => (
<button
key={label}
className="wf-palette-btn"
onClick={() => addNode(kind, label, presetConfig)}
>
<Icon size={13} /> {label}
</button>
))}
</div>
<div className="wf-editor-actions">
<button className="wf-editor-delete" onClick={handleDeleteWorkflow}>
<Trash2 size={13} /> {t("common.delete", "Delete")}
</button>
))}
<button className="wf-editor-save" onClick={handleSave} disabled={saving}>
{saving ? <Loader2 size={13} className="wf-spin" /> : <Save size={13} />}{" "}
{t("common.save", "Save")}
</button>
</div>
</div>
<div className="wf-editor-actions">
{isBuiltin ? (
<>
<span className="wf-editor-readonly-note" role="status">
Read-only built-in
</span>
<button className="wf-editor-save" onClick={handleDuplicate}>
<Plus size={13} /> Duplicate to edit
</button>
</>
) : (
<>
<button className="wf-editor-delete" onClick={handleDeleteWorkflow}>
<Trash2 size={13} /> Delete
</button>
<button className="wf-editor-save" onClick={handleSave} disabled={saving}>
{saving ? <Loader2 size={13} className="wf-spin" /> : <Save size={13} />} Save
</button>
</>
)}
</div>
</div>
)}
{validationError && (
<div className="wf-editor-banner" role="alert">
{validationError}
</div>
)}
{unplaced.length > 0 && (
<div className="wf-editor-banner wf-editor-banner--warn" role="alert" data-testid="wf-unplaced-summary">
{t("workflowColumns.unplacedCount", "{{count}} nodes not placed in a column", {
count: unplaced.length,
})}
</div>
)}
<div className="wf-editor-canvas">
<ReactFlow
nodes={nodes}
nodes={nodesForRender}
edges={edges}
nodeTypes={workflowNodeTypes}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onConnect={onConnect}
onNodeDragStop={onNodeDragStop}
onNodeClick={(_, node) => setSelectedNodeId(node.id)}
onPaneClick={() => setSelectedNodeId(null)}
fitView
@@ -407,11 +529,22 @@ function InnerEditor({
</>
) : (
<div className="wf-editor-empty wf-editor-canvas-empty">
Select or create a workflow to start editing.
{t("workflows.selectOrCreate", "Select or create a workflow to start editing.")}
</div>
)}
</section>
{activeWorkflow && (
<WorkflowColumnPanel
columns={columns}
onChange={setColumns}
violations={columnViolations}
readOnly={isBuiltin}
projectId={projectId}
addToast={addToast}
/>
)}
{selectedNode && selectedNode.data.kind !== "start" && selectedNode.data.kind !== "end" && (
<aside className="wf-editor-inspector">
<h3>Node</h3>
@@ -615,9 +748,90 @@ function InnerEditor({
</label>
) : null}
{selectedNode.data.kind !== "merge" ? (
{selectedNode.data.kind === "hold" ? (
<label className="wf-field">
<span>Gate mode</span>
<span>{t("workflowNodes.releaseCondition", "Release condition")}</span>
<select
value={String(selectedNode.data.config?.release ?? "manual")}
onChange={(e) => updateSelectedData({ config: { release: e.target.value } })}
>
<option value="manual">{t("workflowNodes.releaseManual", "Manual promote")}</option>
<option value="timer">{t("workflowNodes.releaseTimer", "Timer")}</option>
<option value="capacity">{t("workflowNodes.releaseCapacity", "Downstream capacity")}</option>
<option value="dependency">{t("workflowNodes.releaseDependency", "Dependency complete")}</option>
<option value="external-event">{t("workflowNodes.releaseExternal", "External event")}</option>
</select>
</label>
) : null}
{selectedNode.data.kind === "join" ? (
<>
<label className="wf-field">
<span>{t("workflowNodes.joinMode", "Join mode")}</span>
<select
value={(() => {
const m = selectedNode.data.config?.mode as unknown;
if (m && typeof m === "object" && "quorum" in (m as object)) return "quorum";
return typeof m === "string" ? m : "all";
})()}
onChange={(e) => {
const v = e.target.value;
if (v === "quorum") {
updateSelectedData({ config: { mode: { quorum: 2 } } });
} else {
updateSelectedData({ config: { mode: v } });
}
}}
>
<option value="all">{t("workflowNodes.joinAll", "All branches")}</option>
<option value="any">{t("workflowNodes.joinAny", "Any branch")}</option>
<option value="quorum">{t("workflowNodes.joinQuorum", "Quorum (n)")}</option>
</select>
</label>
{(() => {
const m = selectedNode.data.config?.mode as unknown;
return m && typeof m === "object" && "quorum" in (m as object);
})() && (
<label className="wf-field">
<span>{t("workflowNodes.quorumN", "Quorum count (n)")}</span>
<input
type="number"
min={1}
value={String((selectedNode.data.config?.mode as { quorum?: number })?.quorum ?? 2)}
onChange={(e) => {
const n = parseInt(e.target.value, 10);
if (!isNaN(n)) updateSelectedData({ config: { mode: { quorum: n } } });
}}
/>
</label>
)}
<label className="wf-field">
<span>{t("workflowNodes.failurePolicy", "On branch failure")}</span>
<select
value={String(selectedNode.data.config?.onBranchFailure ?? "collect")}
onChange={(e) => updateSelectedData({ config: { onBranchFailure: e.target.value } })}
>
<option value="collect">{t("workflowNodes.failureCollect", "Collect (wait for all)")}</option>
<option value="fail-fast">{t("workflowNodes.failureFailFast", "Fail-fast (cancel siblings)")}</option>
</select>
</label>
</>
) : null}
{selectedNode.data.kind === "split" ? (
<p className="wf-inspector-note wf-inspector-note--info">
{t(
"workflowNodes.splitNote",
"Branches run concurrently from this node. Execute and merge seams are not allowed inside a branch.",
)}
</p>
) : null}
{selectedNode.data.kind === "prompt" ||
selectedNode.data.kind === "gate" ||
selectedNode.data.kind === "script" ? (
<label className="wf-field">
<span>{t("workflowNodes.gateMode", "Gate mode")}</span>
<select
// Default display must match the compiler's defaults:
// gate and script nodes block by default, prompt is advisory.
@@ -627,15 +841,18 @@ function InnerEditor({
)}
onChange={(e) => updateSelectedData({ config: { gateMode: e.target.value } })}
>
<option value="advisory">Advisory</option>
<option value="gate">Gate (blocks)</option>
<option value="advisory">{t("workflowNodes.advisory", "Advisory")}</option>
<option value="gate">{t("workflowNodes.gateBlocks", "Gate (blocks)")}</option>
</select>
</label>
) : (
) : selectedNode.data.kind === "merge" ? (
<p className="wf-inspector-note">
Steps before this marker run pre-merge; steps after run post-merge.
{t(
"workflowNodes.mergeBoundaryNote",
"Steps before this marker run pre-merge; steps after run post-merge.",
)}
</p>
)}
) : null}
</fieldset>
</aside>
)}

View File

@@ -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(<WorkflowNodeEditor isOpen onClose={() => {}} 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(<WorkflowNodeEditor isOpen onClose={() => {}} 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(<WorkflowNodeEditor isOpen onClose={() => {}} 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(<WorkflowNodeEditor isOpen onClose={() => {}} 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(<WorkflowNodeEditor isOpen onClose={() => {}} 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(<WorkflowNodeEditor isOpen onClose={() => {}} 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);
});
});

View File

@@ -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<WorkflowFlowNodeData> = {
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<WorkflowFlowNodeData> = {
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<WorkflowFlowNodeData> = {
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<WorkflowFlowNodeData> = {
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);
});
});

View File

@@ -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<string, unknown>;
/** 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<WorkflowEditorNodeKind, typeof Play> = {
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 (
<span className="wf-node-error-badge" role="alert" data-testid="wf-node-error-badge" title={message}>
<AlertTriangle size={11} aria-hidden /> {message}
</span>
);
}
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 (
<div className={`wf-node wf-node-${kind}`} data-testid={`wf-node-${kind}`}>
<div
className={`wf-node wf-node-${kind}${data.errorBadge ? " wf-node--error" : ""}`}
data-testid={`wf-node-${kind}`}
>
{showTarget && <Handle type="target" position={Position.Left} />}
<span className="wf-node-icon">
<Icon size={14} aria-hidden />
</span>
<span className="wf-node-label">{data.label || kind}</span>
{kind === "gate" && <span className="wf-node-badge">gate</span>}
{release && <span className="wf-node-badge">{release}</span>}
{joinMode && <span className="wf-node-badge">{joinMode}</span>}
{data.errorBadge && <WorkflowNodeErrorBadge message={data.errorBadge} />}
{showSource && <Handle type="source" position={Position.Right} />}
</div>
);
@@ -45,4 +92,7 @@ export const workflowNodeTypes = {
script: ({ data }: NodeProps) => <NodeShell data={data as WorkflowFlowNodeData} kind="script" />,
gate: ({ data }: NodeProps) => <NodeShell data={data as WorkflowFlowNodeData} kind="gate" />,
merge: ({ data }: NodeProps) => <NodeShell data={data as WorkflowFlowNodeData} kind="merge" />,
hold: ({ data }: NodeProps) => <NodeShell data={data as WorkflowFlowNodeData} kind="hold" />,
split: ({ data }: NodeProps) => <NodeShell data={data as WorkflowFlowNodeData} kind="split" />,
join: ({ data }: NodeProps) => <NodeShell data={data as WorkflowFlowNodeData} kind="join" />,
};

View File

@@ -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<WorkflowFlowNodeData>[] {
return columns.map((col, index): FlowNode<WorkflowFlowNodeData> => ({
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<WorkflowFlowNodeData>[];
edges: FlowEdge[];
} {
const nodes = def.ir.nodes.map((node, index): FlowNode<WorkflowFlowNodeData> => {
const columns = isV2(def.ir) ? def.ir.columns : [];
const bandNodes = columnsToBandNodes(columns);
const stepNodes = def.ir.nodes.map((node, index): FlowNode<WorkflowFlowNodeData> => {
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<WorkflowFlowNodeData>): Record<string, unknown> | undefined {
const data = node.data;
const config: Record<string, unknown> = { ...(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<WorkflowFlowNodeData>[],
edges: FlowEdge[],
columns?: WorkflowIrColumn[],
): { ir: WorkflowIr; layout: Record<string, { x: number; y: number }> } {
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<string, unknown> = { ...(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<Record<string, { x: number; y: number }>>((acc, node) => {
const layout = stepNodes.reduce<Record<string, { x: number; y: number }>>((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<string, TraitCatalogEntry>,
): { 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<string, boolean>)[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<WorkflowFlowNodeData>[],
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 {

View File

@@ -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<string, boolean>; 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;

View File

@@ -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 {