feat(dashboard): node editor authors columns, traits, hold and split/join nodes with inline validation (U10)
This commit is contained in:
@@ -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));
|
||||
|
||||
205
packages/dashboard/app/components/WorkflowColumnPanel.tsx
Normal file
205
packages/dashboard/app/components/WorkflowColumnPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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" />,
|
||||
};
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user