FN-6261: map IR-only workflow nodes to editor types

Normalize graph-only workflow node kinds before rendering them in the workflow editor.\n\n- Add a typed mapping from IR-only merge, retry, recovery, branch, and PR node kinds to supported editor node kinds.\n- Keep same-kind editor node handling type-safe and fall back unknown graph-only kinds to prompt nodes.\n- Cover policy-node, foreach-template, merge-seam, and PR special cases in workflow-flow mapping tests.\n\nFiles changed:\n .../__tests__/workflow-flow-mapping.test.ts        | 101 ++++++++++++++++++++-\n .../app/components/workflow-flow-mapping.ts        |  71 ++++++++++-----\n 2 files changed, 149 insertions(+), 23 deletions(-)

Fusion-Task-Id: FN-6261

Fusion-Task-Lineage: 7c7ccb71-77b6-4759-95ab-6f577473fdb6
This commit is contained in:
gsxdsm
2026-06-11 22:55:48 -07:00
parent 44b756d927
commit 22d6562494
2 changed files with 149 additions and 23 deletions

View File

@@ -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<Record<WorkflowIrNodeKind, WorkflowEditorNodeKind>>;
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", () => {

View File

@@ -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<WorkflowIrNodeKind>([
"start",
"prompt",
"script",
"gate",
"end",
"hold",
"split",
"join",
"foreach",
"loop",
"step-review",
"parse-steps",
"code",
"notify",
]);
const GRAPH_ONLY_EDITOR_KIND: Partial<Record<WorkflowIrNodeKind, WorkflowEditorNodeKind>> = {
"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<WorkflowEditorNodeKind, WorkflowIrNodeKind> {
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 {