feat(FN-6880): insert add-ons as node subgraphs / optional groups (U5)

Surface all seven WORKFLOW_STEP_TEMPLATES add-ons in the editor's Built-in steps
palette with two insert variants: as a single node (existing stepTemplateToNode)
and as an optional-group-wrapped subgraph (optionalGroupFragmentIr + the existing
insertFragment path, which already expands optional-group children). Also update
the cross-package built-in failure-edge parity assertion for U6's
workflow-step -> browser-verification optional-group migration.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-06-21 18:54:59 -07:00
parent 5506fcc0fc
commit baa2e8f42b
4 changed files with 305 additions and 33 deletions

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 } from "@fusion/core";
import type { WorkflowDefinition, WorkflowIrColumn, TraitViolation, WorkflowStepTemplate, WorkflowOptionalStep, WorkflowIrNodeKind } from "@fusion/core";
import { getErrorMessage } from "@fusion/core";
import {
fetchWorkflows,
@@ -61,6 +61,7 @@ import {
emptyWorkflowLayout,
copyIrWithFreshIds,
insertFragment,
optionalGroupFragmentIr,
fragmentSeamConflicts,
columnsOf,
fieldsOf,
@@ -1428,6 +1429,34 @@ function InnerEditor({
[isBuiltin, addNode],
);
/*
FNXC:WorkflowOptionalGroup 2026-06-21-14:32:
"Insert as optional group" (U5/R5): drop an add-on already wrapped in an `optional-group` container in
one action, seeding the group's `defaultOn` from the template's `defaultOn`. Reuses `stepTemplateToNode`
(KTD-5 — the catalog stays flat) to project the add-on to a prompt/script node, then `optionalGroupFragmentIr`
to wrap it and the EXISTING `insertFragment` path to remap ids + expand the group's template child — so two
inserts of the same add-on never collide. The group name carries the template name so the per-task toggle
surfaces label it.
*/
const handleInsertStepTemplateAsOptionalGroup = useCallback(
(tpl: WorkflowStepTemplate) => {
if (isBuiltin) return;
const { kind, config } = stepTemplateToNode(tpl);
const fragmentIr = optionalGroupFragmentIr(
{ kind: kind as WorkflowIrNodeKind, config },
{ name: tpl.name, defaultOn: tpl.defaultOn ?? false },
);
const result = insertFragment(nodes, edges, fragmentIr, {
x: 240,
y: 200 + (nodes.length % 4) * 40,
});
setNodes(result.nodes);
setEdges(result.edges);
setSelectedNodeId(result.insertedNodeIds[0] ?? null);
},
[isBuiltin, nodes, edges, setNodes, setEdges],
);
// U9/R8: insert a fragment definition's body into the active graph. Pre-validates
// seam duplication via fragmentSeamConflicts; on conflict, surfaces a persistent
// inline error inside the Templates section and does NOT insert. Otherwise
@@ -2751,19 +2780,37 @@ function InnerEditor({
{templateGroups.stepEntries.length > 0 && (
<div className="wf-mobile-template-group">
<h4>{t("workflowNodes.templatesBuiltinSteps", "Built-in steps")}</h4>
{/* FNXC:WorkflowOptionalGroup 2026-06-21-14:38: mobile mirrors the desktop two-variant insert (node / optional group). */}
{templateGroups.stepEntries.map((s) => (
<button
key={s.id}
type="button"
className="wf-mobile-template-option"
data-testid={`wf-mobile-tpl-step-${s.id}`}
onClick={() => {
handleInsertStepTemplate(s);
setMobilePanel("graph");
}}
>
{s.name}
</button>
<div key={s.id} className="wf-mobile-template-option-row">
<button
type="button"
className="wf-mobile-template-option"
data-testid={`wf-mobile-tpl-step-${s.id}`}
onClick={() => {
handleInsertStepTemplate(s);
setMobilePanel("graph");
}}
>
{s.name}
</button>
<button
type="button"
className="wf-mobile-template-option-optional"
data-testid={`wf-mobile-tpl-step-${s.id}-optional-group`}
aria-label={t(
"workflowNodes.insertTemplateAsOptionalGroup",
"Insert {{name}} as optional group",
{ name: s.name },
)}
onClick={() => {
handleInsertStepTemplateAsOptionalGroup(s);
setMobilePanel("graph");
}}
>
{t("workflowNodes.asOptionalGroup", "as optional group")}
</button>
</div>
))}
</div>
)}
@@ -3155,22 +3202,48 @@ function InnerEditor({
{t("workflowNodes.templatesBuiltinSteps", "Built-in steps")}
</h4>
<div className="wf-templates-entries">
{/*
FNXC:WorkflowOptionalGroup 2026-06-21-14:36:
Each built-in add-on surfaces TWO insert variants: the row inserts as a single node
(today's behavior), and a small secondary "as optional group" affordance wraps it in
an `optional-group` container (U5/R5). Both keep the established `wf-tpl-step-*` testid
convention (the wrap variant suffixes `-optional-group`).
*/}
{templateGroups.stepEntries.map((s) => (
<button
key={s.id}
type="button"
className="wf-templates-entry"
data-testid={`wf-tpl-step-${s.id}`}
disabled={isBuiltin}
aria-label={t(
"workflowNodes.insertTemplate",
"Insert template {{name}}",
{ name: s.name },
)}
onClick={() => handleInsertStepTemplate(s)}
>
{s.name}
</button>
<div key={s.id} className="wf-templates-entry-row">
<button
type="button"
className="wf-templates-entry"
data-testid={`wf-tpl-step-${s.id}`}
disabled={isBuiltin}
aria-label={t(
"workflowNodes.insertTemplate",
"Insert template {{name}}",
{ name: s.name },
)}
onClick={() => handleInsertStepTemplate(s)}
>
{s.name}
</button>
<button
type="button"
className="wf-templates-entry-optional"
data-testid={`wf-tpl-step-${s.id}-optional-group`}
disabled={isBuiltin}
title={t(
"workflowNodes.insertAsOptionalGroup",
"Insert as optional group",
)}
aria-label={t(
"workflowNodes.insertTemplateAsOptionalGroup",
"Insert {{name}} as optional group",
{ name: s.name },
)}
onClick={() => handleInsertStepTemplateAsOptionalGroup(s)}
>
{t("workflowNodes.asOptionalGroup", "as optional group")}
</button>
</div>
))}
</div>
</div>

View File

@@ -1,7 +1,7 @@
import { readFileSync } from "node:fs";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, waitFor, cleanup, within } from "@testing-library/react";
import { parseWorkflowIr, type WorkflowDefinition, type Settings } from "@fusion/core";
import { parseWorkflowIr, WORKFLOW_STEP_TEMPLATES, type WorkflowDefinition, type Settings } from "@fusion/core";
import type { Agent } from "../../api";
import {
irToFlow,
@@ -386,12 +386,13 @@ describe("workflow-flow-mapping", () => {
it("preserves duplicate and parallel built-in edges with valid endpoints and hit targets", () => {
const { edges } = edgeRenderableAssertion(builtinDef());
const failuresToEnd = edges.filter((edge) => edge.target === "end" && edge.data?.condition === "failure");
// FNXC:WorkflowOptionalGroup 2026-06-21-15:30: the coding built-in's pre-merge `workflow-step` seam was migrated to a `browser-verification` optional-group (U6), which now carries the failure->end edge in its place.
expect(failuresToEnd.map((edge) => edge.source).sort()).toEqual([
"browser-verification",
"execute",
"merge-attempt",
"planning",
"review",
"workflow-step",
]);
expect(new Set(failuresToEnd.map((edge) => edge.id)).size).toBe(failuresToEnd.length);
expect(failuresToEnd.every((edge) => edge.interactionWidth === WF_EDGE_INTERACTION_WIDTH)).toBe(true);
@@ -2893,11 +2894,13 @@ describe("WorkflowNodeEditor — U9 palette Templates section", () => {
await screen.findByTestId("wf-palette-templates");
const filter = await screen.findByTestId("wf-template-filter");
// All 8 step entries present pre-filter.
expect(screen.getAllByTestId(/^wf-tpl-step-/).length).toBe(8);
// All 8 step entries present pre-filter. Match only the primary "insert as
// node" buttons, excluding the sibling "-optional-group" insert variant.
const primaryStep = /^wf-tpl-step-(?!.*-optional-group$).*/;
expect(screen.getAllByTestId(primaryStep).length).toBe(8);
// Filter to "Step 3" → only that step survives.
fireEvent.change(filter, { target: { value: "Step 3" } });
await waitFor(() => expect(screen.getAllByTestId(/^wf-tpl-step-/).length).toBe(1));
await waitFor(() => expect(screen.getAllByTestId(primaryStep).length).toBe(1));
expect(screen.getByTestId("wf-tpl-step-s-3")).toBeInTheDocument();
// Fragment (name "Lint fragment") no longer matches.
expect(screen.queryByTestId("wf-tpl-fragment-WF-FRAG-A")).not.toBeInTheDocument();
@@ -2936,6 +2939,123 @@ describe("WorkflowNodeEditor — U9 palette Templates section", () => {
expect(screen.getByTestId("wf-tpl-step-qa-check")).toBeDisabled();
expect(screen.getByTestId("wf-tpl-plugin-acme-scan")).toBeDisabled();
});
// FNXC:WorkflowOptionalGroup 2026-06-21-14:50: All seven built-in add-ons must
// surface in the palette and insert two ways — as a single node (today's
// behavior, reusing stepTemplateToNode) and wrapped in an optional-group
// container (reusing insertFragment). These tests pin U5/R5.
it("surfaces all seven built-in add-ons in the palette", async () => {
vi.mocked(fetchWorkflows).mockResolvedValue([def()]);
vi.mocked(fetchWorkflowStepTemplates).mockResolvedValue({
templates: WORKFLOW_STEP_TEMPLATES,
});
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />);
await screen.findByTestId("wf-palette-templates");
// Every add-on id is present as a primary "insert as node" button AND offers
// the "as optional group" sibling variant.
for (const tpl of WORKFLOW_STEP_TEMPLATES) {
expect(screen.getByTestId(`wf-tpl-step-${tpl.id}`)).toBeInTheDocument();
expect(
screen.getByTestId(`wf-tpl-step-${tpl.id}-optional-group`),
).toBeInTheDocument();
}
expect(WORKFLOW_STEP_TEMPLATES).toHaveLength(7);
});
it("inserts an add-on as a single node carrying its template config", async () => {
vi.mocked(fetchWorkflows).mockResolvedValue([def()]);
vi.mocked(updateWorkflow).mockImplementation(async (_id, updates) => ({ ...def(), ...(updates as object) }));
vi.mocked(compileWorkflow).mockResolvedValue({ steps: [] });
vi.mocked(fetchWorkflowStepTemplates).mockResolvedValue({
templates: WORKFLOW_STEP_TEMPLATES,
});
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />);
await screen.findByTestId("wf-palette-templates");
await screen.findByTestId("wf-node-gate", undefined, { timeout: 3000 });
const before = screen.queryAllByTestId("wf-node-prompt").length;
fireEvent.click(screen.getByTestId("wf-tpl-step-documentation-review"));
await waitFor(
() => expect(screen.queryAllByTestId("wf-node-prompt").length).toBe(before + 1),
{ timeout: 3000 },
);
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: { nodes: { kind: string; config?: Record<string, unknown> }[] } }).ir;
const docTpl = WORKFLOW_STEP_TEMPLATES.find((tpl) => tpl.id === "documentation-review")!;
const inserted = ir.nodes.find((n) => n.config?.name === docTpl.name);
expect(inserted).toBeTruthy();
expect(inserted!.kind).toBe(docTpl.mode === "script" ? "script" : "prompt");
});
it("inserts an add-on as an optional-group whose template holds the projected node and defaultOn matches", async () => {
vi.mocked(fetchWorkflows).mockResolvedValue([def()]);
vi.mocked(updateWorkflow).mockImplementation(async (_id, updates) => ({ ...def(), ...(updates as object) }));
vi.mocked(compileWorkflow).mockResolvedValue({ steps: [] });
vi.mocked(fetchWorkflowStepTemplates).mockResolvedValue({
templates: WORKFLOW_STEP_TEMPLATES,
});
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />);
await screen.findByTestId("wf-palette-templates");
await screen.findByTestId("wf-node-gate", undefined, { timeout: 3000 });
fireEvent.click(screen.getByTestId("wf-tpl-step-security-audit-optional-group"));
// The wrapped add-on renders as a registered optional-group container.
await waitFor(
() => expect(screen.getByTestId("wf-node-optional-group")).toBeInTheDocument(),
{ timeout: 5000 },
);
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: { nodes: { kind: string; config?: Record<string, unknown> }[] } }).ir;
const secTpl = WORKFLOW_STEP_TEMPLATES.find((tpl) => tpl.id === "security-audit")!;
const group = ir.nodes.find((n) => n.kind === "optional-group");
expect(group).toBeTruthy();
expect(group!.config!.defaultOn).toBe(secTpl.defaultOn ?? false);
const template = group!.config!.template as { nodes: { kind: string; config?: Record<string, unknown> }[] };
expect(template.nodes).toHaveLength(1);
expect(template.nodes[0].config?.name).toBe(secTpl.name);
});
it("remaps ids when the same add-on subgraph is inserted twice (no collision)", async () => {
vi.mocked(fetchWorkflows).mockResolvedValue([def()]);
vi.mocked(updateWorkflow).mockImplementation(async (_id, updates) => ({ ...def(), ...(updates as object) }));
vi.mocked(compileWorkflow).mockResolvedValue({ steps: [] });
vi.mocked(fetchWorkflowStepTemplates).mockResolvedValue({
templates: WORKFLOW_STEP_TEMPLATES,
});
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />);
await screen.findByTestId("wf-palette-templates");
await screen.findByTestId("wf-node-gate", undefined, { timeout: 3000 });
fireEvent.click(screen.getByTestId("wf-tpl-step-security-audit-optional-group"));
await waitFor(
() => expect(screen.queryAllByTestId("wf-node-optional-group").length).toBe(1),
{ timeout: 5000 },
);
fireEvent.click(screen.getByTestId("wf-tpl-step-security-audit-optional-group"));
await waitFor(
() => expect(screen.queryAllByTestId("wf-node-optional-group").length).toBe(2),
{ timeout: 5000 },
);
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: { nodes: { id: string; kind: string }[] } }).ir;
const groupIds = ir.nodes.filter((n) => n.kind === "optional-group").map((n) => n.id);
expect(groupIds).toHaveLength(2);
expect(new Set(groupIds).size).toBe(2);
});
});
// ── U10: Design-with-AI editor affordances ──────────────────────────────────

View File

@@ -6,6 +6,7 @@ import {
irToFlow,
flowToIr,
insertFragment,
optionalGroupFragmentIr,
fragmentSeamConflicts,
copyIrWithFreshIds,
columnsOf,
@@ -1436,6 +1437,38 @@ describe("insertFragment", () => {
expect(template?.nodes).toHaveLength(2);
expect(template?.edges).toHaveLength(1);
});
// FNXC:WorkflowOptionalGroup 2026-06-21-14:55: optionalGroupFragmentIr wraps a
// projected add-on node in an optional-group; insertFragment must expand its
// template child and round-trip it via flowToIr, and two inserts must not collide.
it("wraps an add-on node in an optional-group fragment that round-trips with defaultOn", () => {
const fragmentIr = optionalGroupFragmentIr(
{ kind: "prompt", config: { name: "Security Audit", prompt: "audit it" } },
{ name: "Security Audit", defaultOn: true },
);
const existing = irToFlow(u8ChainDef());
const first = insertFragment(existing.nodes, existing.edges, fragmentIr, { x: 400, y: 200 });
const second = insertFragment(first.nodes, first.edges, fragmentIr, { x: 700, y: 200 });
// Two optional-group containers, each with its template child expanded.
const groups = second.nodes.filter((n) => n.data.kind === "optional-group");
expect(groups).toHaveLength(2);
for (const g of groups) {
expect(second.nodes.some((n) => n.parentId === g.id)).toBe(true);
}
// All ids disjoint across both inserts.
const allIds = second.nodes.map((n) => n.id);
expect(new Set(allIds).size).toBe(allIds.length);
// Round-trip: the group carries defaultOn + a single-node template.
const { ir: out } = flowToIr("wf", second.nodes, second.edges);
const og = out.nodes.find((n) => n.kind === "optional-group")!;
expect(og.config?.defaultOn).toBe(true);
const template = (og.config as { template?: { nodes: { config?: Record<string, unknown> }[] } }).template;
expect(template?.nodes).toHaveLength(1);
expect(template?.nodes[0].config?.name).toBe("Security Audit");
});
});
describe("fragmentSeamConflicts", () => {

View File

@@ -1250,6 +1250,52 @@ export function insertFragment(
};
}
/*
FNXC:WorkflowOptionalGroup 2026-06-21-14:30:
"Insert as optional group" (U5/R5) wraps a single projected add-on node in an `optional-group`
container so an author can drop e.g. "Security Audit (optional)" in one action. The wrapper is built
as a v1-shaped fragment IR (start → optional-group → end) and handed to the EXISTING `insertFragment`
path, which strips start/end, remaps the group id, and expands the group's `config.template` child as a
`parentId` flow node — so no new insertion engine is needed and ids never collide across repeated inserts.
KTD-5: the add-on catalog stays FLAT; projection to a node is done by the caller via `stepTemplateToNode`,
and only the wrap-in-container step lives here.
*/
/** Wrap a single projected add-on node in an `optional-group` fragment IR ready
* for `insertFragment`. `defaultOn` seeds the group's per-task enable default
* (from the source template's `defaultOn`). The group's `name` labels it in the
* editor and the per-task toggle surfaces. The inner node uses a template-local
* id; `insertFragment` remaps the group id and namespaces the child, so this id
* need only be unique WITHIN the template. */
export function optionalGroupFragmentIr(
addOnNode: { kind: WorkflowIrNodeKind; config?: Record<string, unknown> },
opts: { name?: string; defaultOn?: boolean },
): WorkflowIr {
const innerId = "addon";
const optionalGroupId = "optional-group";
const config: WorkflowOptionalGroupConfig & Record<string, unknown> = {
defaultOn: opts.defaultOn ?? false,
template: {
nodes: [{ id: innerId, kind: addOnNode.kind, config: addOnNode.config }],
edges: [],
},
};
if (opts.name) config.name = opts.name;
return {
version: "v1",
name: opts.name ?? "optional-group",
nodes: [
{ id: "start", kind: "start" },
{ id: optionalGroupId, kind: "optional-group", config },
{ id: "end", kind: "end" },
],
edges: [
{ from: "start", to: optionalGroupId, condition: "success" },
{ from: optionalGroupId, to: "end", condition: "success" },
],
};
}
/** Remap a template group's internal node ids + edges to fresh ids. Returns a
* new template object; the original is untouched. Template-local ids are scoped
* to the template, so a fresh local id space suffices (and keeps config compact