feat: live lifecycle warning analysis with one-click Fix / Fix all
Lifecycle warnings now recompute client-side from the live graph for editable workflows, so the banner reflects edits immediately instead of waiting for a save round-trip. The two deterministically fixable codes gain one-click fixes in the banner (all view modes): - missing-merge-region inserts a Merge boundary in front of end; - missing-completion-summary inserts the canonical completion-summary node (config from @fusion/core's completionSummaryNode) upstream of the merge region when one exists, else in front of end. "Fix all" on the collapsed summary line applies both in order, producing start → summary → merge → end on a fresh workflow in one click. The other three codes are structural judgment calls and stay manual. analyzeWorkflowLifecycle + completionSummaryNode are pure and now re-export through core's browser-safe types.ts alias entry. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 112 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 149 KiB |
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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({
|
||||
})}
|
||||
</span>
|
||||
<ChevronDown size={12} className="wf-lifecycle-warnings-chevron" aria-hidden />
|
||||
{/* 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 && (
|
||||
<button
|
||||
type="button"
|
||||
className="wf-lifecycle-fix wf-lifecycle-fix--all"
|
||||
data-testid="wf-lifecycle-fix-all"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleLifecycleFixAll();
|
||||
}}
|
||||
>
|
||||
{t("workflows.lifecycleFixAll", "Fix all")}
|
||||
</button>
|
||||
)}
|
||||
</summary>
|
||||
<ul>
|
||||
{lifecycleWarnings.map((warning, index) => (
|
||||
@@ -3111,6 +3193,16 @@ function InnerEditor({
|
||||
<span className="wf-lifecycle-warning-code">{warning.code}</span>
|
||||
{warning.nodeId && <span className="wf-lifecycle-warning-node">{warning.nodeId}</span>}
|
||||
<span>{warning.message}</span>
|
||||
{!isBuiltin && LIFECYCLE_AUTOFIXABLE_CODES.has(warning.code) && (
|
||||
<button
|
||||
type="button"
|
||||
className="wf-lifecycle-fix"
|
||||
data-testid={`wf-lifecycle-fix-${warning.code}`}
|
||||
onClick={() => handleLifecycleFix(warning.code)}
|
||||
>
|
||||
{t("workflows.lifecycleFix", "Fix")}
|
||||
</button>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
@@ -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(<WorkflowNodeEditor isOpen onClose={() => {}} 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(<WorkflowNodeEditor isOpen onClose={() => {}} 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<string, unknown> }>; 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()]);
|
||||
|
||||
|
||||
@@ -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<WorkflowFlowNodeData>;
|
||||
|
||||
function node(id: string, kind: WorkflowFlowNodeData["kind"], x = 0, y = 0, extra: Partial<N> = {}): 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();
|
||||
});
|
||||
});
|
||||
122
packages/dashboard/app/components/workflow-lifecycle-autofix.ts
Normal file
122
packages/dashboard/app/components/workflow-lifecycle-autofix.ts
Normal file
@@ -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<WorkflowFlowNodeData>;
|
||||
|
||||
export const LIFECYCLE_AUTOFIXABLE_CODES: ReadonlySet<WorkflowLifecycleWarningCode> = 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;
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -8795,6 +8795,8 @@
|
||||
"importInvalidJson": "이 파일은 유효한 JSON이 아닙니다.",
|
||||
"importStripped": "가져온 노드에서 자동 승인 플래그가 제거되었습니다",
|
||||
"importTooltip": "JSON 파일에서 워크플로 가져오기",
|
||||
"lifecycleFix": "",
|
||||
"lifecycleFixAll": "",
|
||||
"lifecycleWarningsCount_one": "",
|
||||
"lifecycleWarningsCount_other": "",
|
||||
"loadFailed": "",
|
||||
|
||||
@@ -8795,6 +8795,8 @@
|
||||
"importInvalidJson": "该文件不是有效的 JSON。",
|
||||
"importStripped": "已从导入的节点中移除自动审批标志",
|
||||
"importTooltip": "从 JSON 文件导入工作流",
|
||||
"lifecycleFix": "",
|
||||
"lifecycleFixAll": "",
|
||||
"lifecycleWarningsCount_one": "",
|
||||
"lifecycleWarningsCount_other": "",
|
||||
"loadFailed": "",
|
||||
|
||||
@@ -8795,6 +8795,8 @@
|
||||
"importInvalidJson": "該檔案不是有效的 JSON。",
|
||||
"importStripped": "已從匯入的節點移除自動核准旗標",
|
||||
"importTooltip": "從 JSON 檔案匯入工作流程",
|
||||
"lifecycleFix": "",
|
||||
"lifecycleFixAll": "",
|
||||
"lifecycleWarningsCount_one": "",
|
||||
"lifecycleWarningsCount_other": "",
|
||||
"loadFailed": "",
|
||||
|
||||
Reference in New Issue
Block a user