diff --git a/packages/dashboard/app/components/__tests__/workflow-flow-mapping.test.ts b/packages/dashboard/app/components/__tests__/workflow-flow-mapping.test.ts index 1aecef5f7b..698a8c8f89 100644 --- a/packages/dashboard/app/components/__tests__/workflow-flow-mapping.test.ts +++ b/packages/dashboard/app/components/__tests__/workflow-flow-mapping.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import type { WorkflowDefinition } from "@fusion/core"; +import type { WorkflowDefinition, WorkflowIrNodeKind } from "@fusion/core"; import { parseWorkflowIr } from "@fusion/core"; import type { Node as FlowNode } from "@xyflow/react"; import { @@ -32,7 +32,7 @@ import { FOREACH_CHILD_X, FOREACH_CHILD_Y, } from "../workflow-flow-mapping"; -import type { WorkflowFlowNodeData } from "../nodes/WorkflowNodeTypes"; +import type { WorkflowEditorNodeKind, WorkflowFlowNodeData } from "../nodes/WorkflowNodeTypes"; import type { TraitCatalogEntry } from "../../api"; function makeDef(ir: WorkflowDefinition["ir"]): WorkflowDefinition { @@ -360,6 +360,103 @@ describe("workflow-flow-mapping validation helpers", () => { }); }); +// ── IR-only graph node kinds map to existing editor node shapes ───────────── + +const VALID_EDITOR_NODE_KINDS: readonly WorkflowEditorNodeKind[] = [ + "start", + "end", + "prompt", + "script", + "gate", + "merge", + "hold", + "split", + "join", + "foreach", + "loop", + "step-review", + "parse-steps", + "code", + "notify", +]; + +const IR_ONLY_EDITOR_KIND = { + "merge-gate": "gate", + "merge-attempt": "merge", + "manual-merge-hold": "hold", + "retry-backoff": "hold", + "recovery-router": "gate", + "branch-group-member-integration": "merge", + "branch-group-promotion": "merge", +} satisfies Partial>; + +describe("workflow-flow-mapping editor kind mapping", () => { + it("maps workflow-owned IR-only node kinds to valid editor kinds", () => { + const irOnlyKinds = Object.keys(IR_ONLY_EDITOR_KIND) as (keyof typeof IR_ONLY_EDITOR_KIND)[]; + const ir: WorkflowDefinition["ir"] = { + version: "v2", + name: "policy-nodes", + columns: [{ id: "work", name: "Work", traits: [] }], + nodes: [ + { id: "start", kind: "start", column: "work" }, + ...irOnlyKinds.map((kind) => ({ id: kind, kind, column: "work" as const })), + { id: "foreach", kind: "foreach", column: "work", config: { + source: "task-steps", + template: { + nodes: [{ id: "template-merge-gate", kind: "merge-gate" }], + edges: [], + }, + } }, + { id: "end", kind: "end", column: "work" }, + ], + edges: [], + }; + + const { nodes } = irToFlow(makeDef(ir)); + const stepNodes = nodes.filter((node) => !isColumnBandNode(node.id)); + expect(stepNodes.every((node) => VALID_EDITOR_NODE_KINDS.includes(node.data.kind))).toBe(true); + expect(stepNodes.every((node) => VALID_EDITOR_NODE_KINDS.includes(node.type as WorkflowEditorNodeKind))).toBe(true); + + for (const rawKind of irOnlyKinds) { + const flowNode = stepNodes.find((node) => node.id === rawKind); + const expectedKind = IR_ONLY_EDITOR_KIND[rawKind]; + expect(flowNode?.type).toBe(expectedKind); + expect(flowNode?.data.kind).toBe(expectedKind); + expect(flowNode?.type).not.toBe(rawKind); + expect(flowNode?.data.kind).not.toBe(rawKind); + } + + const templateChild = stepNodes.find((node) => node.id === foreachChildFlowId("foreach", "template-merge-gate")); + expect(templateChild?.type).toBe("gate"); + expect(templateChild?.data.kind).toBe("gate"); + expect(templateChild?.type).not.toBe("merge-gate"); + }); + + it("keeps merge seam and PR graph-node special cases mapped to existing editor kinds", () => { + const ir: WorkflowDefinition["ir"] = { + version: "v1", + name: "special-cases", + nodes: [ + { id: "merge-seam", kind: "prompt", config: { seam: "merge" } }, + { id: "pr-merge", kind: "pr-merge" }, + { id: "pr-create", kind: "pr-create" }, + { id: "pr-respond", kind: "pr-respond" }, + ], + edges: [], + }; + + const byId = Object.fromEntries(irToFlow(makeDef(ir)).nodes.map((node) => [node.id, node])); + expect(byId["merge-seam"]?.type).toBe("merge"); + expect(byId["merge-seam"]?.data.kind).toBe("merge"); + expect(byId["pr-merge"]?.type).toBe("merge"); + expect(byId["pr-merge"]?.data.kind).toBe("merge"); + expect(byId["pr-create"]?.type).toBe("prompt"); + expect(byId["pr-create"]?.data.kind).toBe("prompt"); + expect(byId["pr-respond"]?.type).toBe("prompt"); + expect(byId["pr-respond"]?.data.kind).toBe("prompt"); + }); +}); + // ── U8: step-inversion round-trip (foreach template, rework edges) ─────────── describe("workflow-flow-mapping foreach + rework round-trip", () => { diff --git a/packages/dashboard/app/components/workflow-flow-mapping.ts b/packages/dashboard/app/components/workflow-flow-mapping.ts index bfab9f630f..cd93d61789 100644 --- a/packages/dashboard/app/components/workflow-flow-mapping.ts +++ b/packages/dashboard/app/components/workflow-flow-mapping.ts @@ -5,6 +5,7 @@ import type { WorkflowIrColumn, WorkflowIrNode, WorkflowIrEdge, + WorkflowIrNodeKind, WorkflowDefinition, WorkflowFieldDefinition, WorkflowSettingDefinition, @@ -127,30 +128,58 @@ function isV2(ir: WorkflowIr): ir is WorkflowIrV2 { return ir.version === "v2"; } -/** Resolve the editor node "type" for an IR node (merge seam → "merge"). */ +const SAME_KIND_EDITOR_NODE_KINDS = new Set([ + "start", + "prompt", + "script", + "gate", + "end", + "hold", + "split", + "join", + "foreach", + "loop", + "step-review", + "parse-steps", + "code", + "notify", +]); + +const GRAPH_ONLY_EDITOR_KIND: Partial> = { + "merge-gate": "gate", + "merge-attempt": "merge", + "manual-merge-hold": "hold", + "retry-backoff": "hold", + "recovery-router": "gate", + "branch-group-member-integration": "merge", + "branch-group-promotion": "merge", + "pr-merge": "merge", + "pr-create": "prompt", + "pr-respond": "prompt", +}; + +function isSameKindEditorNodeKind( + kind: WorkflowIrNodeKind, +): kind is Extract { + return SAME_KIND_EDITOR_NODE_KINDS.has(kind); +} + +/** + * Resolve the editor node "type" for an IR node. Graph-only IR policy nodes map + * to the closest existing editor shape: merge/recovery gates render as gate, + * merge/branch actions render as merge, passive waits render as hold, and PR + * nodes reuse merge/prompt until dedicated renderers exist. + */ 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"; - // Workflow-owned merge/retry/recovery nodes are executable IR kinds, but the - // dashboard editor does not expose dedicated palette/renderers for each one. - // Preserve rendering by mapping them to the closest existing editor shape. - if (node.kind === "merge-gate") return "gate"; - if (node.kind === "manual-merge-hold" || node.kind === "retry-backoff") return "hold"; - if (node.kind === "recovery-router") return "split"; - if ( - node.kind === "merge-attempt" || - node.kind === "branch-group-member-integration" || - node.kind === "branch-group-promotion" - ) { - return "merge"; - } - return node.kind; + + const mapped = GRAPH_ONLY_EDITOR_KIND[node.kind]; + if (mapped) return mapped; + + if (isSameKindEditorNodeKind(node.kind)) return node.kind; + + return "prompt"; } function nodeLabel(node: WorkflowIr["nodes"][number]): string {