diff --git a/docs/assets/simplified-workflow-view/lifecycle-warnings-fix-all.png b/docs/assets/simplified-workflow-view/lifecycle-warnings-fix-all.png
new file mode 100644
index 0000000000..0af09b2401
Binary files /dev/null and b/docs/assets/simplified-workflow-view/lifecycle-warnings-fix-all.png differ
diff --git a/docs/assets/simplified-workflow-view/lifecycle-warnings-fixed.png b/docs/assets/simplified-workflow-view/lifecycle-warnings-fixed.png
new file mode 100644
index 0000000000..51b54f69cc
Binary files /dev/null and b/docs/assets/simplified-workflow-view/lifecycle-warnings-fixed.png differ
diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts
index ebff481491..b07db8ce24 100644
--- a/packages/core/src/types.ts
+++ b/packages/core/src/types.ts
@@ -7946,3 +7946,19 @@ export {
export type { ResolvedModelSelection } from "./model-resolution.js";
export { resolveResearchSettings } from "./research-settings.js";
export type { ResolvedResearchSettings } from "./research-settings.js";
+
+/*
+FNXC:WorkflowLifecycleAutofix 2026-07-12-13:00:
+The workflow editor recomputes lifecycle warnings client-side as the graph is
+edited (so the banner clears without a save round-trip) and offers one-click
+fixes that insert the canonical completion-summary node. Both helpers are
+pure (types + string constants only), so they are safe to re-export through
+this browser-safe alias entry.
+*/
+export { analyzeWorkflowLifecycle } from "./workflow-lifecycle-validation.js";
+export type { WorkflowLifecycleWarning, WorkflowLifecycleWarningCode } from "./workflow-lifecycle-validation.js";
+export {
+ completionSummaryNode,
+ isCompletionSummaryNode,
+ COMPLETION_SUMMARY_NODE_ID,
+} from "./builtin-completion-summary-node.js";
diff --git a/packages/dashboard/app/components/WorkflowNodeEditor.css b/packages/dashboard/app/components/WorkflowNodeEditor.css
index 61999843e8..013e987a44 100644
--- a/packages/dashboard/app/components/WorkflowNodeEditor.css
+++ b/packages/dashboard/app/components/WorkflowNodeEditor.css
@@ -1683,6 +1683,41 @@ native marker and rotate the chevron on open.
margin-top: var(--space-xs);
}
+/*
+FNXC:WorkflowLifecycleAutofix 2026-07-12-13:00:
+One-click fix affordances: a quiet pill per fixable warning row plus a
+"Fix all" pill inline in the collapsed summary line.
+*/
+.wf-lifecycle-fix {
+ display: inline-flex;
+ align-items: center;
+ padding: 1px 9px;
+ border: 1px solid color-mix(in srgb, var(--ws-warning) 55%, var(--border));
+ border-radius: var(--radius-pill);
+ background: transparent;
+ color: var(--ws-warning);
+ font: inherit;
+ font-size: 0.7rem;
+ font-weight: 600;
+ cursor: pointer;
+ transition:
+ background var(--transition-fast),
+ color var(--transition-fast);
+}
+
+.wf-lifecycle-fix:hover {
+ background: color-mix(in srgb, var(--ws-warning) 15%, transparent);
+}
+
+.wf-lifecycle-fix:focus-visible {
+ outline: 2px solid var(--accent);
+ outline-offset: 1px;
+}
+
+.wf-lifecycle-fix--all {
+ margin-left: var(--space-xs);
+}
+
.wf-lifecycle-warnings ul {
display: grid;
gap: 4px;
diff --git a/packages/dashboard/app/components/WorkflowNodeEditor.tsx b/packages/dashboard/app/components/WorkflowNodeEditor.tsx
index 208c269648..21794bf31f 100644
--- a/packages/dashboard/app/components/WorkflowNodeEditor.tsx
+++ b/packages/dashboard/app/components/WorkflowNodeEditor.tsx
@@ -20,7 +20,8 @@ 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, DoorOpen } from "lucide-react";
import type { WorkflowDefinition, WorkflowIrColumn, TraitViolation, WorkflowStepTemplate, WorkflowIrNodeKind } from "@fusion/core";
-import { getErrorMessage } from "@fusion/core";
+import { getErrorMessage, analyzeWorkflowLifecycle } from "@fusion/core";
+import type { WorkflowLifecycleWarning, WorkflowLifecycleWarningCode } from "@fusion/core";
import {
fetchWorkflows,
createWorkflow,
@@ -92,6 +93,12 @@ import {
} from "./workflow-flow-mapping";
import { autoLayout, applyAutoLayout } from "./workflow-auto-layout";
import { insertNodeOnEdge, findAppendEdgeId, spliceInsertedSubgraphOnEdge } from "./workflow-simple-layout";
+import {
+ LIFECYCLE_AUTOFIXABLE_CODES,
+ lifecycleFixNodeSpec,
+ applyLifecycleWarningFix,
+ applyAllLifecycleWarningFixes,
+} from "./workflow-lifecycle-autofix";
import { WorkflowSimpleCanvas } from "./WorkflowSimpleCanvas";
import { WorkflowAddStepModal, type AddStepPaletteEntry } from "./WorkflowAddStepModal";
import { fetchTraits, fetchStepParsers, type TraitCatalogEntry } from "../api";
@@ -1035,7 +1042,25 @@ function InnerEditor({
const activeWorkflow = useMemo(() => workflows.find((w) => w.id === activeId), [workflows, activeId]);
const isBuiltin = !!activeWorkflow && isBuiltinWorkflowId(activeWorkflow.id);
- const lifecycleWarnings = activeWorkflow?.lifecycleWarnings ?? [];
+ /*
+ FNXC:WorkflowLifecycleAutofix 2026-07-12-13:00:
+ Lifecycle warnings recompute client-side from the LIVE graph for editable
+ workflows, so the banner reflects edits (including one-click fixes)
+ immediately instead of waiting for the save round-trip. Built-ins are
+ read-only, and an unmaterialized canvas has nothing to analyze — both keep
+ the server-computed warnings.
+ */
+ const lifecycleWarnings: WorkflowLifecycleWarning[] = useMemo(() => {
+ if (!activeWorkflow) return [];
+ const serverWarnings = activeWorkflow.lifecycleWarnings ?? [];
+ if (isBuiltin || nodes.length === 0) return serverWarnings;
+ try {
+ const { ir } = flowToIr(name || activeWorkflow.name, nodes, edges, columns, fields, settings);
+ return analyzeWorkflowLifecycle(ir, { kind: activeWorkflow.kind });
+ } catch {
+ return serverWarnings;
+ }
+ }, [activeWorkflow, isBuiltin, nodes, edges, columns, fields, settings, name]);
// Live mirror of the active workflow id, readable inside async callbacks that
// captured an earlier value before an await (e.g. the AI-design round-trip).
@@ -1760,6 +1785,45 @@ function InnerEditor({
[insertFromAddStep],
);
+ /*
+ FNXC:WorkflowLifecycleAutofix 2026-07-12-13:00:
+ One-click lifecycle fixes: splice the canonical node into the unambiguous
+ wiring point when one exists, else fall back to a free-floating node with
+ the same config (advanced-canvas users can wire it manually). The live
+ warning recompute clears the banner row immediately; Save persists it.
+ */
+ const handleLifecycleFix = useCallback(
+ (code: WorkflowLifecycleWarningCode) => {
+ if (isBuiltin) return;
+ const result = applyLifecycleWarningFix(nodes, edges, code);
+ if (result) {
+ setNodes(result.nodes);
+ setEdges(result.edges);
+ setSelectedNodeId(result.newNodeId);
+ setSelectedEdgeId(null);
+ return;
+ }
+ const spec = lifecycleFixNodeSpec(code);
+ if (spec) addNode(spec.kind, spec.label, spec.presetConfig);
+ },
+ [isBuiltin, nodes, edges, setNodes, setEdges, addNode],
+ );
+
+ const fixableLifecycleCodes = useMemo(
+ () => lifecycleWarnings.map((w) => w.code).filter((code) => LIFECYCLE_AUTOFIXABLE_CODES.has(code)),
+ [lifecycleWarnings],
+ );
+
+ const handleLifecycleFixAll = useCallback(() => {
+ if (isBuiltin || fixableLifecycleCodes.length === 0) return;
+ const result = applyAllLifecycleWarningFixes(nodes, edges, fixableLifecycleCodes);
+ if (!result) return;
+ setNodes(result.nodes);
+ setEdges(result.edges);
+ setSelectedNodeId(result.newNodeId);
+ setSelectedEdgeId(null);
+ }, [isBuiltin, fixableLifecycleCodes, nodes, edges, setNodes, setEdges]);
+
// Auto-layout: one-click left-to-right tidy (U5, R8). Recomputes positions
// only; bands and foreach template children are left in place. Marks the
// editor dirty automatically via the layout serialization in isDirty.
@@ -3104,6 +3168,24 @@ function InnerEditor({
})}
+ {/* FNXC:WorkflowLifecycleAutofix 2026-07-12-13:00: one
+ click inserts every deterministically fixable node
+ (merge boundary, then completion summary upstream of
+ it) without expanding the disclosure. */}
+ {!isBuiltin && fixableLifecycleCodes.length > 0 && (
+
+ )}
{lifecycleWarnings.map((warning, index) => (
@@ -3111,6 +3193,16 @@ function InnerEditor({
{warning.code}
{warning.nodeId && {warning.nodeId}}
{warning.message}
+ {!isBuiltin && LIFECYCLE_AUTOFIXABLE_CODES.has(warning.code) && (
+
+ )}
))}
diff --git a/packages/dashboard/app/components/__tests__/WorkflowNodeEditor.test.tsx b/packages/dashboard/app/components/__tests__/WorkflowNodeEditor.test.tsx
index f3df7675e6..44d6492878 100644
--- a/packages/dashboard/app/components/__tests__/WorkflowNodeEditor.test.tsx
+++ b/packages/dashboard/app/components/__tests__/WorkflowNodeEditor.test.tsx
@@ -550,38 +550,71 @@ describe("WorkflowNodeEditor", () => {
expect(screen.getAllByRole("button", { name: "QA" })[0]).toHaveClass("active");
});
- it("renders workflow lifecycle warnings returned by the store", async () => {
- vi.mocked(fetchWorkflows).mockResolvedValue([
- {
- ...v2Def(),
- lifecycleWarnings: [
- {
- code: "missing-merge-region",
- message: "Full task workflows should include a merge region so done is backed by merge proof.",
- },
- {
- code: "optional-group-after-execution",
- nodeId: "plan-review",
- message: "Plan Review should be ordered before parse/execution.",
- },
- ],
- },
- ]);
+ it("analyzes lifecycle warnings live and fixes a missing completion summary in one click", async () => {
+ // FNXC:WorkflowLifecycleAutofix 2026-07-12-13:00: warnings recompute from
+ // the LIVE graph (not the server snapshot) for editable workflows, and
+ // deterministically fixable codes carry a one-click Fix button.
+ vi.mocked(fetchWorkflows).mockResolvedValue([def()]);
render( {}} addToast={() => {}} />);
- // FNXC:WorkflowEditor 2026-07-12-10:30: banner is a collapsed-by-default
- // disclosure: a count summary line, with details revealed on expand.
+ // def() has a merge seam but no completion-summary node → exactly one
+ // live warning, collapsed to a count line by default.
const banner = await screen.findByTestId("wf-lifecycle-warnings");
- expect(banner).toHaveTextContent("2 lifecycle warnings");
+ expect(banner).toHaveTextContent("1 lifecycle warning");
expect(banner).not.toHaveAttribute("open");
fireEvent.click(screen.getByTestId("wf-lifecycle-warnings-toggle"));
- expect(banner).toHaveAttribute("open");
- expect(banner).toHaveTextContent("missing-merge-region");
- expect(banner).toHaveTextContent("optional-group-after-execution");
- expect(banner).toHaveTextContent("plan-review");
+ expect(banner).toHaveTextContent("missing-completion-summary");
+
+ fireEvent.click(screen.getByTestId("wf-lifecycle-fix-missing-completion-summary"));
+
+ // The canonical summary node is inserted (selected → inspector opens) and
+ // the live re-analysis clears the banner without a save round-trip.
+ await waitFor(() => expect(screen.queryByTestId("wf-lifecycle-warnings")).not.toBeInTheDocument());
+ expect(screen.getAllByText("Completion summary").length).toBeGreaterThan(0);
});
+ it("fixes all lifecycle warnings on a fresh start→end graph and wires summary→merge→end", async () => {
+ const blank: WorkflowDefinition = {
+ ...def(),
+ id: "WF-BLANK",
+ name: "Blank",
+ ir: {
+ version: "v1",
+ name: "Blank",
+ nodes: [
+ { id: "start", kind: "start" },
+ { id: "end", kind: "end" },
+ ],
+ edges: [{ from: "start", to: "end", condition: "success" }],
+ },
+ layout: { start: { x: 0, y: 0 }, end: { x: 360, y: 0 } },
+ };
+ vi.mocked(fetchWorkflows).mockResolvedValue([blank]);
+ vi.mocked(updateWorkflow).mockImplementation(async (_id, updates) => ({ ...blank, ...(updates as object) }));
+
+ render( {}} addToast={() => {}} />);
+
+ const banner = await screen.findByTestId("wf-lifecycle-warnings");
+ expect(banner).toHaveTextContent("2 lifecycle warnings");
+
+ // "Fix all" sits on the collapsed summary line — no expand needed.
+ fireEvent.click(screen.getByTestId("wf-lifecycle-fix-all"));
+ await waitFor(() => expect(screen.queryByTestId("wf-lifecycle-warnings")).not.toBeInTheDocument());
+
+ fireEvent.click(screen.getByText("Save").closest("button")!);
+ await waitFor(() => expect(updateWorkflow).toHaveBeenCalledTimes(1));
+ const [, updates] = vi.mocked(updateWorkflow).mock.calls[0];
+ const ir = (updates as { ir: { nodes: Array<{ id: string; kind: string; config?: Record }>; edges: Array<{ from: string; to: string }> } }).ir;
+ const summary = ir.nodes.find((n) => n.config?.summaryTarget === "task");
+ const merge = ir.nodes.find((n) => n.config?.seam === "merge");
+ expect(summary).toBeDefined();
+ expect(merge).toBeDefined();
+ expect(ir.edges.some((e) => e.from === "start" && e.to === summary!.id)).toBe(true);
+ expect(ir.edges.some((e) => e.from === summary!.id && e.to === merge!.id)).toBe(true);
+ expect(ir.edges.some((e) => e.from === merge!.id && e.to === "end")).toBe(true);
+ expect(ir.edges.some((e) => e.from === "start" && e.to === "end")).toBe(false);
+ });
it("lets desktop users collapse and restore the workflow sidebar", async () => {
vi.mocked(fetchWorkflows).mockResolvedValue([def()]);
diff --git a/packages/dashboard/app/components/__tests__/workflow-lifecycle-autofix.test.ts b/packages/dashboard/app/components/__tests__/workflow-lifecycle-autofix.test.ts
new file mode 100644
index 0000000000..f00ae115dd
--- /dev/null
+++ b/packages/dashboard/app/components/__tests__/workflow-lifecycle-autofix.test.ts
@@ -0,0 +1,93 @@
+import { describe, it, expect } from "vitest";
+import type { Node as FlowNode, Edge as FlowEdge } from "@xyflow/react";
+import type { WorkflowFlowNodeData } from "../nodes/WorkflowNodeTypes";
+import {
+ LIFECYCLE_AUTOFIXABLE_CODES,
+ lifecycleFixNodeSpec,
+ lifecycleFixTargetEdgeId,
+ applyLifecycleWarningFix,
+ applyAllLifecycleWarningFixes,
+} from "../workflow-lifecycle-autofix";
+
+/*
+FNXC:WorkflowLifecycleAutofix 2026-07-12-13:00:
+Unit coverage for the deterministic lifecycle fixes: canonical node specs,
+wiring-point selection (summary lands upstream of an existing merge region),
+and the fix-all composition (merge first, then summary before it).
+*/
+
+type N = FlowNode;
+
+function node(id: string, kind: WorkflowFlowNodeData["kind"], x = 0, y = 0, extra: Partial = {}): N {
+ return { id, type: kind, position: { x, y }, data: { kind, label: id, config: {} }, ...extra };
+}
+
+function edge(id: string, source: string, target: string, condition = "success"): FlowEdge {
+ return { id, source, target, data: { condition } };
+}
+
+describe("lifecycleFixNodeSpec", () => {
+ it("produces the canonical completion-summary config", () => {
+ const spec = lifecycleFixNodeSpec("missing-completion-summary")!;
+ expect(spec.kind).toBe("prompt");
+ expect(spec.presetConfig?.summaryTarget).toBe("task");
+ expect(spec.presetConfig?.toolMode).toBe("readonly");
+ expect(typeof spec.presetConfig?.prompt).toBe("string");
+ });
+
+ it("produces a merge boundary for missing-merge-region and null otherwise", () => {
+ expect(lifecycleFixNodeSpec("missing-merge-region")?.kind).toBe("merge");
+ expect(lifecycleFixNodeSpec("unsafe-terminal-before-merge")).toBeNull();
+ expect(LIFECYCLE_AUTOFIXABLE_CODES.has("optional-group-after-execution")).toBe(false);
+ });
+});
+
+describe("lifecycleFixTargetEdgeId", () => {
+ it("targets the edge into an existing merge node for the summary fix", () => {
+ const nodes = [node("start", "start"), node("a", "prompt", 300, 0), node("m", "merge", 600, 0), node("end", "end", 900, 0)];
+ const edges = [edge("e1", "start", "a"), edge("e2", "a", "m"), edge("e3", "m", "end")];
+ expect(lifecycleFixTargetEdgeId(nodes, edges, "missing-completion-summary")).toBe("e2");
+ expect(lifecycleFixTargetEdgeId(nodes, edges, "missing-merge-region")).toBe("e3");
+ });
+
+ it("falls back to the edge into end when no merge node exists", () => {
+ const nodes = [node("start", "start"), node("end", "end", 360, 0)];
+ const edges = [edge("e1", "start", "end")];
+ expect(lifecycleFixTargetEdgeId(nodes, edges, "missing-completion-summary")).toBe("e1");
+ });
+});
+
+describe("applyAllLifecycleWarningFixes", () => {
+ it("wires start→summary→merge→end on a fresh graph", () => {
+ const nodes = [node("start", "start"), node("end", "end", 360, 0)];
+ const edges = [edge("e1", "start", "end")];
+ const result = applyAllLifecycleWarningFixes(nodes, edges, [
+ "missing-completion-summary",
+ "missing-merge-region",
+ ]);
+ expect(result).not.toBeNull();
+ const summary = result!.nodes.find((n) => n.data.config?.summaryTarget === "task");
+ const merge = result!.nodes.find((n) => n.data.kind === "merge");
+ expect(summary).toBeDefined();
+ expect(merge).toBeDefined();
+ const has = (from: string, to: string) => result!.edges.some((e) => e.source === from && e.target === to);
+ expect(has("start", summary!.id)).toBe(true);
+ expect(has(summary!.id, merge!.id)).toBe(true);
+ expect(has(merge!.id, "end")).toBe(true);
+ expect(has("start", "end")).toBe(false);
+ });
+
+ it("returns null when no wiring point exists and no fix applies", () => {
+ // Two edges into end → ambiguous; no merge node to anchor on.
+ const nodes = [node("start", "start"), node("a", "prompt", 300, 0), node("end", "end", 900, 0)];
+ const edges = [edge("e1", "start", "end"), edge("e2", "a", "end")];
+ expect(applyAllLifecycleWarningFixes(nodes, edges, ["missing-merge-region"])).toBeNull();
+ });
+});
+
+describe("applyLifecycleWarningFix", () => {
+ it("returns null for non-fixable codes", () => {
+ const nodes = [node("start", "start"), node("end", "end", 360, 0)];
+ expect(applyLifecycleWarningFix(nodes, [edge("e1", "start", "end")], "review-gate-without-failure-route")).toBeNull();
+ });
+});
diff --git a/packages/dashboard/app/components/workflow-lifecycle-autofix.ts b/packages/dashboard/app/components/workflow-lifecycle-autofix.ts
new file mode 100644
index 0000000000..74696ab4eb
--- /dev/null
+++ b/packages/dashboard/app/components/workflow-lifecycle-autofix.ts
@@ -0,0 +1,122 @@
+import type { Node as FlowNode, Edge as FlowEdge } from "@xyflow/react";
+import { completionSummaryNode } from "@fusion/core";
+import type { WorkflowLifecycleWarningCode } from "@fusion/core";
+import type { WorkflowFlowNodeData } from "./nodes/WorkflowNodeTypes";
+import {
+ insertNodeOnEdge,
+ findAppendEdgeId,
+ edgeSupportsSimpleInsert,
+ type SimpleInsertSpec,
+ type SimpleInsertResult,
+} from "./workflow-simple-layout";
+
+/*
+FNXC:WorkflowLifecycleAutofix 2026-07-12-13:00:
+Two of the five lifecycle warning codes have a deterministic remedy, so the
+editor's warning banner offers one-click fixes for them (all view modes; the
+simplified view is the motivating surface since its users cannot drag nodes
+into place):
+ - missing-completion-summary → insert the CANONICAL completion-summary
+ prompt node (config from @fusion/core's completionSummaryNode, keyed by
+ summaryTarget:"task") in front of the merge region when one exists,
+ otherwise in front of `end`.
+ - missing-merge-region → insert a Merge boundary node (serializes to
+ prompt + seam:"merge") in front of `end`.
+The remaining codes (unsafe-terminal-before-merge, optional-group-after-
+execution, review-gate-without-failure-route) are structural judgment calls
+and stay manual.
+*/
+
+type LayoutNode = FlowNode;
+
+export const LIFECYCLE_AUTOFIXABLE_CODES: ReadonlySet = new Set([
+ "missing-completion-summary",
+ "missing-merge-region",
+] as WorkflowLifecycleWarningCode[]);
+
+/** The node the fix inserts, shared by the edge-splice path and the
+ * free-floating fallback so both produce identical configs. */
+export function lifecycleFixNodeSpec(code: WorkflowLifecycleWarningCode): SimpleInsertSpec | null {
+ if (code === "missing-merge-region") {
+ return { kind: "merge", label: "Merge boundary" };
+ }
+ if (code === "missing-completion-summary") {
+ // Canonical config (name/prompt/toolMode/summaryTarget) from core; the
+ // column argument only stamps the IR node's column, which the editor
+ // derives from placement instead.
+ const canonical = completionSummaryNode("");
+ return {
+ kind: "prompt",
+ label: "Completion summary",
+ presetConfig: { ...(canonical.config ?? {}) },
+ };
+ }
+ return null;
+}
+
+function singleInboundEdgeId(nodes: LayoutNode[], edges: FlowEdge[], targetId: string): string | null {
+ const inbound = edges.filter(
+ (e) => e.target === targetId && edgeSupportsSimpleInsert(e) && nodes.some((n) => n.id === e.source),
+ );
+ return inbound.length === 1 ? inbound[0].id : null;
+}
+
+/** The edge the fix should splice into, or null when no unambiguous wiring
+ * point exists (caller falls back to a free-floating node). */
+export function lifecycleFixTargetEdgeId(
+ nodes: LayoutNode[],
+ edges: FlowEdge[],
+ code: WorkflowLifecycleWarningCode,
+): string | null {
+ if (code === "missing-merge-region") {
+ return findAppendEdgeId(nodes, edges);
+ }
+ if (code === "missing-completion-summary") {
+ // Prefer directly upstream of the merge region so the summary runs
+ // before review/merge/done, matching the built-in workflows' shape.
+ const mergeNode = nodes.find((n) => !n.parentId && n.data.kind === "merge");
+ if (mergeNode) {
+ const beforeMerge = singleInboundEdgeId(nodes, edges, mergeNode.id);
+ if (beforeMerge) return beforeMerge;
+ }
+ return findAppendEdgeId(nodes, edges);
+ }
+ return null;
+}
+
+/**
+ * Apply one lifecycle fix. Returns the updated graph (and the inserted node
+ * id) or null when the code is not auto-fixable or no unambiguous wiring
+ * point exists.
+ */
+export function applyLifecycleWarningFix(
+ nodes: LayoutNode[],
+ edges: FlowEdge[],
+ code: WorkflowLifecycleWarningCode,
+): SimpleInsertResult | null {
+ const spec = lifecycleFixNodeSpec(code);
+ if (!spec) return null;
+ const edgeId = lifecycleFixTargetEdgeId(nodes, edges, code);
+ if (!edgeId) return null;
+ return insertNodeOnEdge(nodes, edges, edgeId, spec);
+}
+
+/**
+ * Apply every auto-fixable warning in one pass. Merge region first, then the
+ * completion summary — with the merge boundary in place, the summary lands
+ * directly upstream of it. Returns null when nothing could be applied.
+ */
+export function applyAllLifecycleWarningFixes(
+ nodes: LayoutNode[],
+ edges: FlowEdge[],
+ codes: readonly WorkflowLifecycleWarningCode[],
+): SimpleInsertResult | null {
+ const wanted = new Set(codes.filter((code) => LIFECYCLE_AUTOFIXABLE_CODES.has(code)));
+ let current: SimpleInsertResult | null = null;
+ for (const code of ["missing-merge-region", "missing-completion-summary"] as WorkflowLifecycleWarningCode[]) {
+ if (!wanted.has(code)) continue;
+ const next = applyLifecycleWarningFix(current?.nodes ?? nodes, current?.edges ?? edges, code);
+ if (next) current = next;
+ }
+ return current;
+}
diff --git a/packages/i18n/locales/en/app.json b/packages/i18n/locales/en/app.json
index 8728b524f9..ac3fc7d386 100644
--- a/packages/i18n/locales/en/app.json
+++ b/packages/i18n/locales/en/app.json
@@ -8806,6 +8806,8 @@
"importInvalidJson": "That file isn't valid JSON.",
"importStripped": "Auto-approval flags were removed from imported nodes",
"importTooltip": "Import a workflow from a JSON file",
+ "lifecycleFix": "Fix",
+ "lifecycleFixAll": "Fix all",
"lifecycleWarningsCount_one": "{{count}} lifecycle warning",
"lifecycleWarningsCount_other": "{{count}} lifecycle warnings",
"loadFailed": "Failed to load workflows",
diff --git a/packages/i18n/locales/es/app.json b/packages/i18n/locales/es/app.json
index 3226b43905..cfaaab43a3 100644
--- a/packages/i18n/locales/es/app.json
+++ b/packages/i18n/locales/es/app.json
@@ -8795,6 +8795,8 @@
"importInvalidJson": "Ese archivo no es JSON válido.",
"importStripped": "Se eliminaron los indicadores de aprobación automática de los nodos importados",
"importTooltip": "Importar un flujo de trabajo desde un archivo JSON",
+ "lifecycleFix": "",
+ "lifecycleFixAll": "",
"lifecycleWarningsCount_one": "",
"lifecycleWarningsCount_other": "",
"loadFailed": "",
diff --git a/packages/i18n/locales/fr/app.json b/packages/i18n/locales/fr/app.json
index 262fb6e1f0..ca2d79af5e 100644
--- a/packages/i18n/locales/fr/app.json
+++ b/packages/i18n/locales/fr/app.json
@@ -8796,6 +8796,8 @@
"importInvalidJson": "Ce fichier n'est pas un JSON valide.",
"importStripped": "Les indicateurs d'approbation automatique ont été supprimés des nœuds importés",
"importTooltip": "Importer un workflow depuis un fichier JSON",
+ "lifecycleFix": "",
+ "lifecycleFixAll": "",
"lifecycleWarningsCount_one": "",
"lifecycleWarningsCount_other": "",
"loadFailed": "",
diff --git a/packages/i18n/locales/ko/app.json b/packages/i18n/locales/ko/app.json
index 4d94cf400b..8d896e9dc9 100644
--- a/packages/i18n/locales/ko/app.json
+++ b/packages/i18n/locales/ko/app.json
@@ -8795,6 +8795,8 @@
"importInvalidJson": "이 파일은 유효한 JSON이 아닙니다.",
"importStripped": "가져온 노드에서 자동 승인 플래그가 제거되었습니다",
"importTooltip": "JSON 파일에서 워크플로 가져오기",
+ "lifecycleFix": "",
+ "lifecycleFixAll": "",
"lifecycleWarningsCount_one": "",
"lifecycleWarningsCount_other": "",
"loadFailed": "",
diff --git a/packages/i18n/locales/zh-CN/app.json b/packages/i18n/locales/zh-CN/app.json
index 675d4bda76..d16563e0ea 100644
--- a/packages/i18n/locales/zh-CN/app.json
+++ b/packages/i18n/locales/zh-CN/app.json
@@ -8795,6 +8795,8 @@
"importInvalidJson": "该文件不是有效的 JSON。",
"importStripped": "已从导入的节点中移除自动审批标志",
"importTooltip": "从 JSON 文件导入工作流",
+ "lifecycleFix": "",
+ "lifecycleFixAll": "",
"lifecycleWarningsCount_one": "",
"lifecycleWarningsCount_other": "",
"loadFailed": "",
diff --git a/packages/i18n/locales/zh-TW/app.json b/packages/i18n/locales/zh-TW/app.json
index 53229b3011..eb25fee3de 100644
--- a/packages/i18n/locales/zh-TW/app.json
+++ b/packages/i18n/locales/zh-TW/app.json
@@ -8795,6 +8795,8 @@
"importInvalidJson": "該檔案不是有效的 JSON。",
"importStripped": "已從匯入的節點移除自動核准旗標",
"importTooltip": "從 JSON 檔案匯入工作流程",
+ "lifecycleFix": "",
+ "lifecycleFixAll": "",
"lifecycleWarningsCount_one": "",
"lifecycleWarningsCount_other": "",
"loadFailed": "",