From e5382f0d8c4836509a726509fb3d9051e6bcb7a0 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sun, 21 Jun 2026 19:40:41 -0700 Subject: [PATCH] refactor(FN-6880): retire the legacy optional-steps declaration surface (U7a) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Optional steps are now graph-native optional-group nodes, so the dead declaration model is removed: the WorkflowOptionalStep type + WorkflowIrV2 .optionalSteps field + validateOptionalSteps (core), and the editor's declaration AUTHORING surface — WorkflowOptionalStepsPanel, optionalStepsOf, and the flowToIr/serializeGraph optionalSteps threading (dashboard). A legacy persisted optionalSteps key is tolerated (ignored) at parse. The per-task TOGGLE surfaces (dropdown, inline card, modal, Workflow tab) are unchanged — they consume ResolvedWorkflowOptionalStep, which stays. The workflow-step seam infrastructure removal remains a separate documented follow-up. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../retire-optional-steps-declaration.md | 5 + .../core/src/__tests__/workflow-ir.test.ts | 31 +-- packages/core/src/index.ts | 1 - packages/core/src/workflow-ir-types.ts | 14 +- packages/core/src/workflow-ir.ts | 39 ++-- packages/core/src/workflow-optional-steps.ts | 2 +- .../app/components/WorkflowNodeEditor.tsx | 85 ++------- .../components/WorkflowOptionalStepsPanel.css | 107 ----------- .../components/WorkflowOptionalStepsPanel.tsx | 177 ------------------ .../__tests__/WorkflowNodeEditor.test.tsx | 38 +--- .../WorkflowOptionalStepsPanel.test.tsx | 92 --------- .../__tests__/workflow-flow-mapping.test.ts | 73 +------- .../app/components/workflow-flow-mapping.ts | 37 ++-- 13 files changed, 79 insertions(+), 622 deletions(-) create mode 100644 .changeset/retire-optional-steps-declaration.md delete mode 100644 packages/dashboard/app/components/WorkflowOptionalStepsPanel.css delete mode 100644 packages/dashboard/app/components/WorkflowOptionalStepsPanel.tsx delete mode 100644 packages/dashboard/app/components/__tests__/WorkflowOptionalStepsPanel.test.tsx diff --git a/.changeset/retire-optional-steps-declaration.md b/.changeset/retire-optional-steps-declaration.md new file mode 100644 index 0000000000..72baaa6311 --- /dev/null +++ b/.changeset/retire-optional-steps-declaration.md @@ -0,0 +1,5 @@ +--- +"@runfusion/fusion": patch +--- + +Retire the legacy optional-step DECLARATION model now that optional steps are graph-native `optional-group` nodes. Remove the `WorkflowOptionalStep` type and the `WorkflowIrV2.optionalSteps` IR field, drop the workflow node editor's optional-step declaration authoring panel (sidebar section, mobile tab, and collapse state), and stop threading an `optionalSteps` array through `flowToIr`/`serializeGraph`. A legacy persisted `optionalSteps` key on an old v2 workflow row is now tolerated (ignored, not validated) at parse so old rows still load as v2, and the rollback-downgrade heuristic still treats such a row as v2. The per-task optional-step toggle surfaces are unchanged — they continue to list and toggle optional steps sourced from `optional-group` nodes via `resolveWorkflowOptionalSteps` (`ResolvedWorkflowOptionalStep`). diff --git a/packages/core/src/__tests__/workflow-ir.test.ts b/packages/core/src/__tests__/workflow-ir.test.ts index 291e681e31..6285828f7f 100644 --- a/packages/core/src/__tests__/workflow-ir.test.ts +++ b/packages/core/src/__tests__/workflow-ir.test.ts @@ -155,7 +155,12 @@ describe("parseWorkflowIr — v2 columns & placement", () => { }); }); -describe("parseWorkflowIr — optionalSteps", () => { +// FNXC:WorkflowOptionalGroup 2026-06-21-18:00: +// The legacy `optionalSteps` declaration field is retired. A legacy persisted +// `optionalSteps` key on an old v2 row is now TOLERATED — no longer validated or +// required — so old rows still parse as v2 (optional steps are graph-native +// `optional-group` nodes now). +describe("parseWorkflowIr — legacy optionalSteps tolerated", () => { const columns = DEFAULT_WORKFLOW_COLUMN_IDS.map((id) => ({ id, name: id, traits: [] })); const base = (): WorkflowIrV2 => v2( columns, @@ -166,27 +171,25 @@ describe("parseWorkflowIr — optionalSteps", () => { [{ from: "start", to: "end" }], ); - it("parses and serializes optionalSteps deterministically", () => { - const ir: WorkflowIrV2 = { + it("parses a legacy v2 row carrying an optionalSteps key without throwing", () => { + const ir = { ...base(), + // Legacy declaration shapes — including ones the old validator rejected — + // are now ignored, not validated. optionalSteps: [ { templateId: "browser-verification" }, - { templateId: "plugin:example:step", defaultOn: true }, + { defaultOn: "yes" }, + "nope", ], - }; + } as unknown as WorkflowIr; + expect(() => parseWorkflowIr(ir)).not.toThrow(); const parsed = parseWorkflowIr(ir); - expect(parsed).toEqual(ir); + expect(parsed.version).toBe("v2"); + // The key passes through untouched (round-trips through serialize/parse). expect(JSON.parse(serializeWorkflowIr(parsed))).toEqual(ir); }); - it("rejects malformed optionalSteps", () => { - expect(() => parseWorkflowIr({ ...base(), optionalSteps: "nope" } as unknown as WorkflowIr)).toThrow(WorkflowIrError); - expect(() => parseWorkflowIr({ ...base(), optionalSteps: [{}] } as unknown as WorkflowIr)).toThrow(/non-empty templateId/); - expect(() => parseWorkflowIr({ ...base(), optionalSteps: [{ templateId: "" }] } as unknown as WorkflowIr)).toThrow(/non-empty templateId/); - expect(() => parseWorkflowIr({ ...base(), optionalSteps: [{ templateId: "browser-verification", defaultOn: "yes" }] } as unknown as WorkflowIr)).toThrow(/defaultOn must be a boolean/); - }); - it("upgrades v1 graphs without optionalSteps", () => { const parsed = parseWorkflowIr({ version: "v1", @@ -196,7 +199,7 @@ describe("parseWorkflowIr — optionalSteps", () => { }); expect(parsed.version).toBe("v2"); if (parsed.version !== "v2") throw new Error("expected v2"); - expect(parsed.optionalSteps).toBeUndefined(); + expect((parsed as { optionalSteps?: unknown }).optionalSteps).toBeUndefined(); }); }); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 8d3f4cb8d2..b40fe3cad9 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -92,7 +92,6 @@ export type { WorkflowFieldOption, WorkflowFieldRender, // Workflow-settings (U1): typed setting declaration IR types. - WorkflowOptionalStep, WorkflowSettingDefinition, WorkflowSettingType, WorkflowSettingOption, diff --git a/packages/core/src/workflow-ir-types.ts b/packages/core/src/workflow-ir-types.ts index 689a7b25c9..2bc146afaa 100644 --- a/packages/core/src/workflow-ir-types.ts +++ b/packages/core/src/workflow-ir-types.ts @@ -332,13 +332,10 @@ export interface WorkflowIrV1 { edges: WorkflowIrEdge[]; } -/** Workflow-declared optional step backed by a workflow-step template. - * Execution-inert: consumed by create/edit UI to seed per-task - * `enabledWorkflowSteps`, never by the graph executor. Absent on legacy graphs. */ -export interface WorkflowOptionalStep { - templateId: string; - defaultOn?: boolean; -} +/* +FNXC:WorkflowOptionalGroup 2026-06-21-18:00: +Retired the legacy declaration-based optional-steps model. The `WorkflowOptionalStep` interface and the `WorkflowIrV2.optionalSteps` field are removed — optional steps are now graph-native `optional-group` NODES (see `WorkflowOptionalGroupConfig` above), resolved by `resolveWorkflowOptionalSteps`. A legacy persisted `optionalSteps` key on an old v2 row is TOLERATED at parse (ignored, not validated) so old rows still load as v2. +*/ /** A v2 workflow IR graph: v1 plus workflow-defined columns and node placement. * Step-inversion adds optional `artifacts` (KTD-12) and `fields` (KTD-13) @@ -354,9 +351,6 @@ export interface WorkflowIrV2 { /** Workflow-settings (U1, R1): typed setting declarations. Additive; absent on * legacy graphs. Values persist per-`(workflowId, projectId)` (U2), not here. */ settings?: WorkflowSettingDefinition[]; - /** Optional workflow-step templates tasks may independently enable/disable via - * `enabledWorkflowSteps`. Execution-inert; the graph executor ignores this facet. */ - optionalSteps?: WorkflowOptionalStep[]; } /** Either IR version. v1 graphs upgrade to v2 on parse (see parseWorkflowIr). */ diff --git a/packages/core/src/workflow-ir.ts b/packages/core/src/workflow-ir.ts index 4f0a50a161..93b56928a3 100644 --- a/packages/core/src/workflow-ir.ts +++ b/packages/core/src/workflow-ir.ts @@ -14,7 +14,6 @@ import type { WorkflowFieldType, WorkflowSettingDefinition, WorkflowSettingType, - WorkflowOptionalStep, } from "./workflow-ir-types.js"; import { getWorkflowExtensionRegistry } from "./workflow-extension-registry.js"; import type { WorkflowExtensionConfigField } from "./workflow-extension-types.js"; @@ -1174,29 +1173,6 @@ function validateSettings(settings: WorkflowSettingDefinition[] | undefined): vo } } -function validateOptionalSteps(optionalSteps: WorkflowOptionalStep[] | undefined): void { - if (optionalSteps === undefined) return; - if (!Array.isArray(optionalSteps)) { - throw new WorkflowIrError("Workflow IR optionalSteps must be an array"); - } - for (const optionalStep of optionalSteps) { - if (!optionalStep || typeof optionalStep !== "object" || Array.isArray(optionalStep)) { - throw new WorkflowIrError("Workflow optional step must be an object"); - } - if (typeof optionalStep.templateId !== "string" || optionalStep.templateId === "") { - throw new WorkflowIrError("Workflow optional step must have a non-empty templateId"); - } - if ( - optionalStep.defaultOn !== undefined && - typeof optionalStep.defaultOn !== "boolean" - ) { - throw new WorkflowIrError( - `Workflow optional step '${optionalStep.templateId}' defaultOn must be a boolean`, - ); - } - } -} - function validateColumns(ir: WorkflowIrV2): void { if (!Array.isArray(ir.columns)) { throw new WorkflowIrError("Workflow IR v2 columns must be an array"); @@ -1376,7 +1352,11 @@ function validateV2(ir: WorkflowIrV2): void { validateNotifyNodes(ir.nodes); validateFields(ir.fields); validateSettings(ir.settings); - validateOptionalSteps(ir.optionalSteps); + // FNXC:WorkflowOptionalGroup 2026-06-21-18:00: + // The legacy `optionalSteps` declaration field is retired (optional steps are + // now graph-native `optional-group` nodes). A legacy persisted `optionalSteps` + // key on an old v2 row is TOLERATED — no longer validated/required — so old + // rows still parse as v2. // Rework edges are legal intra-template (foreach, KTD-5) and — since U6 // generalized the bounded-rework mechanism to the top-level walk — for a @@ -1485,12 +1465,17 @@ export function downgradeIrToV1IfPure(ir: WorkflowIr): WorkflowIr { } // Step-inversion declarations (artifacts/fields), workflow settings (U1), and - // optional workflow-step declarations are v2-only features. + // any legacy persisted optional-step declarations are v2-only features. + // FNXC:WorkflowOptionalGroup 2026-06-21-18:00: + // `optionalSteps` is no longer a typed IR field (retired declaration model), but + // a legacy v2 row may still carry the key. Read it via an untyped cast so such a + // row is still treated as v2 (kept on v2, never silently downgraded). + const legacyOptionalSteps = (ir as { optionalSteps?: unknown[] }).optionalSteps; if ( (ir.artifacts && ir.artifacts.length > 0) || (ir.fields && ir.fields.length > 0) || (ir.settings && ir.settings.length > 0) || - (ir.optionalSteps && ir.optionalSteps.length > 0) + (Array.isArray(legacyOptionalSteps) && legacyOptionalSteps.length > 0) ) { return ir; } diff --git a/packages/core/src/workflow-optional-steps.ts b/packages/core/src/workflow-optional-steps.ts index 1aa0f53a83..4cfef149e8 100644 --- a/packages/core/src/workflow-optional-steps.ts +++ b/packages/core/src/workflow-optional-steps.ts @@ -16,7 +16,7 @@ export interface ResolvedWorkflowOptionalStep { /* FNXC:WorkflowOptionalGroup 2026-06-21-14:05: -Re-pointed the per-task optional-step toggle SOURCE from the execution-inert `ir.optionalSteps` declaration to v2 `optional-group` NODES (one resolved entry per group). The legacy `WorkflowOptionalStep`/`optionalSteps` type stays in place for now — only the resolution + seeding source moved here (U3); the type removal is a later unit (U7). +Re-pointed the per-task optional-step toggle SOURCE from the execution-inert `ir.optionalSteps` declaration to v2 `optional-group` NODES (one resolved entry per group). The legacy `WorkflowOptionalStep` type + `optionalSteps` IR field are now REMOVED (FNXC:WorkflowOptionalGroup 2026-06-21-18:00); a legacy persisted `optionalSteps` key on an old v2 row is tolerated/ignored at parse. KEYING: the resolved entry is keyed by the group node `id`. The output field is still named `templateId` (not renamed) so the four consuming UI surfaces — inline quick-create card, New Task modal/TaskForm, task-detail Workflow tab, and the optional-steps dropdown — keep reading the same shape unchanged; they now toggle group ids into `enabledWorkflowSteps` instead of template ids. Renaming/recreating a group resets per-task state, identical to the prior `templateId` keying. Display metadata: `name` comes from `config.name` (falling back to the node id), `defaultOn` from `config.defaultOn ?? false`. The group node carries no description/icon/phase, so `description` is "" and `phase` defaults to "pre-merge" — keeping every field the consumers read populated and non-blank. */ diff --git a/packages/dashboard/app/components/WorkflowNodeEditor.tsx b/packages/dashboard/app/components/WorkflowNodeEditor.tsx index 726b6e8769..d42e008f35 100644 --- a/packages/dashboard/app/components/WorkflowNodeEditor.tsx +++ b/packages/dashboard/app/components/WorkflowNodeEditor.tsx @@ -17,7 +17,7 @@ import { import { createPortal } from "react-dom"; import { useTranslation } from "react-i18next"; import { X, Plus, Trash2, Save, MessageSquare, Terminal, Shield, GitMerge, Loader2, HelpCircle, PauseCircle, Split, Merge, Repeat, ToggleRight, ClipboardCheck, ListChecks, Code2, Bell, LayoutGrid, Workflow, Download, Upload, ChevronDown, ChevronRight, ChevronLeft, Library, Sparkles, Maximize2, Minimize2 } from "lucide-react"; -import type { WorkflowDefinition, WorkflowIrColumn, TraitViolation, WorkflowStepTemplate, WorkflowOptionalStep, WorkflowIrNodeKind } from "@fusion/core"; +import type { WorkflowDefinition, WorkflowIrColumn, TraitViolation, WorkflowStepTemplate, WorkflowIrNodeKind } from "@fusion/core"; import { getErrorMessage } from "@fusion/core"; import { fetchWorkflows, @@ -66,7 +66,6 @@ import { columnsOf, fieldsOf, settingsOf, - optionalStepsOf, columnsToBandNodes, reconcileNodeColumns, strictColumnForY, @@ -90,7 +89,6 @@ import { fetchTraits, fetchStepParsers, type TraitCatalogEntry } from "../api"; import { WorkflowColumnPanel } from "./WorkflowColumnPanel"; import { WorkflowFieldsPanel } from "./WorkflowFieldsPanel"; import { WorkflowSettingsPanel } from "./WorkflowSettingsPanel"; -import { WorkflowOptionalStepsPanel } from "./WorkflowOptionalStepsPanel"; import type { WorkflowFieldDefinition, WorkflowSettingDefinition } from "../api"; import { CustomModelDropdown } from "./CustomModelDropdown"; import { MobileWorkflowGraphView } from "./MobileWorkflowGraphView"; @@ -102,7 +100,9 @@ import { } from "./workflow-mobile-graph"; type ExecutorKind = "model" | "agent" | "skill" | "cli" | "cli-agent"; -type MobileWorkflowPanel = "graph" | "add" | "settings" | "fields" | "optional-steps" | "columns" | "actions"; +// FNXC:WorkflowOptionalGroup 2026-06-21-18:00: dropped the "optional-steps" mobile +// panel — the declaration authoring surface is retired (optional-group nodes now). +type MobileWorkflowPanel = "graph" | "add" | "settings" | "fields" | "columns" | "actions"; function builtinSeamPrompt(config: Record | undefined): string { const seam = typeof config?.seam === "string" ? config.seam : ""; @@ -169,7 +169,6 @@ function serializeGraph( columns: WorkflowIrColumn[], fields: WorkflowFieldDefinition[], settings: WorkflowSettingDefinition[], - optionalSteps: WorkflowOptionalStep[], ): string { const { ir, layout } = flowToIr( name, @@ -178,7 +177,6 @@ function serializeGraph( columns.length ? columns : undefined, fields.length ? fields : undefined, settings.length ? settings : undefined, - optionalSteps.length ? optionalSteps : undefined, ); return JSON.stringify({ name, description, ir, layout }); } @@ -749,7 +747,10 @@ function InnerEditor({ // VALUES live per-project in the workflow_settings table (KTD-2) and are // managed by the panel's Values tab, not this declaration array. const [settings, setSettings] = useState([]); - const [optionalSteps, setOptionalSteps] = useState([]); + /* FNXC:WorkflowOptionalGroup 2026-06-21-18:00: + The legacy optional-step DECLARATION authoring state/panel is removed. Optional + steps are graph-native `optional-group` nodes authored through the canvas; the + editor no longer carries a separate `optionalSteps` declaration array. */ // Ref to the settings panel so a `?panel=settings` deep link can scroll it // into view on mount (U6/U9 redirect stubs). const settingsPanelRef = useRef(null); @@ -788,7 +789,6 @@ function InnerEditor({ const columnsCollapsedStorageKey = "fusion:wf-sidebar-columns-collapsed"; const fieldsCollapsedStorageKey = "fusion:wf-sidebar-fields-collapsed"; const settingsCollapsedStorageKey = "fusion:wf-sidebar-settings-collapsed"; - const optionalStepsCollapsedStorageKey = "fusion:wf-sidebar-optional-steps-collapsed"; const [columnsCollapsed, setColumnsCollapsed] = useState(() => { try { return localStorage.getItem(columnsCollapsedStorageKey) === "1"; @@ -810,13 +810,6 @@ function InnerEditor({ return false; } }); - const [optionalStepsCollapsed, setOptionalStepsCollapsed] = useState(() => { - try { - return localStorage.getItem(optionalStepsCollapsedStorageKey) === "1"; - } catch { - return false; - } - }); useEffect(() => { try { localStorage.setItem(columnsCollapsedStorageKey, columnsCollapsed ? "1" : "0"); @@ -838,13 +831,6 @@ function InnerEditor({ // localStorage unavailable (private mode / SSR): non-fatal. } }, [settingsCollapsed]); - useEffect(() => { - try { - localStorage.setItem(optionalStepsCollapsedStorageKey, optionalStepsCollapsed ? "1" : "0"); - } catch { - // localStorage unavailable (private mode / SSR): non-fatal. - } - }, [optionalStepsCollapsed]); // React Flow instance for programmatic viewport control (auto-layout on load). const { setViewport } = useReactFlow(); // Wrapper around so keyboard deletion can return focus to the @@ -1034,10 +1020,10 @@ function InnerEditor({ if (isBuiltin) return false; if (!activeWorkflow || loadedSnapshotRef.current === null) return false; return ( - serializeGraph(name, description, nodes, edges, columns, fields, settings, optionalSteps) !== + serializeGraph(name, description, nodes, edges, columns, fields, settings) !== loadedSnapshotRef.current ); - }, [isBuiltin, activeWorkflow, name, description, nodes, edges, columns, fields, settings, optionalSteps]); + }, [isBuiltin, activeWorkflow, name, description, nodes, edges, columns, fields, settings]); const loadWorkflows = useCallback(async () => { setLoading(true); @@ -1185,7 +1171,6 @@ function InnerEditor({ setColumns([]); setFields([]); setSettings([]); - setOptionalSteps([]); setName(""); setDescription(""); loadedSnapshotRef.current = null; @@ -1195,7 +1180,6 @@ function InnerEditor({ const loadedColumns = columnsOf(activeWorkflow); const loadedFields = fieldsOf(activeWorkflow); const loadedSettings = settingsOf(activeWorkflow); - const loadedOptionalSteps = optionalStepsOf(activeWorkflow); // Auto-layout on load: compute tidy positions and apply them before the // first render so nodes are visible in the top-left viewport. const layoutPositions = autoLayout(flow.nodes, flow.edges, loadedColumns); @@ -1205,7 +1189,6 @@ function InnerEditor({ setColumns(loadedColumns); setFields(loadedFields); setSettings(loadedSettings); - setOptionalSteps(loadedOptionalSteps); setName(activeWorkflow.name); setDescription(activeWorkflow.description ?? ""); setEditingName(false); @@ -1220,7 +1203,6 @@ function InnerEditor({ loadedColumns, loadedFields, loadedSettings, - loadedOptionalSteps, ); setSelectedNodeId(null); setSelectedEdgeId(null); @@ -1555,11 +1537,12 @@ function InnerEditor({ setEdges(flow.edges); setColumns(columnsOf({ ...targetWorkflow, ir: result.ir })); setFields(fieldsOf({ ...targetWorkflow, ir: result.ir })); - // Hydrate settings + optionalSteps on the fragment/generate path too — it - // previously dropped both, which silently lost the declarations on the next - // save (the round-trip data loss U2 fixes for the primary load path). + // Hydrate settings on the fragment/generate path too — it previously dropped + // them, which silently lost the declarations on the next save (the round-trip + // data loss U2 fixes for the primary load path). Optional steps need no + // separate hydration: they are graph-native `optional-group` nodes carried by + // the node/edge mapping above (FNXC:WorkflowOptionalGroup 2026-06-21-18:00). setSettings(settingsOf({ ...targetWorkflow, ir: result.ir })); - setOptionalSteps(optionalStepsOf({ ...targetWorkflow, ir: result.ir })); setSelectedNodeId(null); setSelectedEdgeId(null); setValidationError(null); @@ -1871,7 +1854,6 @@ function InnerEditor({ columns.length ? columns : undefined, fields.length ? fields : undefined, settings.length ? settings : undefined, - optionalSteps.length ? optionalSteps : undefined, ); // Include name/description in the PATCH only when they changed from the // loaded workflow (KTD-10 inline rename/description persist here). @@ -1889,7 +1871,6 @@ function InnerEditor({ columns, fields, settings, - optionalSteps, ); setName(updated.name); setDescription(updated.description ?? ""); @@ -1959,7 +1940,7 @@ function InnerEditor({ } finally { setSaving(false); } - }, [activeWorkflow, name, description, nodes, edges, columns, fields, settings, optionalSteps, unplaced, blockingViolationCount, projectId, addToast, t]); + }, [activeWorkflow, name, description, nodes, edges, columns, fields, settings, unplaced, blockingViolationCount, projectId, addToast, t]); // Stamp the shared error-state badge onto offending nodes: unplaced step // nodes and any node the server flagged (seam-in-branch). One component @@ -2527,26 +2508,9 @@ function InnerEditor({ )} -
- - {!optionalStepsCollapsed && ( - p.template)} - /> - )} -
+ {/* FNXC:WorkflowOptionalGroup 2026-06-21-18:00: The optional-step + DECLARATION authoring sidebar section is removed. Optional steps + are authored as graph-native `optional-group` nodes on the canvas. */} )} @@ -2669,7 +2633,6 @@ function InnerEditor({ ["add", t("workflowNodes.mobileAdd", "Add")], ["settings", t("workflowSettings.title", "Settings")], ["fields", t("workflowFields.title", "Fields")], - ["optional-steps", t("workflowOptionalSteps.title", "Optional steps")], ["columns", t("workflowColumns.title", "Columns")], ["actions", t("workflowNodes.mobileActions", "Actions")], ] as Array<[MobileWorkflowPanel, string]>).map(([panel, label]) => ( @@ -2866,16 +2829,6 @@ function InnerEditor({ )} - {mobilePanel === "optional-steps" && ( -
- p.template)} - /> -
- )} {mobilePanel === "columns" && (
diff --git a/packages/dashboard/app/components/WorkflowOptionalStepsPanel.css b/packages/dashboard/app/components/WorkflowOptionalStepsPanel.css deleted file mode 100644 index d458de87a4..0000000000 --- a/packages/dashboard/app/components/WorkflowOptionalStepsPanel.css +++ /dev/null @@ -1,107 +0,0 @@ -/* WorkflowOptionalStepsPanel — sibling of WorkflowFieldsPanel; mirrors its layout - * so the optional-steps panel reads consistently alongside Fields/Settings. */ - -.wf-optional-steps-panel { - display: flex; - flex-direction: column; - gap: var(--space-sm); - padding: var(--space-md); -} - -.wf-optional-steps-header h3 { - margin: 0; -} - -.wf-optional-steps-hint, -.wf-optional-steps-empty { - font-size: 0.75rem; - color: var(--text-muted); - margin: 0; -} - -.wf-optional-steps-list { - list-style: none; - margin: 0; - padding: 0; - display: flex; - flex-direction: column; - gap: var(--space-sm); -} - -.wf-optional-step-item { - display: flex; - flex-direction: column; - gap: 4px; - padding: var(--space-sm); - border: 1px solid var(--border); - border-radius: var(--radius-sm, 6px); -} - -.wf-optional-step-item.is-unknown { - opacity: 0.6; -} - -.wf-optional-step-head { - display: flex; - align-items: center; - justify-content: space-between; - gap: 8px; -} - -.wf-optional-step-title { - display: inline-flex; - align-items: center; - gap: 6px; - min-width: 0; -} - -.wf-optional-step-name { - font-weight: 600; - font-size: 0.8rem; -} - -.wf-optional-step-name--unknown { - font-style: italic; - font-weight: 400; -} - -.wf-optional-step-description { - font-size: 0.72rem; - color: var(--text-muted); - margin: 0; -} - -.wf-optional-step-default { - display: inline-flex; - align-items: center; - gap: 6px; - font-size: 0.75rem; -} - -.wf-optional-step-remove { - display: inline-flex; - align-items: center; - justify-content: center; - background: transparent; - border: none; - color: var(--text-muted); - cursor: pointer; -} - -.wf-optional-step-remove:hover:not(:disabled) { - color: var(--color-error); -} - -.wf-optional-steps-add { - display: flex; - flex-direction: column; - gap: 4px; -} - -.wf-optional-steps-add-label { - display: inline-flex; - align-items: center; - gap: 4px; - font-size: 0.75rem; - color: var(--text-muted); -} diff --git a/packages/dashboard/app/components/WorkflowOptionalStepsPanel.tsx b/packages/dashboard/app/components/WorkflowOptionalStepsPanel.tsx deleted file mode 100644 index 40bdc94a0f..0000000000 --- a/packages/dashboard/app/components/WorkflowOptionalStepsPanel.tsx +++ /dev/null @@ -1,177 +0,0 @@ -/** - * FNXC:WorkflowOptionalSteps 2026-06-21-00:00: - * Workflow authors need to declare which step templates are optional and set each - * one's defaultOn from the visual editor (persisted on the IR's `optionalSteps` - * array) so optional steps are authorable without hand-editing IR. - * - * WorkflowOptionalStepsPanel — the workflow editor's optional-step authoring - * surface. Sibling to {@link WorkflowFieldsPanel} / WorkflowSettingsPanel: lives - * alongside the canvas in {@link WorkflowNodeEditor} and mutates the IR's - * `optionalSteps` array through the same state/save flow (preserved across the - * round-trip by `flowToIr`). - * - * A declaration is just `{ templateId, defaultOn? }`. Display metadata - * (name/description/phase) is resolved from the built-in step-template catalog at - * render time — never duplicated into the IR — so the resolver stays the single - * source of truth. Unknown/stale template ids render a muted, still-removable row - * rather than being silently dropped. - */ -import { useCallback, useMemo } from "react"; -import { useTranslation } from "react-i18next"; -import { Plus, Trash2 } from "lucide-react"; -import { WORKFLOW_STEP_TEMPLATES, type WorkflowOptionalStep, type WorkflowStepTemplate } from "@fusion/core"; -import { phaseBadge } from "./workflow-phase-badge"; -import "./WorkflowOptionalStepsPanel.css"; - -interface WorkflowOptionalStepsPanelProps { - optionalSteps: WorkflowOptionalStep[]; - onChange: (next: WorkflowOptionalStep[]) => void; - readOnly: boolean; - /** Plugin-contributed templates, merged into the catalog when available. */ - pluginTemplates?: WorkflowStepTemplate[]; -} - -export function WorkflowOptionalStepsPanel({ - optionalSteps, - onChange, - readOnly, - pluginTemplates = [], -}: WorkflowOptionalStepsPanelProps) { - const { t } = useTranslation("app"); - - const templatesById = useMemo(() => { - const map = new Map(); - for (const tpl of [...WORKFLOW_STEP_TEMPLATES, ...pluginTemplates]) map.set(tpl.id, tpl); - return map; - }, [pluginTemplates]); - - const declaredIds = useMemo(() => new Set(optionalSteps.map((s) => s.templateId)), [optionalSteps]); - - // Catalog entries not already declared — the "Add optional step" picker source. - const available = useMemo( - () => [...templatesById.values()].filter((tpl) => !declaredIds.has(tpl.id)), - [templatesById, declaredIds], - ); - - const addStep = useCallback( - (templateId: string) => { - if (!templateId || declaredIds.has(templateId)) return; - onChange([...optionalSteps, { templateId, defaultOn: false }]); - }, - [optionalSteps, onChange, declaredIds], - ); - - const removeStep = useCallback( - (templateId: string) => onChange(optionalSteps.filter((s) => s.templateId !== templateId)), - [optionalSteps, onChange], - ); - - const toggleDefaultOn = useCallback( - (templateId: string, defaultOn: boolean) => - onChange(optionalSteps.map((s) => (s.templateId === templateId ? { ...s, defaultOn } : s))), - [optionalSteps, onChange], - ); - - return ( - - ); -} - -export default WorkflowOptionalStepsPanel; diff --git a/packages/dashboard/app/components/__tests__/WorkflowNodeEditor.test.tsx b/packages/dashboard/app/components/__tests__/WorkflowNodeEditor.test.tsx index b93babdb02..f10fd6db68 100644 --- a/packages/dashboard/app/components/__tests__/WorkflowNodeEditor.test.tsx +++ b/packages/dashboard/app/components/__tests__/WorkflowNodeEditor.test.tsx @@ -164,13 +164,9 @@ function v2Def(): WorkflowDefinition { }; } -function v2DefWithOptional(): WorkflowDefinition { - const base = v2Def(); - return { - ...base, - ir: { ...(base.ir as object), optionalSteps: [{ templateId: "browser-verification" }] } as WorkflowDefinition["ir"], - }; -} +// FNXC:WorkflowOptionalGroup 2026-06-21-18:00: `v2DefWithOptional` and its +// optional-step DECLARATION hydration/save test are removed — the declaration +// authoring panel is retired (optional-group nodes now). function builtinDef(): WorkflowDefinition { return { @@ -751,34 +747,6 @@ describe("WorkflowNodeEditor", () => { expect(start?.column).toBe("done"); }); - it("hydrates declared optional steps and preserves them through a dirty save (round-trip)", async () => { - vi.mocked(fetchWorkflows).mockResolvedValue([v2DefWithOptional()]); - vi.mocked(updateWorkflow).mockImplementation(async (_id, updates) => ({ - ...v2DefWithOptional(), - ...(updates as object), - })); - vi.mocked(compileWorkflow).mockResolvedValue({ steps: [] }); - - render( {}} addToast={() => {}} />); - - await screen.findByText("Save"); - // The declared optional step is hydrated into the panel (optionalStepsOf). - const row = await screen.findByTestId("wf-optional-step-browser-verification"); - expect(within(row).getByText("Browser Verification")).toBeTruthy(); - - // Toggling defaultOn must mark the editor dirty (serializeGraph threading) so - // the Save button enables and persists the change. - fireEvent.click(within(row).getByRole("checkbox")); - fireEvent.click(screen.getByText("Save").closest("button")!); - - await waitFor(() => expect(updateWorkflow).toHaveBeenCalled()); - const [, updates] = vi.mocked(updateWorkflow).mock.calls[0]; - const ir = (updates as { ir: WorkflowDefinition["ir"] }).ir as { - optionalSteps?: { templateId: string; defaultOn?: boolean }[]; - }; - expect(ir.optionalSteps).toEqual([{ templateId: "browser-verification", defaultOn: true }]); - }); - it("renders the start inspector without the entry-column select for v1 workflows", async () => { vi.mocked(fetchWorkflows).mockResolvedValue([def()]); diff --git a/packages/dashboard/app/components/__tests__/WorkflowOptionalStepsPanel.test.tsx b/packages/dashboard/app/components/__tests__/WorkflowOptionalStepsPanel.test.tsx deleted file mode 100644 index 93398b01f9..0000000000 --- a/packages/dashboard/app/components/__tests__/WorkflowOptionalStepsPanel.test.tsx +++ /dev/null @@ -1,92 +0,0 @@ -import { describe, it, expect, vi, afterEach } from "vitest"; -import { render, screen, fireEvent, cleanup, within } from "@testing-library/react"; -import { useState } from "react"; -import type { WorkflowOptionalStep } from "@fusion/core"; -import { WorkflowOptionalStepsPanel } from "../WorkflowOptionalStepsPanel"; - -// Controlled host mirroring how WorkflowNodeEditor drives the panel. -function Host({ - initial, - readOnly = false, - onState, -}: { - initial: WorkflowOptionalStep[]; - readOnly?: boolean; - onState?: (s: WorkflowOptionalStep[]) => void; -}) { - const [optionalSteps, setOptionalSteps] = useState(initial); - return ( - { - setOptionalSteps(next); - onState?.(next); - }} - /> - ); -} - -afterEach(() => { - cleanup(); - vi.clearAllMocks(); -}); - -describe("WorkflowOptionalStepsPanel", () => { - it("renders the empty state and an add picker when no steps are declared", () => { - render(); - expect(screen.getByText(/No optional steps/i)).toBeTruthy(); - const select = screen.getByTestId("wf-optional-steps-add-select") as HTMLSelectElement; - // browser-verification is in the catalog and not yet declared → available. - expect(within(select).getByRole("option", { name: "Browser Verification" })).toBeTruthy(); - }); - - it("adds a step from the picker (defaultOn false) and removes it from the picker", () => { - const onState = vi.fn(); - render(); - fireEvent.change(screen.getByTestId("wf-optional-steps-add-select"), { - target: { value: "browser-verification" }, - }); - expect(onState).toHaveBeenCalledWith([{ templateId: "browser-verification", defaultOn: false }]); - // The declared row is shown with the resolved template name… - const row = screen.getByTestId("wf-optional-step-browser-verification"); - expect(within(row).getByText("Browser Verification")).toBeTruthy(); - // …and the picker no longer offers it. - const select = screen.getByTestId("wf-optional-steps-add-select") as HTMLSelectElement; - expect(within(select).queryByRole("option", { name: "Browser Verification" })).toBeNull(); - }); - - it("toggles defaultOn for a declared step", () => { - const onState = vi.fn(); - render(); - const row = screen.getByTestId("wf-optional-step-browser-verification"); - fireEvent.click(within(row).getByRole("checkbox")); - expect(onState).toHaveBeenCalledWith([{ templateId: "browser-verification", defaultOn: true }]); - }); - - it("removes a declared step and returns it to the picker", () => { - render(); - const row = screen.getByTestId("wf-optional-step-browser-verification"); - fireEvent.click(within(row).getByRole("button", { name: /Remove optional step/i })); - expect(screen.queryByTestId("wf-optional-step-browser-verification")).toBeNull(); - const select = screen.getByTestId("wf-optional-steps-add-select") as HTMLSelectElement; - expect(within(select).getByRole("option", { name: "Browser Verification" })).toBeTruthy(); - }); - - it("renders an unknown/stale templateId as a muted, still-removable row", () => { - const onState = vi.fn(); - render(); - const row = screen.getByTestId("wf-optional-step-does-not-exist"); - expect(row.className).toContain("is-unknown"); - expect(within(row).getByText(/Unknown step/i)).toBeTruthy(); - fireEvent.click(within(row).getByRole("button", { name: /Remove optional step/i })); - expect(onState).toHaveBeenCalledWith([]); - }); - - it("disables editing when readOnly", () => { - render(); - const row = screen.getByTestId("wf-optional-step-browser-verification"); - expect((within(row).getByRole("checkbox") as HTMLInputElement).disabled).toBe(true); - expect((within(row).getByRole("button", { name: /Remove optional step/i }) as HTMLButtonElement).disabled).toBe(true); - }); -}); 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 864e9ddf79..d214a4da50 100644 --- a/packages/dashboard/app/components/__tests__/workflow-flow-mapping.test.ts +++ b/packages/dashboard/app/components/__tests__/workflow-flow-mapping.test.ts @@ -10,7 +10,6 @@ import { fragmentSeamConflicts, copyIrWithFreshIds, columnsOf, - optionalStepsOf, columnForY, bandTop, columnsToBandNodes, @@ -1636,58 +1635,12 @@ describe("copyIrWithFreshIds", () => { }); }); -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", () => { +// FNXC:WorkflowOptionalGroup 2026-06-21-18:00: +// The legacy optional-step DECLARATION authoring surface is retired: `optionalStepsOf` +// is removed and `flowToIr` no longer accepts/emits an `optionalSteps` array. Optional +// steps are graph-native `optional-group` nodes carried by the normal node/edge mapping. +describe("optionalSteps declaration authoring removed (U7)", () => { + it("flowToIr never emits a legacy optionalSteps key", () => { const { ir: out } = flowToIr( "opt-only", [ @@ -1698,21 +1651,7 @@ describe("optionalSteps round-trip (U2)", () => { [], [], [], - [{ 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 f889a188f2..11c718114e 100644 --- a/packages/dashboard/app/components/workflow-flow-mapping.ts +++ b/packages/dashboard/app/components/workflow-flow-mapping.ts @@ -9,7 +9,6 @@ import type { WorkflowDefinition, WorkflowFieldDefinition, WorkflowSettingDefinition, - WorkflowOptionalStep, } from "@fusion/core"; import type { WorkflowFlowNodeData, WorkflowEditorNodeKind } from "./nodes/WorkflowNodeTypes"; @@ -453,7 +452,6 @@ 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 @@ -475,15 +473,14 @@ export function flowToIr( ); const hasFields = Array.isArray(fields) && fields.length > 0; const hasSettings = Array.isArray(settings) && settings.length > 0; - const hasOptionalSteps = Array.isArray(optionalSteps) && optionalSteps.length > 0; - // FNXC:WorkflowOptionalSteps 2026-06-21-00:00: - // Optional steps must round-trip through the node editor without data loss, yet - // must never upgrade a legacy v1 graph. 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, and the key is omitted entirely (R6 byte-identity for legacy graphs). + // FNXC:WorkflowOptionalGroup 2026-06-21-18:00: + // The editor no longer AUTHORS legacy `optionalSteps` declarations — optional + // steps are graph-native `optional-group` nodes carried through the normal + // node/edge mapping. Fields and settings remain v2-only declarations: a workflow + // with either but no custom columns still serializes as v2 (with the synthesized + // default columns). Empty/absent → not a v2 signal (R6 byte-identity for legacy). const v2 = - (Array.isArray(columns) && columns.length > 0) || hasFields || hasSettings || hasOptionalSteps; + (Array.isArray(columns) && columns.length > 0) || hasFields || hasSettings; const layout: Record = {}; /** Project one flow node (top-level or template child) into an IR node. */ @@ -595,12 +592,6 @@ 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 }; } @@ -1013,15 +1004,11 @@ 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 })); -} +/* FNXC:WorkflowOptionalGroup 2026-06-21-18:00: + `optionalStepsOf` (the editor's legacy `optionalSteps` declaration extractor) + is removed. Optional steps are graph-native `optional-group` nodes now; the + editor reads/writes them through the normal node/edge mapping, and the per-task + toggle surfaces resolve them via `resolveWorkflowOptionalSteps`. */ /** Seed graph for a brand-new workflow: start → end with room to insert steps. */ export function emptyWorkflowIr(name: string): WorkflowIr {