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 18dd5b39b3..19d46d461c 100644 --- a/packages/dashboard/app/components/__tests__/workflow-flow-mapping.test.ts +++ b/packages/dashboard/app/components/__tests__/workflow-flow-mapping.test.ts @@ -9,6 +9,7 @@ import { fragmentSeamConflicts, copyIrWithFreshIds, columnsOf, + optionalStepsOf, columnForY, bandTop, columnsToBandNodes, @@ -1503,3 +1504,84 @@ describe("copyIrWithFreshIds", () => { expect(t1NewId).toEqual({ x: 10, y: 20 }); }); }); + +describe("optionalSteps round-trip (U2)", () => { + const v2WithOptional = (optionalSteps?: { templateId: string; defaultOn?: boolean }[]) => + makeDef( + parseWorkflowIr({ + version: "v2", + name: "wf-opt", + columns: [ + { id: "triage", name: "Triage", traits: [] }, + { id: "done", name: "Done", traits: [{ trait: "complete" }] }, + ], + nodes: [ + { id: "start", kind: "start", column: "triage" }, + { id: "end", kind: "end", column: "done" }, + ], + edges: [{ from: "start", to: "end" }], + ...(optionalSteps ? { optionalSteps } : {}), + }), + ); + + it("optionalStepsOf reads declarations from a v2 IR and returns a copy", () => { + const def = v2WithOptional([{ templateId: "browser-verification", defaultOn: true }]); + const read = optionalStepsOf(def); + expect(read).toEqual([{ templateId: "browser-verification", defaultOn: true }]); + // mutating the result does not mutate the source IR + read[0].defaultOn = false; + expect(optionalStepsOf(def)).toEqual([{ templateId: "browser-verification", defaultOn: true }]); + }); + + it("optionalStepsOf returns [] for v1 and for v2 without optionalSteps", () => { + const v1 = makeDef({ + version: "v1", + name: "legacy", + nodes: [ + { id: "start", kind: "start" }, + { id: "end", kind: "end" }, + ], + edges: [{ from: "start", to: "end" }], + }); + expect(optionalStepsOf(v1)).toEqual([]); + expect(optionalStepsOf(v2WithOptional())).toEqual([]); + }); + + it("flowToIr preserves optionalSteps across a full irToFlow round-trip", () => { + const def = v2WithOptional([{ templateId: "browser-verification", defaultOn: true }]); + const { nodes, edges } = irToFlow(def); + const { ir: out } = flowToIr("wf-opt", nodes, edges, columnsOf(def), [], [], optionalStepsOf(def)); + expect((out as { optionalSteps?: unknown }).optionalSteps).toEqual([ + { templateId: "browser-verification", defaultOn: true }, + ]); + }); + + it("serializes as v2 when optionalSteps present but no custom columns/fields/settings", () => { + const { ir: out } = flowToIr( + "opt-only", + [ + { id: "start", type: "workflowNode", position: { x: 0, y: 0 }, data: { kind: "start" } }, + { id: "end", type: "workflowNode", position: { x: 0, y: 200 }, data: { kind: "end" } }, + ] as unknown as FlowNode[], + [{ id: "e1", source: "start", target: "end" }], + [], + [], + [], + [{ templateId: "browser-verification" }], + ); + expect(out.version).toBe("v2"); + expect((out as { optionalSteps?: unknown }).optionalSteps).toEqual([ + { templateId: "browser-verification" }, + ]); + }); + + it("omits the optionalSteps key entirely when empty (R6 byte-identity)", () => { + const def = v2WithOptional(); + const { nodes, edges } = irToFlow(def); + const { ir: out } = flowToIr("wf-opt", nodes, edges, columnsOf(def), [], [], []); + expect("optionalSteps" in out).toBe(false); + // and with the arg omitted entirely + const { ir: out2 } = flowToIr("wf-opt", nodes, edges, columnsOf(def)); + expect("optionalSteps" in out2).toBe(false); + }); +}); diff --git a/packages/dashboard/app/components/workflow-flow-mapping.ts b/packages/dashboard/app/components/workflow-flow-mapping.ts index 4cf2f9ee10..4881e01514 100644 --- a/packages/dashboard/app/components/workflow-flow-mapping.ts +++ b/packages/dashboard/app/components/workflow-flow-mapping.ts @@ -9,6 +9,7 @@ import type { WorkflowDefinition, WorkflowFieldDefinition, WorkflowSettingDefinition, + WorkflowOptionalStep, } from "@fusion/core"; import type { WorkflowFlowNodeData, WorkflowEditorNodeKind } from "./nodes/WorkflowNodeTypes"; @@ -434,6 +435,7 @@ export function flowToIr( columns?: WorkflowIrColumn[], fields?: WorkflowFieldDefinition[], settings?: WorkflowSettingDefinition[], + optionalSteps?: WorkflowOptionalStep[], ): { ir: WorkflowIr; layout: Record } { const realNodes = nodes.filter((n) => !isColumnBandNode(n.id)); // Partition by parentId: foreach group children reassemble into that group's @@ -453,9 +455,12 @@ export function flowToIr( ); 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 hasOptionalSteps = Array.isArray(optionalSteps) && optionalSteps.length > 0; + // Fields, settings, and optional steps are v2-only declarations: a workflow with + // any of them but no custom columns still serializes as v2 (with the synthesized + // default columns). Empty/absent → not a v2 signal (R6 byte-identity). + const v2 = + (Array.isArray(columns) && columns.length > 0) || hasFields || hasSettings || hasOptionalSteps; const layout: Record = {}; /** Project one flow node (top-level or template child) into an IR node. */ @@ -556,6 +561,12 @@ export function flowToIr( render: s.render ? { ...s.render } : undefined, })); } + if (hasOptionalSteps) { + // Optional-step DECLARATIONS round-trip through the editor opaquely (they are + // not graph nodes; the resolver + server validator are the source of truth). + // Omitted entirely when empty so legacy graphs stay byte-identical (R6). + (ir as { optionalSteps?: unknown }).optionalSteps = optionalSteps!.map((o) => ({ ...o })); + } return { ir, layout }; } @@ -967,6 +978,16 @@ export function settingsOf(def: WorkflowDefinition): WorkflowSettingDefinition[] })); } +/** Extract the editor's working optional-step declaration list from a definition. + * v2 with `optionalSteps` → a shallow copy; v1 or none → empty. Display metadata + * (name/icon/phase) is NOT carried here — it is resolved from the step-template + * catalog at render time so the resolver stays the single source of truth. */ +export function optionalStepsOf(def: WorkflowDefinition): WorkflowOptionalStep[] { + const ir = def.ir as { optionalSteps?: WorkflowOptionalStep[] }; + if (!isV2(def.ir) || !Array.isArray(ir.optionalSteps)) return []; + return ir.optionalSteps.map((o) => ({ ...o })); +} + /** Seed graph for a brand-new workflow: start → end with room to insert steps. */ export function emptyWorkflowIr(name: string): WorkflowIr { return {