import type { Node as FlowNode, Edge as FlowEdge } from "@xyflow/react"; import type { WorkflowIr, WorkflowIrV2, WorkflowIrColumn, WorkflowIrNode, WorkflowIrEdge, WorkflowDefinition, WorkflowFieldDefinition, WorkflowSettingDefinition, } from "@fusion/core"; import type { WorkflowFlowNodeData, WorkflowEditorNodeKind } from "./nodes/WorkflowNodeTypes"; /** Local mirror of @fusion/core's WorkflowForeachConfig (KTD-3). The core index * barrel does not re-export it, and the dashboard build aliases @fusion/core to * a types-only entry, so we describe just the shape this mapping needs. */ interface WorkflowForeachConfig { source: "task-steps"; maxReworkCycles?: number; mode?: "sequential" | "parallel"; concurrency?: number; isolation?: "shared" | "worktree"; template: { nodes: WorkflowIrNode[]; edges: WorkflowIrEdge[] }; } interface WorkflowLoopConfig { maxIterations?: number; timeoutMs?: number; exitWhen?: { type: "output-contains" | "output-matches"; nodeId?: string; value?: string; pattern?: string; flags?: string; }; template: { nodes: WorkflowIrNode[]; edges: WorkflowIrEdge[] }; } // WorkflowFieldDefinition is imported from @fusion/core above (KTD-13/14). // Re-exported so existing importers that reference WorkflowFieldDefinitionShape // can migrate; callers should prefer WorkflowFieldDefinition directly. export type { WorkflowFieldDefinition as WorkflowFieldDefinitionShape }; // ── foreach template region (KTD-3, U8) ────────────────────────────────────── // // A `foreach` node is authored inline as a React Flow group node whose template // subgraph nodes are children with `parentId` set to the group id. To keep child // flow-node ids globally unique while preserving the *template-local* ids that // the IR's `config.template` stores, child flow ids are namespaced as // `::`; flowToIr strips the prefix back out when it // reassembles the template. Geometry for the group + auto-layout for template // nodes lacking persisted layout data. // ── Card-style node dimensions (U1) ───────────────────────────────────────── // // Cards are larger than the old icon+label pills: a header row plus a config // summary line. These constants are the single source of truth for card sizing // — the CSS sizes cards to WF_CARD_WIDTH (with WF_CARD_MAX_WIDTH as the hard // ceiling so long labels/summaries truncate rather than grow the canvas), and // U5's auto-layout imports WF_CARD_WIDTH for column spacing rather than // duplicating the number. export const WF_CARD_WIDTH = 200; export const WF_CARD_MAX_WIDTH = 240; export const WF_CARD_HEIGHT = 64; export const FOREACH_GROUP_WIDTH = 560; export const FOREACH_GROUP_HEIGHT = 220; export const FOREACH_CHILD_X = 30; export const FOREACH_CHILD_Y = 56; export const FOREACH_CHILD_STEP_X = 260; const FOREACH_CHILD_SEP = "::"; /** Compose a globally-unique flow-node id for a template child. */ export function foreachChildFlowId(groupId: string, templateNodeId: string): string { return `${groupId}${FOREACH_CHILD_SEP}${templateNodeId}`; } /** Recover the template-local node id from a namespaced child flow id. */ export function templateNodeIdFromChild(groupId: string, childFlowId: string): string { const prefix = `${groupId}${FOREACH_CHILD_SEP}`; return childFlowId.startsWith(prefix) ? childFlowId.slice(prefix.length) : childFlowId; } /** 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; if (seam === "merge") return "merge"; // PR node kinds (pr-create/pr-respond/pr-merge) are graph node kinds but have // no dedicated editor palette renderer yet; map them to the closest existing // editor shape so the workflow editor renders them as recognizable nodes. // (Dedicated PR-node editor rendering is a follow-up, not part of this work.) if (node.kind === "pr-merge") return "merge"; if (node.kind === "pr-create" || node.kind === "pr-respond") return "prompt"; return node.kind; } function nodeLabel(node: WorkflowIr["nodes"][number]): string { const name = node.config?.name; if (typeof name === "string" && name.trim()) return name; if (node.config?.seam === "merge") return "Merge boundary"; return node.id; } /** Build React Flow swimlane band group nodes from the workflow's columns. */ export function columnsToBandNodes(columns: WorkflowIrColumn[]): FlowNode[] { return columns.map((col, index): FlowNode => ({ id: columnBandNodeId(col.id), type: "group", position: { x: COLUMN_BAND_X, y: bandTop(index) }, data: { kind: "start", label: col.name, column: col.id } as unknown as WorkflowFlowNodeData, draggable: false, selectable: false, deletable: false, // Bands sit behind step nodes so steps remain clickable/draggable. zIndex: -1, style: { width: COLUMN_BAND_WIDTH, height: COLUMN_BAND_HEIGHT, }, className: "wf-column-band", })); } /** Read a node's foreach template config, or undefined when it is not a foreach * node carrying a template. */ function foreachConfigOf(node: WorkflowIrNode): WorkflowForeachConfig | undefined { if (node.kind !== "foreach") return undefined; const cfg = node.config as Partial | undefined; if (!cfg || !cfg.template) return undefined; return cfg as WorkflowForeachConfig; } function loopConfigOf(node: WorkflowIrNode): WorkflowLoopConfig | undefined { if (node.kind !== "loop") return undefined; const cfg = node.config as Partial | undefined; if (!cfg || !cfg.template) return undefined; return cfg as WorkflowLoopConfig; } function groupTemplateConfigOf( node: WorkflowIrNode, ): WorkflowForeachConfig | WorkflowLoopConfig | undefined { return foreachConfigOf(node) ?? loopConfigOf(node); } /** CSS class for an edge given its condition + rework kind. Rework takes * precedence; failure edges get the distinct failure styling; success and other * conditions get no class (default styling). R2's two-channel rule (label always * rendered + dash pattern) plus color is enforced by the CSS for these classes. */ export function edgeClassName(condition: string, isRework: boolean): string | undefined { if (isRework) return "wf-edge-rework"; if (condition === "failure") return "wf-edge-failure"; return undefined; } /** Build a React Flow edge from an IR edge. Rework edges (KTD-5) carry kind so * the editor renders them dashed in the accent color. Failure edges (R2) carry * the wf-edge-failure class for a distinct dash + error-token stroke. */ function irEdgeToFlow(edge: WorkflowIrEdge, index: number, idScope = ""): FlowEdge { const condition = edge.condition ?? "success"; const isRework = edge.kind === "rework"; return { id: `e-${idScope}${edge.from}-${edge.to}-${index}`, source: idScope ? `${idScope}${edge.from}` : edge.from, target: idScope ? `${idScope}${edge.to}` : edge.to, label: isRework ? `${shortConditionLabel(condition)} (rework)` : shortConditionLabel(condition), data: { condition, kind: isRework ? "rework" : undefined }, type: isRework ? "step" : undefined, animated: isRework, className: edgeClassName(condition, isRework), interactionWidth: WF_EDGE_INTERACTION_WIDTH, markerEnd: undefined, }; } /** Default edge hit-target width (px) so edges are clickable/tappable even when * visually thin. Applied per-edge (defaultEdgeOptions only seeds new edges). */ export const WF_EDGE_INTERACTION_WIDTH = 24; /** Short display label for an edge condition. `outcome:` conditions * render as the verdict alone (KTD-4); everything else verbatim. */ export function shortConditionLabel(condition: string): string { if (condition.startsWith("outcome:")) return condition.slice("outcome:".length); return condition; } /** Build React Flow nodes/edges from a stored workflow definition. v2 columns * render as swimlane band group nodes; step nodes carry their `column`. A * `foreach` node renders as a group whose template subgraph nodes are children * (parentId = the group id). */ export function irToFlow(def: WorkflowDefinition): { nodes: FlowNode[]; edges: FlowEdge[]; } { const columns = isV2(def.ir) ? def.ir.columns : []; const bandNodes = columnsToBandNodes(columns); const childNodes: FlowNode[] = []; const childEdges: FlowEdge[] = []; const stepNodes = def.ir.nodes.map((node, index): FlowNode => { const pos = def.layout?.[node.id]; const kind = editorKind(node); const column = isV2(def.ir) ? node.column : undefined; const colIndex = column ? columns.findIndex((c) => c.id === column) : -1; // Default placement seeds the node inside its column band when no persisted // layout exists; otherwise we honor the saved absolute position. const fallbackY = colIndex >= 0 ? bandTop(colIndex) + 70 : 120; const groupCfg = groupTemplateConfigOf(node); if (groupCfg) { const template = groupCfg.template; // Render template nodes as children of this group (parentId = group id). template.nodes.forEach((inner, innerIdx) => { const childFlowId = foreachChildFlowId(node.id, inner.id); // Template layout lives under namespaced keys; auto-layout otherwise. const childPos = def.layout?.[childFlowId] ?? { x: FOREACH_CHILD_X + innerIdx * FOREACH_CHILD_STEP_X, y: FOREACH_CHILD_Y, }; const innerKind = editorKind(inner); childNodes.push({ id: childFlowId, type: innerKind, position: childPos, parentId: node.id, extent: "parent", data: { kind: innerKind, label: nodeLabel(inner), config: { ...(inner.config ?? {}) } }, deletable: true, }); }); template.edges.forEach((edge, eIdx) => { childEdges.push(irEdgeToFlow(edge, eIdx, `${node.id}${FOREACH_CHILD_SEP}`)); }); // Strip the template off the group node's own config (children carry it). const { template: _t, ...restCfg } = (node.config ?? {}) as Record; return { id: node.id, type: kind, position: pos ?? { x: 80 + index * 180, y: fallbackY }, data: { kind, label: nodeLabel(node), config: { ...restCfg }, column, templateEmpty: template.nodes.length === 0, }, style: { width: FOREACH_GROUP_WIDTH, height: FOREACH_GROUP_HEIGHT }, deletable: true, }; } return { id: node.id, type: kind, position: pos ?? { x: 80 + index * 180, y: fallbackY }, data: { kind, label: nodeLabel(node), config: { ...(node.config ?? {}) }, column, }, deletable: node.kind !== "start" && node.kind !== "end", }; }); const edges = def.ir.edges.map((edge, index): FlowEdge => irEdgeToFlow(edge, index)); // Group nodes must precede their children in the array for React Flow. return { nodes: [...bandNodes, ...stepNodes, ...childNodes], edges: [...edges, ...childEdges] }; } /** Sanitize a node config, applying the v1 round-trip name rules. */ function nodeConfig(node: FlowNode): Record | undefined { const data = node.data; const config: Record = { ...(data.config ?? {}) }; const fallbackLabel = data.kind === "merge" ? "Merge boundary" : node.id; if (data.kind !== "start" && data.kind !== "end" && data.label && data.label !== fallbackLabel) { config.name = data.label; } else { delete config.name; } return config; } /** * Project React Flow nodes/edges back into a WorkflowIr plus a layout map. * * When `columns` is provided (the editor manages columns via WorkflowColumnPanel) * the result is a **v2** IR: column bands are dropped, each step node's `column` * is derived by hit-testing its y against the ordered bands, and split/join/hold * config is preserved verbatim. With no columns the result is a v1 IR (legacy * round-trip, byte-compatible with the pre-U10 mapping). */ export function flowToIr( name: string, nodes: FlowNode[], edges: FlowEdge[], columns?: WorkflowIrColumn[], fields?: WorkflowFieldDefinition[], settings?: WorkflowSettingDefinition[], ): { ir: WorkflowIr; layout: Record } { const realNodes = nodes.filter((n) => !isColumnBandNode(n.id)); // Partition by parentId: foreach group children reassemble into that group's // config.template; everything else (no parentId) is top-level. (Column band // group nodes are already excluded above.) const topNodes = realNodes.filter((n) => !n.parentId); const childrenByGroup = new Map[]>(); for (const n of realNodes) { if (n.parentId) { const arr = childrenByGroup.get(n.parentId) ?? []; arr.push(n); childrenByGroup.set(n.parentId, arr); } } const groupIds = new Set( topNodes.filter((n) => n.data.kind === "foreach" || n.data.kind === "loop").map((n) => n.id), ); const hasFields = Array.isArray(fields) && fields.length > 0; const hasSettings = Array.isArray(settings) && settings.length > 0; // Fields and settings are v2-only declarations: a workflow with either but no // custom columns still serializes as v2 (with the synthesized default columns). const v2 = (Array.isArray(columns) && columns.length > 0) || hasFields || hasSettings; const layout: Record = {}; /** Project one flow node (top-level or template child) into an IR node. */ function toIrNode(node: FlowNode, localId: string): WorkflowIrNode { const data = node.data; const config = nodeConfig(node); if (data.kind === "merge") { return { id: localId, kind: "prompt", config: { ...(config ?? {}), seam: "merge" } }; } if (data.kind === "foreach" || data.kind === "loop") { // Reassemble the template from this group's children. const children = childrenByGroup.get(node.id) ?? []; const templateNodes: WorkflowIrNode[] = children.map((c) => { const innerId = templateNodeIdFromChild(node.id, c.id); layout[c.id] = { x: Math.round(c.position.x), y: Math.round(c.position.y) }; return toIrNode(c, innerId); }); const childIdSet = new Set(children.map((c) => c.id)); const templateEdges: WorkflowIrEdge[] = edges .filter((e) => childIdSet.has(e.source) && childIdSet.has(e.target)) .map((e) => flowEdgeToIr(e, node.id)); const baseCfg = (config ?? {}) as Record; return { id: localId, kind: data.kind, config: { ...baseCfg, template: { nodes: templateNodes, edges: templateEdges } }, }; } return { id: localId, kind: data.kind as WorkflowIrNode["kind"], config: config && Object.keys(config).length ? config : undefined, }; } const hasColumns = Array.isArray(columns) && columns.length > 0; const irNodes: WorkflowIr["nodes"] = topNodes.map((node) => { const column = hasColumns ? node.data.column ?? columnForY(node.position.y, columns!) : undefined; const base = toIrNode(node, node.id); layout[node.id] = { x: Math.round(node.position.x), y: Math.round(node.position.y) }; return column ? { ...base, column } : base; }); // Top-level edges: exclude any edge that lives entirely inside a foreach // template (both endpoints are children of the same group) — those are folded // into the group's template above. const childIdToGroup = new Map(); for (const [gid, kids] of childrenByGroup) for (const k of kids) childIdToGroup.set(k.id, gid); const irEdges: WorkflowIr["edges"] = edges .filter((e) => { const sg = childIdToGroup.get(e.source); const tg = childIdToGroup.get(e.target); return !(sg && tg && sg === tg); }) .map((e) => flowEdgeToIr(e)); void groupIds; if (v2) { const ir: WorkflowIrV2 = { version: "v2", name, // Preserve the optional column-agent binding through the editor round-trip // (column-agent plan U6). Omit the `agent` key entirely when unset so // legacy/default workflows stay byte-identical (R9) — never emit // `agent: undefined`/`agent: null`. columns: hasColumns ? columns!.map((c) => ({ id: c.id, name: c.name, traits: c.traits, ...(c.agent ? { agent: c.agent } : {}), })) : [], nodes: irNodes, edges: irEdges, }; if (hasFields) { // The IR's `fields` is typed against @fusion/core's concrete // WorkflowFieldDefinition; the editor carries the array through opaquely // and the server validator is the source of truth, so assign via unknown. (ir as { fields?: unknown }).fields = fields!.map((f) => ({ ...f })); } if (hasSettings) { // Setting DECLARATIONS round-trip through the editor opaquely (server // validator is the source of truth, same as fields). Values live in the // workflow_settings table, NOT in the IR (KTD-2). (ir as { settings?: unknown }).settings = settings!.map((s) => ({ ...s, options: s.options ? s.options.map((o) => ({ ...o })) : undefined, render: s.render ? { ...s.render } : undefined, })); } return { ir, layout }; } return { ir: { version: "v1", name, nodes: irNodes, edges: irEdges }, layout }; } /** Project a React Flow edge into an IR edge. Rework edges carry `kind`. When * `groupId` is given the endpoints are de-namespaced back to template-local * ids. */ function flowEdgeToIr(edge: FlowEdge, groupId?: string): WorkflowIrEdge { const condition = (edge.data?.condition as string | undefined) ?? "success"; const isRework = (edge.data?.kind as string | undefined) === "rework"; const from = groupId ? templateNodeIdFromChild(groupId, edge.source) : edge.source; const to = groupId ? templateNodeIdFromChild(groupId, edge.target) : edge.target; return { from, to, condition, ...(isRework ? { kind: "rework" as const } : {}) }; } // ── Deletion with cascade semantics (U3, R6) ───────────────────────────────── // // Pure node/edge transformation for deleting nodes and/or edges. React Flow's // built-in deletion removes incident edges but does NOT cascade group children // (deleting a foreach group leaves its `parentId` children orphaned), so the // editor routes all deletions through this helper for explicit, testable // behavior. /** Node kinds that may never be deleted (start/end are structural). */ const PROTECTED_NODE_KINDS = new Set(["start", "end"]); /** True when a flow node is protected from deletion: start/end kinds and column * band group nodes are never removable, regardless of the requested ids. */ function isProtectedFromDelete(node: FlowNode): boolean { return isColumnBandNode(node.id) || PROTECTED_NODE_KINDS.has(node.data.kind); } /** * Delete the requested node and/or edge ids from the flow graph, applying R6's * cascade rules: * - Deleting a node removes ALL edges incident to it (no auto-bridging). * - Deleting a `foreach`/`loop` group node also deletes its template children * (nodes with `parentId === groupId`) and every edge incident to those * children (React Flow does not cascade parents — handled explicitly). * - `start`/`end` nodes and column band nodes are never deleted: they are * filtered out of the requested ids up front (and their incident edges are * therefore preserved). * - Edge ids in `ids` are removed directly. * * Pure and order-independent: the same `ids` set always yields the same result. */ export function cascadeDelete( nodes: FlowNode[], edges: FlowEdge[], ids: Iterable, ): { nodes: FlowNode[]; edges: FlowEdge[] } { const requested = new Set(ids); const nodeById = new Map(nodes.map((n) => [n.id, n])); // Resolve which node ids are actually deletable, expanding template groups to // their template children. Protected nodes are dropped from the request. const deleteNodeIds = new Set(); for (const id of requested) { const node = nodeById.get(id); if (!node || isProtectedFromDelete(node)) continue; deleteNodeIds.add(id); if (node.data.kind === "foreach" || node.data.kind === "loop") { for (const child of nodes) { if (child.parentId === id) deleteNodeIds.add(child.id); } } } // Edge ids requested directly (only ones that exist as edges). const deleteEdgeIds = new Set(); for (const e of edges) { if (requested.has(e.id)) deleteEdgeIds.add(e.id); } const nextNodes = nodes.filter((n) => !deleteNodeIds.has(n.id)); const nextEdges = edges.filter( (e) => !deleteEdgeIds.has(e.id) && !deleteNodeIds.has(e.source) && !deleteNodeIds.has(e.target), ); return { nodes: nextNodes, edges: nextEdges }; } // ── Edge-condition authoring (U2) ──────────────────────────────────────────── /** Editor node kinds whose edges expose a success/failure condition select * (KTD-2). step-review uses verdict controls; all other kinds are read-only. */ const CONDITION_EDITABLE_KINDS = new Set(["prompt", "script", "gate", "code", "foreach", "loop"]); /** Decide what the edge inspector renders for an edge sourced from `sourceKind`: * - "verdicts": step-review verdict select + rework checkbox (existing); * - "conditions": success/failure native select (KTD-2); * - "readonly": a read-only condition note. Pure so the gating is unit-testable * without rendering edges (jsdom can't). */ export function edgeConditionEditability( sourceKind: string | undefined, ): "verdicts" | "conditions" | "readonly" { if (sourceKind === "step-review") return "verdicts"; if (sourceKind && CONDITION_EDITABLE_KINDS.has(sourceKind)) return "conditions"; return "readonly"; } /** True when adding an edge source→target would create a cycle, i.e. `target` * can already reach `source` by walking existing non-rework edges (rework edges * are the only legal cycles and are excluded from the reachability walk, per * KTD-9). Pure + exported so the connect-time guard is testable at the mapping * layer. */ export function wouldCreateCycle(edges: FlowEdge[], source: string, target: string): boolean { if (source === target) return true; // Build adjacency over non-rework edges only. const adj = new Map(); for (const e of edges) { if ((e.data?.kind as string | undefined) === "rework") continue; const arr = adj.get(e.source) ?? []; arr.push(e.target); adj.set(e.source, arr); } // Can `target` reach `source`? If so, the new source→target edge closes a loop. const seen = new Set(); const stack = [target]; while (stack.length) { const cur = stack.pop()!; if (cur === source) return true; if (seen.has(cur)) continue; seen.add(cur); for (const next of adj.get(cur) ?? []) stack.push(next); } return false; } let edgeSeq = 0; /** Allocate a globally-unique edge id (mirrors the editor's newNodeId pattern). * Used by buildConnectionEdge so parallel success+failure edges between the same * pair don't collide (KTD-3). */ export function newEdgeId(): string { edgeSeq += 1; return `e-${Date.now().toString(36)}-${edgeSeq}`; } let nodeSeq = 0; /** Allocate a globally-unique node id (mirrors the editor's local newNodeId). The * fragment-insert / graph-copy helpers (U8) remap every node id through this so * inserted/copied subgraphs never collide with existing ids. */ export function newNodeId(): string { nodeSeq += 1; return `n-${Date.now().toString(36)}-${nodeSeq}`; } /** Result of attempting to build an edge from a React Flow connection. */ export type BuildConnectionResult = | { edge: FlowEdge } | { error: "missing-endpoint" | "duplicate" | "cycle" }; /** Construct a new success edge for a React Flow connection, reimplementing the * sanity guards React Flow's addEdge provided (KTD-3) plus the author-time cycle * guard (KTD-9). Returns an error tag instead of an edge when the connection is * rejected so the caller can surface a toast: * - missing-endpoint: source or target absent; * - duplicate: an edge with the same source+target+condition already exists * (parallel edges of a DIFFERENT condition between the same pair ARE allowed); * - cycle: the connection would close a non-rework loop, and the endpoints are * not both children of the same foreach template (intra-template rework * cycles are authored separately and exempt). */ export function buildConnectionEdge( connection: { source?: string | null; target?: string | null }, edges: FlowEdge[], nodes: FlowNode[], ): BuildConnectionResult { const source = connection.source ?? undefined; const target = connection.target ?? undefined; if (!source || !target) return { error: "missing-endpoint" }; const srcNode = nodes.find((n) => n.id === source); const tgtNode = nodes.find((n) => n.id === target); // Existing conditions already authored between this exact pair. const existingConditions = new Set( edges .filter((e) => e.source === source && e.target === target) .map((e) => (e.data?.condition as string | undefined) ?? "success"), ); // New edges are normally born "success". But a second connect gesture between a // pair that already has a success edge should author the *parallel* "failure" // edge (rather than being rejected as a duplicate), so users can build a // success/failure split with two connect gestures — but only when the source // kind actually exposes a condition select. Block only when both conditions // already exist (or the only available condition is already taken). const supportsConditions = edgeConditionEditability(srcNode?.data.kind) === "conditions"; let condition = "success"; if (existingConditions.has("success")) { if (supportsConditions && !existingConditions.has("failure")) { condition = "failure"; } else { return { error: "duplicate" }; } } else if (existingConditions.has(condition)) { return { error: "duplicate" }; } // Cycle guard (KTD-9). Exempt connections where both endpoints are children of // the same foreach template — those may legitimately be rework cycles authored // separately; the simplest correct rule applies the guard only to non-template // connections. const bothTemplateChildren = !!srcNode?.parentId && srcNode.parentId === tgtNode?.parentId; if (!bothTemplateChildren && wouldCreateCycle(edges, source, target)) { return { error: "cycle" }; } return { edge: { id: newEdgeId(), source, target, label: shortConditionLabel(condition), data: { condition, kind: undefined }, className: edgeClassName(condition, false), interactionWidth: WF_EDGE_INTERACTION_WIDTH, }, }; } // ── Client-side validation (U10) ───────────────────────────────────────────── // // The server's parseWorkflowIr (run on PATCH) is the authority for structural // errors (undefined-column references, seam-in-branch, duplicate column ids). // These two helpers run client-side so the editor can render precise inline // badges and block the save before a round-trip: // - composition violations attributed to the offending column band; // - unplaced-node errors attributed to the offending step node. // They mirror @fusion/core's validateColumnTraits rules using the catalog flags // (the catalog endpoint ships the same flags the registry validates against). import type { TraitViolation } from "@fusion/core"; import type { TraitCatalogEntry } from "../api"; type CatalogFlags = TraitCatalogEntry["flags"]; function mergedFlags( traits: WorkflowIrColumn["traits"], catalog: Map, ): { flags: CatalogFlags; capacityTraitIds: string[]; unknown: string[] } { const flags: CatalogFlags = {}; const capacityTraitIds: string[] = []; const unknown: string[] = []; for (const ct of traits) { const def = catalog.get(ct.trait); if (!def) { unknown.push(ct.trait); continue; } for (const [k, v] of Object.entries(def.flags)) { if (v) (flags as Record)[k] = true; } if (def.flags.countsTowardWip) capacityTraitIds.push(def.id); } return { flags, capacityTraitIds, unknown }; } /** Client mirror of core's validateColumnTraits, driven by the trait catalog. */ export function validateColumnsClient( columns: WorkflowIrColumn[], catalog: TraitCatalogEntry[], ): TraitViolation[] { const byId = new Map(catalog.map((c) => [c.id, c])); const violations: TraitViolation[] = []; let intakeCount = 0; for (const col of columns) { const { flags, capacityTraitIds, unknown } = mergedFlags(col.traits, byId); for (const u of unknown) { violations.push({ code: "unknown-trait", severity: "error", columnId: col.id, traitIds: [u], message: `Column '${col.id}' references unknown trait '${u}'`, }); } if (flags.complete && flags.countsTowardWip) { violations.push({ code: "complete-with-wip", severity: "error", columnId: col.id, traitIds: capacityTraitIds, message: `Column '${col.name || col.id}' is both a completion column and counts toward WIP`, }); } if (capacityTraitIds.length > 1) { violations.push({ code: "two-capacity-traits", severity: "error", columnId: col.id, traitIds: capacityTraitIds, message: `Column '${col.name || col.id}' has more than one capacity (WIP) trait`, }); } if (flags.complete && flags.intake) { violations.push({ code: "complete-with-intake", severity: "error", columnId: col.id, traitIds: [], message: `Column '${col.name || col.id}' is both a completion column and an intake column`, }); } if (flags.archived && flags.countsTowardWip) { violations.push({ code: "archived-with-wip", severity: "error", columnId: col.id, traitIds: [], message: `Column '${col.name || col.id}' is archived but counts toward WIP`, }); } if (flags.intake) intakeCount += 1; } if (intakeCount > 1) { violations.push({ code: "multiple-intake-columns", severity: "error", columnId: null, traitIds: [], message: `Workflow has ${intakeCount} intake columns; exactly one is allowed`, }); } return violations; } /** Step node ids that are not placed in any column (v2 only). Bands and * start/end are exempt — start/end are structural and need no column. */ export function unplacedNodeIds( nodes: FlowNode[], columns: WorkflowIrColumn[], ): string[] { if (columns.length === 0) return []; const ids: string[] = []; for (const node of nodes) { if (isColumnBandNode(node.id) || node.type === "group") continue; // foreach template children are placed by their parent group, not a column. if (node.parentId) 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] })) : []; } /** Extract the editor's working custom-field list from a definition (KTD-13). * v2 with `fields` → a deep-ish copy; v1 or no fields → empty. */ export function fieldsOf(def: WorkflowDefinition): WorkflowFieldDefinition[] { const ir = def.ir as { fields?: WorkflowFieldDefinition[] }; if (!isV2(def.ir) || !Array.isArray(ir.fields)) return []; return ir.fields.map((f) => ({ ...f, options: f.options ? f.options.map((o) => ({ ...o })) : undefined, render: f.render ? { ...f.render } : undefined, })); } /** Extract the editor's working setting-declaration list from a definition (U6, * KTD-1). v2 with `settings` → a deep-ish copy; v1 or no settings → empty. * Setting VALUES are not carried here — they live per-`(workflowId, projectId)` * in the workflow_settings table and are fetched separately (KTD-2). */ export function settingsOf(def: WorkflowDefinition): WorkflowSettingDefinition[] { const ir = def.ir as { settings?: WorkflowSettingDefinition[] }; if (!isV2(def.ir) || !Array.isArray(ir.settings)) return []; return ir.settings.map((s) => ({ ...s, options: s.options ? s.options.map((o) => ({ ...o })) : undefined, render: s.render ? { ...s.render } : undefined, })); } /** Seed graph for a brand-new workflow: start → end with room to insert steps. */ export function emptyWorkflowIr(name: string): WorkflowIr { return { version: "v1", name, nodes: [ { id: "start", kind: "start" }, { id: "end", kind: "end" }, ], edges: [{ from: "start", to: "end", condition: "success" }], }; } export function emptyWorkflowLayout(): Record { return { start: { x: 80, y: 140 }, end: { x: 460, y: 140 } }; } // ── Fragment insertion + graph copy (U8, R7/R8) ────────────────────────────── // // Pure primitives for the template library: inserting a fragment subgraph into // the live canvas (palette Templates section, U9) and copying a whole workflow // graph with fresh ids (create-from-template picker, U4/R7). All three return // new arrays/objects and never mutate their inputs. /** Seam markers that participate in the duplicate-seam pre-validation. A canvas * may host at most one node per seam, so inserting a fragment that carries a * seam already present on the canvas is rejected. The editor maps the "merge" * seam to its dedicated "merge" node kind, so a merge node counts as the merge * seam even without an explicit config.seam. */ const SEAM_NAMES = new Set(["execute", "review", "merge"]); /** Read the seam marker a flow node represents, if any: an explicit * config.seam, or the "merge" editor kind (which is the merge seam). */ function flowNodeSeam(node: FlowNode): string | undefined { if (node.data?.kind === "merge") return "merge"; const seam = node.data?.config?.seam; return typeof seam === "string" ? seam : undefined; } /** Read the seam marker an IR node carries via its config.seam. */ function irNodeSeam(node: WorkflowIrNode): string | undefined { const seam = node.config?.seam; return typeof seam === "string" ? seam : undefined; } /** * Seam names (execute/review/merge) present in BOTH the fragment and the existing * canvas — i.e. seams that would be duplicated by inserting the fragment. An * empty result means the fragment is safe to insert. Other seam values * (planning, step-execute, …) are not pre-validated here (only the tracked * execute/review/merge seams are single-instance on the canvas). */ export function fragmentSeamConflicts( fragmentIr: WorkflowIr, nodes: FlowNode[], ): string[] { const canvasSeams = new Set(); for (const n of nodes) { const seam = flowNodeSeam(n); if (seam && SEAM_NAMES.has(seam)) canvasSeams.add(seam); } const conflicts: string[] = []; const seen = new Set(); for (const node of fragmentIr.nodes) { const seam = irNodeSeam(node); if (seam && SEAM_NAMES.has(seam) && canvasSeams.has(seam) && !seen.has(seam)) { seen.add(seam); conflicts.push(seam); } } return conflicts; } /** Build a React Flow node from a single IR node at an absolute position — the * same mapping irToFlow applies (kind→type via editorKind, data {kind,label, * config}, deletable). Template group bodies are remapped by the caller; this * carries config (including any template) through verbatim. */ function irNodeToFlowNode( node: WorkflowIrNode, id: string, position: { x: number; y: number }, ): FlowNode { const kind = editorKind(node); return { id, type: kind, position, data: { kind, label: nodeLabel(node), config: { ...(node.config ?? {}) } }, deletable: node.kind !== "start" && node.kind !== "end", }; } /** * Insert a fragment subgraph into a flow graph near `position`. * * The fragment's `start`/`end` nodes (and every edge incident to them) are * stripped; each remaining fragment node id is remapped to a fresh newNodeId() * and the fragment's internal edges are rewired to those ids with fresh edge * ids, preserving condition/kind. Node config and kind are preserved. Inserted * nodes are laid out relative to `position` using the fragment's persisted * layout when present, else simple horizontal x-spacing. * * Returns NEW arrays plus the ids of the inserted (remapped) nodes; inputs are * never mutated. */ export function insertFragment( nodes: FlowNode[], edges: FlowEdge[], fragmentIr: WorkflowIr, position: { x: number; y: number }, layout?: Record, ): { nodes: FlowNode[]; edges: FlowEdge[]; insertedNodeIds: string[]; } { // Drop structural start/end; everything else is a real fragment node. const bodyNodes = fragmentIr.nodes.filter((n) => n.kind !== "start" && n.kind !== "end"); const droppedIds = new Set( fragmentIr.nodes.filter((n) => n.kind === "start" || n.kind === "end").map((n) => n.id), ); // Remap every surviving fragment id to a fresh id. const idMap = new Map(); for (const n of bodyNodes) idMap.set(n.id, newNodeId()); // Anchor the fragment's layout origin so nodes land near `position`. When the // fragment ships layout, preserve relative offsets; otherwise space the nodes // horizontally. const placed = bodyNodes.map((node) => layout?.[node.id]).filter((p): p is { x: number; y: number } => !!p); const minX = placed.length ? Math.min(...placed.map((p) => p.x)) : 0; const minY = placed.length ? Math.min(...placed.map((p) => p.y)) : 0; const insertedNodeIds: string[] = []; // Template group children are expanded into parented child flow nodes (the // same way irToFlow does), so an inserted group round-trips its full template // through flowToIr instead of dropping config.template (which flowToIr would // otherwise rebuild as an empty template from the absent children). const childNodes: FlowNode[] = []; const childEdges: FlowEdge[] = []; const newNodes = bodyNodes.map((node, index): FlowNode => { const id = idMap.get(node.id)!; insertedNodeIds.push(id); const fromLayout = layout?.[node.id]; const pos = fromLayout ? { x: position.x + (fromLayout.x - minX), y: position.y + (fromLayout.y - minY) } : { x: position.x + index * 180, y: position.y }; const groupCfg = groupTemplateConfigOf(node); if (groupCfg) { const template = groupCfg.template; const groupKind = editorKind(node); template.nodes.forEach((inner, innerIdx) => { const innerKind = editorKind(inner); const childPos = layout?.[foreachChildFlowId(node.id, inner.id)] ?? { x: FOREACH_CHILD_X + innerIdx * FOREACH_CHILD_STEP_X, y: FOREACH_CHILD_Y, }; childNodes.push({ id: foreachChildFlowId(id, inner.id), type: innerKind, position: childPos, parentId: id, extent: "parent", data: { kind: innerKind, label: nodeLabel(inner), config: { ...(inner.config ?? {}) } }, deletable: true, }); }); template.edges.forEach((edge, eIdx) => { childEdges.push(irEdgeToFlow(edge, eIdx, `${id}${FOREACH_CHILD_SEP}`)); }); // The group node keeps everything except the template (children carry it). const { template: _t, ...restCfg } = (node.config ?? {}) as Record; return { id, type: groupKind, position: pos, data: { kind: groupKind, label: nodeLabel(node), config: { ...restCfg }, templateEmpty: template.nodes.length === 0, }, style: { width: FOREACH_GROUP_WIDTH, height: FOREACH_GROUP_HEIGHT }, deletable: true, }; } return irNodeToFlowNode(node, id, pos); }); // Rewire only the fragment's INTERNAL edges (both endpoints survived). Edges // touching a stripped start/end node are dropped. const newEdges: FlowEdge[] = fragmentIr.edges .filter((e) => !droppedIds.has(e.from) && !droppedIds.has(e.to)) .filter((e) => idMap.has(e.from) && idMap.has(e.to)) .map((edge, index) => { const flow = irEdgeToFlow(edge, index); return { ...flow, id: newEdgeId(), source: idMap.get(edge.from)!, target: idMap.get(edge.to)!, }; }); return { // Group nodes (in newNodes) must precede their children (childNodes). nodes: [...nodes, ...newNodes, ...childNodes], edges: [...edges, ...newEdges, ...childEdges], insertedNodeIds, }; } /** Remap a template group's internal node ids + edges to fresh ids. Returns a * new template object; the original is untouched. Template-local ids are scoped * to the template, so a fresh local id space suffices (and keeps config compact * rather than reusing global ids). */ function copyGroupTemplate( template: { nodes: WorkflowIrNode[]; edges: WorkflowIrEdge[] }, innerMap: Map, ): { nodes: WorkflowIrNode[]; edges: WorkflowIrEdge[] } { for (const n of template.nodes) innerMap.set(n.id, newNodeId()); const nodes = template.nodes.map((n) => copyIrNode(n, innerMap.get(n.id)!)); const edges = template.edges.map((e) => ({ ...e, from: innerMap.get(e.from) ?? e.from, to: innerMap.get(e.to) ?? e.to, })); return { nodes, edges }; } /** Deep-ish copy of an IR node under a new id, recursing into a template group * template's internal node references so they remain self-consistent. When the * node is a template group, its template-local id remap is recorded in `templateMaps` * keyed by the node's ORIGINAL id, so the caller can remap namespaced * `${groupId}::${templateNodeId}` layout keys consistently. */ function copyIrNode( node: WorkflowIrNode, newId: string, templateMaps?: Map>, ): WorkflowIrNode { const config = node.config ? { ...node.config } : undefined; const group = groupTemplateConfigOf(node); if (group && config) { const innerMap = new Map(); config.template = copyGroupTemplate(group.template, innerMap); templateMaps?.set(node.id, innerMap); } const copy: WorkflowIrNode = { id: newId, kind: node.kind }; if (node.column !== undefined) copy.column = node.column; if (config) copy.config = config; return copy; } /** * Full-graph copy with fresh ids (R7): every top-level node id is remapped to a * fresh id, edges are rewired, and the layout map's keys are remapped to match. * v2 columns/fields/artifacts are preserved untouched (they hold no node id * references). Template group bodies have their internal node ids + edges * remapped consistently too. Returns a NEW ir + layout; inputs are not mutated. */ export function copyIrWithFreshIds( ir: WorkflowIr, layout: Record, ): { ir: WorkflowIr; layout: Record } { const idMap = new Map(); for (const n of ir.nodes) idMap.set(n.id, newNodeId()); // Per template group (by ORIGINAL group id): its template-local id remap, so // namespaced layout keys `${groupId}::${templateNodeId}` can be remapped to // `${newGroupId}::${newTemplateNodeId}` consistently. const templateMaps = new Map>(); const nodes = ir.nodes.map((n) => copyIrNode(n, idMap.get(n.id)!, templateMaps)); const edges = ir.edges.map((e) => ({ ...e, from: idMap.get(e.from) ?? e.from, to: idMap.get(e.to) ?? e.to, })); // Remap layout keys. Top-level node keys remap via idMap; namespaced foreach // child keys remap via the owning group's idMap entry + its inner map; any // unrelated keys pass through unchanged. const newLayout: Record = {}; for (const [key, pos] of Object.entries(layout)) { const sepIdx = key.indexOf(FOREACH_CHILD_SEP); if (sepIdx >= 0) { const groupId = key.slice(0, sepIdx); const innerId = key.slice(sepIdx + FOREACH_CHILD_SEP.length); const newGroupId = idMap.get(groupId); const innerMap = templateMaps.get(groupId); const newInnerId = innerMap?.get(innerId); if (newGroupId && newInnerId) { newLayout[foreachChildFlowId(newGroupId, newInnerId)] = { ...pos }; continue; } } const mapped = idMap.get(key); newLayout[mapped ?? key] = { ...pos }; } let copied: WorkflowIr; if (isV2(ir)) { const v2: WorkflowIrV2 = { ...ir, nodes, edges, columns: ir.columns.map((c) => ({ ...c, traits: c.traits.map((t) => ({ ...t })) })), }; copied = v2; } else { copied = { ...ir, nodes, edges }; } return { ir: copied, layout: newLayout }; }