refactor(FN-6880): retire the legacy optional-steps declaration surface (U7a)

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) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-06-21 19:40:41 -07:00
parent a38acaf864
commit e5382f0d8c
13 changed files with 79 additions and 622 deletions

View File

@@ -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`).

View File

@@ -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();
});
});

View File

@@ -92,7 +92,6 @@ export type {
WorkflowFieldOption,
WorkflowFieldRender,
// Workflow-settings (U1): typed setting declaration IR types.
WorkflowOptionalStep,
WorkflowSettingDefinition,
WorkflowSettingType,
WorkflowSettingOption,

View File

@@ -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). */

View File

@@ -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;
}

View File

@@ -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.
*/

View File

@@ -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<string, unknown> | 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<WorkflowSettingDefinition[]>([]);
const [optionalSteps, setOptionalSteps] = useState<WorkflowOptionalStep[]>([]);
/* 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<HTMLDivElement | null>(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<boolean>(() => {
try {
return localStorage.getItem(columnsCollapsedStorageKey) === "1";
@@ -810,13 +810,6 @@ function InnerEditor({
return false;
}
});
const [optionalStepsCollapsed, setOptionalStepsCollapsed] = useState<boolean>(() => {
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 <ReactFlow> 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({
)}
</section>
<section className="wf-sidebar-section" data-testid="wf-sidebar-optional-steps-section">
<button
type="button"
className="wf-sidebar-section-toggle"
aria-expanded={!optionalStepsCollapsed}
data-testid="wf-sidebar-optional-steps-toggle"
onClick={() => setOptionalStepsCollapsed((c) => !c)}
>
{optionalStepsCollapsed ? <ChevronRight size={13} /> : <ChevronDown size={13} />}
<span>{t("workflowOptionalSteps.title", "Optional steps")}</span>
</button>
{!optionalStepsCollapsed && (
<WorkflowOptionalStepsPanel
optionalSteps={optionalSteps}
onChange={setOptionalSteps}
readOnly={isBuiltin}
pluginTemplates={pluginTemplates.map((p) => p.template)}
/>
)}
</section>
{/* 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. */}
</div>
)}
</aside>
@@ -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({
</div>
)}
{mobilePanel === "optional-steps" && (
<div className="wf-mobile-destination">
<WorkflowOptionalStepsPanel
optionalSteps={optionalSteps}
onChange={setOptionalSteps}
readOnly={isBuiltin}
pluginTemplates={pluginTemplates.map((p) => p.template)}
/>
</div>
)}
{mobilePanel === "columns" && (
<div className="wf-mobile-destination">

View File

@@ -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);
}

View File

@@ -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<string, WorkflowStepTemplate>();
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 (
<aside className="wf-optional-steps-panel" data-testid="wf-optional-steps-panel">
<header className="wf-optional-steps-header">
<h3>{t("workflowOptionalSteps.title", "Optional steps")}</h3>
<p className="wf-optional-steps-hint">
{t(
"workflowOptionalSteps.hint",
"Steps a task can toggle on or off. Default sets the initial state for new tasks.",
)}
</p>
</header>
{optionalSteps.length === 0 ? (
<p className="wf-optional-steps-empty">
{t("workflowOptionalSteps.empty", "No optional steps. Add one to let tasks opt in or out.")}
</p>
) : (
<ul className="wf-optional-steps-list">
{optionalSteps.map((step) => {
const tpl = templatesById.get(step.templateId);
const defaultOn = step.defaultOn ?? tpl?.defaultOn ?? false;
return (
<li
key={step.templateId}
className={`wf-optional-step-item${tpl ? "" : " is-unknown"}`}
data-testid={`wf-optional-step-${step.templateId}`}
>
<div className="wf-optional-step-head">
<div className="wf-optional-step-title">
{tpl ? (
<>
<span className="wf-optional-step-name">{tpl.name}</span>
{phaseBadge(tpl.phase ?? "pre-merge", step.templateId, "wf-optional-step-phase", t)}
</>
) : (
<span className="wf-optional-step-name wf-optional-step-name--unknown">
{t("workflowOptionalSteps.unknown", "Unknown step ({{id}})", { id: step.templateId })}
</span>
)}
</div>
<button
type="button"
className="wf-optional-step-remove"
aria-label={t("workflowOptionalSteps.remove", "Remove optional step")}
disabled={readOnly}
onClick={() => removeStep(step.templateId)}
>
<Trash2 size={13} />
</button>
</div>
{tpl?.description && (
<p className="wf-optional-step-description">{tpl.description}</p>
)}
<label className="wf-optional-step-default">
<input
type="checkbox"
checked={defaultOn}
disabled={readOnly}
aria-label={t("workflowOptionalSteps.defaultOnFor", "Default on for {{name}}", {
name: tpl?.name ?? step.templateId,
})}
onChange={(e) => toggleDefaultOn(step.templateId, e.target.checked)}
/>
<span>{t("workflowOptionalSteps.defaultOn", "Default on")}</span>
</label>
</li>
);
})}
</ul>
)}
{available.length > 0 && (
<div className="wf-optional-steps-add">
{/* Picker resets to placeholder after each add (value stays ""). */}
<label className="wf-optional-steps-add-label" htmlFor="wf-optional-steps-add-select">
<Plus size={13} /> {t("workflowOptionalSteps.add", "Add optional step")}
</label>
<select
id="wf-optional-steps-add-select"
data-testid="wf-optional-steps-add-select"
value=""
disabled={readOnly}
onChange={(e) => {
addStep(e.target.value);
e.target.value = "";
}}
>
<option value="" disabled>
{t("workflowOptionalSteps.addPlaceholder", "Select a step…")}
</option>
{available.map((tpl) => (
<option key={tpl.id} value={tpl.id}>
{tpl.name}
</option>
))}
</select>
</div>
)}
</aside>
);
}
export default WorkflowOptionalStepsPanel;

View File

@@ -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(<WorkflowNodeEditor isOpen onClose={() => {}} 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()]);

View File

@@ -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<WorkflowOptionalStep[]>(initial);
return (
<WorkflowOptionalStepsPanel
optionalSteps={optionalSteps}
readOnly={readOnly}
onChange={(next) => {
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(<Host initial={[]} />);
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(<Host initial={[]} onState={onState} />);
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(<Host initial={[{ templateId: "browser-verification", defaultOn: false }]} onState={onState} />);
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(<Host initial={[{ templateId: "browser-verification" }]} />);
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(<Host initial={[{ templateId: "does-not-exist" }]} onState={onState} />);
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(<Host initial={[{ templateId: "browser-verification" }]} readOnly />);
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);
});
});

View File

@@ -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);
});
});

View File

@@ -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<string, { x: number; y: number }> } {
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<string, { x: number; y: number }> = {};
/** 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 {