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:
@@ -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", () => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user