From 93ea54f77ad20f8d990ebf0ffdac31b8608a0628 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sun, 21 Jun 2026 18:17:14 -0700 Subject: [PATCH] feat(FN-6880): add optional-group container IR kind + validation (U1) Introduce the `optional-group` node kind and `WorkflowOptionalGroupConfig` (defaultOn + single-pass template subgraph), validated by mirroring the loop template rules: non-empty template, single entry/exit, no nested groups, no rework edges (single-pass guarantee), no template/top-level id collisions, boolean defaultOn. Foundation for graph-native optional steps; additive and inert until producers/consumers land in later units. Includes the plan. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...-workflow-optional-group-subgraphs-plan.md | 612 ++++++++++++++++++ .../workflow-ir-optional-group.test.ts | 147 +++++ packages/core/src/workflow-ir-types.ts | 21 + packages/core/src/workflow-ir.ts | 104 +++ 4 files changed, 884 insertions(+) create mode 100644 docs/plans/2026-06-21-002-feat-workflow-optional-group-subgraphs-plan.md create mode 100644 packages/core/src/__tests__/workflow-ir-optional-group.test.ts diff --git a/docs/plans/2026-06-21-002-feat-workflow-optional-group-subgraphs-plan.md b/docs/plans/2026-06-21-002-feat-workflow-optional-group-subgraphs-plan.md new file mode 100644 index 0000000000..4580430acf --- /dev/null +++ b/docs/plans/2026-06-21-002-feat-workflow-optional-group-subgraphs-plan.md @@ -0,0 +1,612 @@ +--- +title: "feat: Optional-group container nodes + add-ons as insertable subgraphs" +status: active +date: 2026-06-21 +type: feat +plan_id: 2026-06-21-002-feat-workflow-optional-group-subgraphs +--- + +# feat: Optional-group container nodes + add-ons as insertable subgraphs + +## Summary + +Today "optional steps" are **execution-inert declarations**: a workflow lists `optionalSteps: +[{ templateId, defaultOn }]`, the create/edit UI seeds a per-task `enabledWorkflowSteps` set, and a +single hidden `workflow-step` seam node runs every enabled step *after* the graph finishes. Nothing about +optionality is visible in the graph, and the steps cannot be placed, ordered, or composed. + +This plan makes optionality **graph-native**. A new `optional-group` container node — modeled on the +existing `foreach`/`loop` container nodes — holds a `template:{ nodes, edges }` subgraph. The graph +executor runs that subgraph **once** when the group is enabled for the task and **passes through +(skips)** it when disabled. Enable state reuses the existing per-task `enabledWorkflowSteps` facet plus a +workflow-level `defaultOn`, keyed by the group. Separately, every prior pre-workflow **add-on** (the seven +`WORKFLOW_STEP_TEMPLATES`: documentation-review, qa-check, security-audit, performance-review, +accessibility-check, browser-verification, frontend-ux-design) becomes **insertable from the editor's +template palette as a node subgraph**, and can be inserted already wrapped in an `optional-group` so an +author can drop in "Security Audit (optional)" in one action. + +Finally, the plan **replaces** the declaration-based system per the confirmed scope decision: the built-in +**coding** and **stepwise-coding** workflows migrate `browser-verification` onto an `optional-group`, and +the now-dead `WorkflowOptionalStep` / `optionalSteps` declaration, `resolveWorkflowOptionalSteps` source, +the `workflow-step` seam node, and its compiler seam-anchor are retired — without breaking the create/edit +toggle surfaces, which re-point to the new source. + +**Plan depth:** Deep. Cross-cutting across core IR + validation, the engine graph executor, per-task +persistence/seeding, the visual node editor, the built-in workflows, and a behavior-affecting removal of +the legacy execution path. + +--- + +## Problem Frame + +The declaration model has three structural limits this plan removes: + +- **Invisible & unplaceable.** `optionalSteps` never appears in the graph + (`packages/core/src/workflow-ir-types.ts:314-339`, marked "Execution-inert; the graph executor ignores + this facet"). All enabled steps run in one lump at the `workflow-step` seam + (`packages/engine/src/executor.ts` `runWorkflowSteps`, gated on `enabledWorkflowSteps`), so an author + cannot put an optional step *between* two graph nodes, order multiple optional steps, or branch on one. +- **Add-ons are flat, not composable.** A `WorkflowStepTemplate` + (`packages/core/src/types.ts:868-899`) is a flat prompt/script config. The seven built-in add-ons appear + in the editor palette today only as **single "Built-in steps"** entries + (`WorkflowNodeEditor.tsx:1002` `stepEntries`), not as subgraphs you can compose or gate. +- **Two ways to express "run this sometimes."** The graph already has real conditional routing + (`shouldTraverseEdge` in `packages/engine/src/workflow-graph-executor.ts:657`, edge `condition` of + `success`/`failure`/`outcome:`) and container nodes (`foreach`/`loop`), yet optionality lives in a + parallel, execution-inert declaration channel. Converging optionality onto the graph removes the split. + +The graph already provides every seam this needs: container nodes compile/execute via a `template` +subgraph (`WorkflowForeachConfig`/`WorkflowLoopConfig`, `workflow-ir-types.ts:129-165`), the executor +dispatches them in `runNodeAndTraverse` (`workflow-graph-executor.ts:431-485`), the editor renders them as +React Flow group nodes with `parentId` children (`workflow-flow-mapping.ts:46-82,316-403,431-550`), and the +palette can already insert multi-node subgraphs via `insertFragment` (`WorkflowNodeEditor.tsx:1425-1430`). +The work is to add one new container kind that branches on a per-task toggle, project the add-on catalog +into the palette as subgraphs, and migrate the built-ins off the legacy path. + +--- + +## Requirements + +- **R1 — Optional-group container kind.** Add an `optional-group` node kind to the IR carrying a + `template:{ nodes, edges }` subgraph, mirroring `WorkflowForeachConfig`. Parse + validate it. +- **R2 — Run-or-bypass execution.** The graph executor runs the group's template **once** when the group + is enabled for the task and **passes through** (skips the subgraph, continues to the group's children) + when disabled. No rework budget; a single pass. +- **R3 — Enable state reuses the per-task facet.** Whether a group runs is driven by the existing per-task + `enabledWorkflowSteps` set plus a workflow-level `defaultOn` on the group, keyed by the group's stable + id. New tasks seed their enabled set from each group's `defaultOn` at creation. +- **R4 — Author optional groups in the node editor.** An author can add an `optional-group` container, + name it, set `defaultOn`, and place nodes inside it — reusing the foreach/loop group UX. The node type is + registered so it renders (not as `react-flow__node-default`). +- **R5 — Every add-on is an insertable subgraph.** All seven `WORKFLOW_STEP_TEMPLATES` add-ons are + insertable from the editor palette as a node subgraph, and offered with an "insert as optional group" + variant that drops the add-on wrapped in an `optional-group` (seeded `defaultOn`). +- **R6 — Built-ins migrated, behavior preserved.** The coding and stepwise-coding built-ins express + `browser-verification` as an `optional-group` (default OFF). A task with it enabled runs the step; a task + with it disabled does not — proven by an execution-level (not traversal-only) test. +- **R7 — Legacy path retired without surface breakage.** The `WorkflowOptionalStep`/`optionalSteps` + declaration, `resolveWorkflowOptionalSteps` as the toggle source, the `workflow-step` seam node, and its + compiler seam-anchor are removed. The create/edit toggle surfaces (inline card, New Task modal, Workflow + tab, steps dropdown) keep working by resolving their toggle list from `optional-group` nodes instead. +- **R8 — Validation invariants hold.** Optional-group templates are validated by walking the subgraph + (children are not in `ir.nodes`): all template nodes reachable, no illegal (non-rework) cycles, no seam + nodes inside a group. Graphs with no optional groups serialize byte-identically (R9 of prior art). +- **R9 — Additive serialization.** A workflow with no optional groups round-trips through the node editor + byte-identically; `optional-group` introduces no new top-level IR keys (it is just a node kind). + +--- + +## High-Level Technical Design + +### Execution: container branches on the per-task toggle + +The new dispatch slots into `runNodeAndTraverse` beside `foreach`/`loop`. Enabled → run the template +sub-walk once (reuse the loop/foreach template-walk machinery, no rework budget); disabled → return success +and traverse the group's children, skipping the body entirely. + +```mermaid +flowchart TD + Prev["upstream node"] --> OG{{"optional-group node\n(id, defaultOn, template)"}} + OG -->|"enabled = task.enabledWorkflowSteps.includes(group.id)"| CHK{enabled?} + CHK -->|yes| RUN["run template sub-walk ONCE\n(template.nodes/edges, single pass)"] + CHK -->|no| SKIP["pass through\noutcome=success, value=bypassed"] + RUN --> CHILD["traverseChildren(OG, result)"] + SKIP --> CHILD + CHILD --> Next["downstream node"] +``` + +Key boundary: the **decision** (run vs skip) is read from per-task state at the trigger seam, exactly like +the existing per-task auto-merge override — so it must be consulted wherever the run is gated, not only in +the executor branch (see Risks R-2). The **body** is an ordinary subgraph the executor already knows how to +walk. + +### Authoring + add-on projection: catalog → palette → graph + +```mermaid +flowchart LR + CAT["WORKFLOW_STEP_TEMPLATES\n(7 add-ons: flat prompt/script config)"] + CAT -->|"project to subgraph entry"| PAL["editor template palette\n(Built-in steps → subgraph entries)"] + PAL -->|"insert as node"| N["prompt/script node\n(add-on config)"] + PAL -->|"insert as optional group"| OGW["optional-group{ template:[ add-on node ], defaultOn }"] + N --> CANVAS["canvas IR"] + OGW --> CANVAS + CANVAS -->|"flowToIr / irToFlow\n(group children via parentId)"| IR["WorkflowIrV2"] +``` + +The add-on→subgraph projection reuses the existing `insertFragment` subgraph-insertion path +(`WorkflowNodeEditor.tsx:1425`), which already remaps ids and rewires internal edges — so "insert as +optional group" is a wrap-then-insert, not a new insertion engine. + +--- + +## Key Technical Decisions + +- **KTD-1 — `optional-group` is a container node mirroring `WorkflowForeachConfig`, not a bypass edge.** + Per the confirmed scope decision, optionality is encapsulated in a container (foreach/loop style) holding + a `template:{ nodes, edges }`, rather than inline nodes plus an explicit bypass edge. This reuses the + entire group-node toolchain (IR config, validation, React Flow `parentId` children, `insertFragment`), + and the "skip" is the container passing through rather than a visible routed edge. + +- **KTD-2 — Enable state reuses `enabledWorkflowSteps`, keyed by the group's stable node id.** No new + persistence. The per-task `tasks.enabledWorkflowSteps` column (`db.ts:324`, + `store.ts:424,2140`) holds the ids of enabled groups; `defaultOn` on the group seeds it at task creation + via the existing materialization path. Keying on the **node id** (stable for built-ins and preserved + across editor round-trips, like foreach/loop ids) means renaming/recreating a group resets its per-task + state — acceptable and identical to today's `templateId` keying. + +- **KTD-3 — Single pass, no rework budget.** Unlike `foreach` (per-step) and `loop` (bounded repeat), an + optional-group runs its template exactly once when enabled. Reuse the loop/foreach template-walk helper + but disable rework/iteration. Rework edges are **forbidden inside** an optional-group template (validation + rejects them) to keep the single-pass guarantee unambiguous. + +- **KTD-4 — Re-point the toggle resolver, don't keep two sources.** `resolveWorkflowOptionalSteps` + (`workflow-optional-steps.ts`) currently maps `ir.optionalSteps` → display metadata for the create/edit + UI. Replace its source with a scan of `optional-group` nodes (id, group name, `defaultOn`), preserving its + output shape (`ResolvedWorkflowOptionalStep[]`) so the inline card, New Task modal, Workflow tab, and + steps dropdown keep consuming it unchanged. This is what lets R7 retire the declaration without breaking + the four toggle surfaces. + +- **KTD-5 — Add-ons stay flat configs; the palette projects them to subgraphs at insert time.** Do **not** + rewrite `WorkflowStepTemplate` into a nodes+edges shape. A template projects to a single `prompt`/`script` + node (carrying its `prompt`/`scriptName`/`toolMode`/`gateMode`/`phase`/model), and the "optional" variant + wraps that node in an `optional-group`. Keeping the catalog flat avoids migrating plugin-contributed + templates and keeps the resolver/seeding logic simple. + +- **KTD-6 — `workflow-step` seam removal is a compiler change, not just an IR edit.** `workflow-step` is a + registered seam anchor in the compiler's canonical pipeline + (`workflow-compiler.ts:45,170` `planning → execute → workflow-step → review → merge`). Retiring it + requires removing it from `SEAM_NAMES`/`expectedSeamOrder` and updating the built-in IRs + their + byte-identity parity oracles together, or the compiler will reject (or mis-order) the migrated graphs. + +--- + +## Implementation Units + +Grouped into three phases: **A — core/engine** (the construct runs), **B — editor** (authoring + add-on +palette), **C — migration/cleanup** (built-ins on the new model, legacy path retired). + +### Phase A — Core construct + execution + +### U1. `optional-group` IR type, parse, and validation + +**Goal:** Introduce the `optional-group` node kind and its `template` config, and validate it by walking +the subgraph (R1, R8). + +**Requirements:** R1, R8, R9 + +**Dependencies:** none + +**Files:** +- `packages/core/src/workflow-ir-types.ts` (modify — add `"optional-group"` to `WorkflowIrNodeKind`; add + `WorkflowOptionalGroupConfig { defaultOn?: boolean; template: { nodes; edges } }` mirroring + `WorkflowForeachConfig` at `:129`) +- `packages/core/src/workflow-ir.ts` (modify — `validateV2` calls a new `validateOptionalGroup(node, ...)`; + extend cycle/reachability/seam walks to descend into the template subgraph) +- `packages/core/src/__tests__/workflow-ir.test.ts` (modify/create — validation cases) + +**Approach:** +- Add the kind + config type. The group's stable enable key is its node `id` (KTD-2); `defaultOn` lives on + the node `config`. +- In validation, treat the template like a foreach template: its nodes are **not** in `ir.nodes`, so every + reader/validator must walk the subgraph explicitly (learning: per-entity blast-radius `:37`). Validate: + all template nodes reachable from the template's entry; endpoints reference template-local nodes; **no + rework edges** inside (KTD-3); **no seam nodes** inside (mirror `validateParallelism`'s seam-in-branch + rule, `workflow-ir.ts:190+`). +- `parseWorkflowIr` descends into optional-group templates the same way it clamps foreach/loop configs. + +**Patterns to follow:** `WorkflowForeachConfig` type + `validateV2`/foreach template validation in +`workflow-ir.ts`; the seam-in-branch check in `validateParallelism`. + +**Test scenarios:** +- A v2 IR with one `optional-group` (valid template) parses and validates. +- A template referencing an undefined template-local node throws `WorkflowIrError`. +- A rework edge inside an optional-group template is rejected. +- A seam node (e.g. `merge-gate`) inside an optional-group template is rejected. +- An unreachable template node is rejected. +- A graph with **no** optional-group serializes/parses byte-identically (R9). + +**Verification:** `pnpm --filter @fusion/core test workflow-ir` green; no change to graphs without optional +groups. + +--- + +### U2. Executor: run-once-or-bypass dispatch for `optional-group` + +**Goal:** Make the graph executor run an enabled group's template once and pass through a disabled group +(R2), reading enable state from per-task `enabledWorkflowSteps` (R3). + +**Requirements:** R2, R3 + +**Dependencies:** U1 + +**Files:** +- `packages/engine/src/workflow-graph-executor.ts` (modify — add an `optional-group` branch in + `runNodeAndTraverse` at `:389-485`, beside `foreach`/`loop`) +- `packages/engine/src/workflow-graph-loop.ts` or a shared helper (modify/extract — reuse the + single-template-walk without iteration/rework for the enabled path) +- `packages/engine/src/__tests__/workflow-graph-optional-group.test.ts` (create — execution-level tests) + +**Approach:** +- Resolve `enabled = currentTask.enabledWorkflowSteps?.includes(node.id) ?? false`. **Read the freshest + task state** at the seam (the executor already re-reads the task in `runWorkflowSteps`); ensure + `enabledWorkflowSteps` is in whatever projection the gate reads (learning: per-task override slim-SELECT + trap `:105`). +- Enabled → walk `node.config.template` once via the extracted helper, threading the same + `runTemplateNode`/`shouldTraverseEdge` deps the foreach/loop handlers pass; collect `visitedNodeIds`; + set `context[node:id:outcome]`. Disabled → `return await traverseChildren(node, { outcome: "success", + value: "optional-group-bypassed" })`. +- Namespaced child ids: reuse the foreach instance-id scheme defensively — parse candidate-style and + validate the template node exists, not just the container (learning `:56`). + +**Execution note:** Start with the failing **two-task divergence** execution test (below) — it is the +contract that guards the dead-toggle failure mode. + +**Test scenarios:** +- **Two-task divergence (critical):** two tasks identical except `enabledWorkflowSteps` — the one including + the group id records the template's node execution; the sibling records none and reaches the same + downstream node. (Shape from per-task-auto-merge learning `:106`.) +- Enabled group runs its template exactly **once** (not per-step, not looped) even when the graph has a + foreach elsewhere. +- Disabled group is byte-inert: downstream context/outcome identical to a graph with the group removed + (kill-switch inertness, learning `:55`). +- A template node failure inside an enabled group surfaces as the group's outcome and routes the group's + `failure`/`outcome:` edges. + +**Verification:** new engine test green; existing graph-executor tests unaffected. + +--- + +### U3. Per-task enable resolution from optional-group nodes + `defaultOn` seeding + +**Goal:** Re-point the per-task toggle source from `ir.optionalSteps` to `optional-group` nodes and seed +new tasks' enabled set from each group's `defaultOn` (R3, R7-prep). + +**Requirements:** R3, R7 + +**Dependencies:** U1 + +**Files:** +- `packages/core/src/workflow-optional-steps.ts` (modify — `resolveWorkflowOptionalSteps` scans + `optional-group` nodes instead of `ir.optionalSteps`, preserving `ResolvedWorkflowOptionalStep[]` output) +- `packages/core/src/` task-creation/materialization path that seeds `enabledWorkflowSteps` from `defaultOn` + (modify — seed from optional-group `defaultOn`; today's `materializeDefaultWorkflowSteps`, see + `types.ts:2668-2675`) +- `packages/core/src/__tests__/workflow-optional-steps.test.ts` (modify — group-sourced resolution) + +**Approach:** +- Resolver: walk `ir` (v2) nodes, collect `optional-group` nodes → `{ id, name (from config), defaultOn }`. + Keep output shape identical so the four UI surfaces (inline card, modal, Workflow tab, dropdown) need no + change beyond what U7/U-editor already covers. Unknown/stale ids in `enabledWorkflowSteps` are ignored + (defensive, as today). +- Seeding: at task creation, the enabled set is the ids of optional-group nodes whose effective + `defaultOn` is true — mirroring the prior `optionalStep.defaultOn ?? false` precedence. +- Grep every reader of `enabledWorkflowSteps`/`optionalSteps` and confirm each now resolves from groups + (learning: consult the override at every trigger seam `:100,:102`). + +**Test scenarios:** +- `resolveWorkflowOptionalSteps` over a workflow with two optional-group nodes returns both, with names and + `defaultOn` from node config. +- A new task created against that workflow seeds `enabledWorkflowSteps` to exactly the `defaultOn: true` + group ids. +- A workflow with no optional-group nodes resolves to `[]` and seeds an empty set. +- Stale id in `enabledWorkflowSteps` (group since removed) does not crash resolution or execution. + +**Verification:** core optional-steps tests green; creation seeding covered. + +--- + +### Phase B — Editor authoring + add-on palette + +### U4. Render & author the `optional-group` container in the node editor + +**Goal:** Let an author add, name, configure (`defaultOn`), and fill an `optional-group` container, +reusing the foreach/loop group UX, with the node type registered so it renders (R4). + +**Requirements:** R4 + +**Dependencies:** U1 (kind exists) + +**Files:** +- `packages/dashboard/app/components/nodes/WorkflowNodeTypes.tsx` (modify — add `optional-group` to the + node-type registry + icon, render as a group container like `foreach`/`loop`) +- `packages/dashboard/app/components/workflow-flow-mapping.ts` (modify — treat `optional-group` as a group + kind everywhere foreach/loop are special-cased: `groupTemplateConfigOf` `:266`, group child + reassembly `:441-480`, intra-template edge handling `:517`, group delete `:611,:636`, + condition-editability `:663`) +- `packages/dashboard/app/components/WorkflowNodeEditor.tsx` (modify — inspector controls: group name + + `defaultOn` toggle; help entry) +- `packages/dashboard/app/components/nodes/node-help.ts` (modify — add an `optional-group` help entry) +- `packages/dashboard/app/components/__tests__/workflow-flow-mapping.test.ts` (modify — round-trip group + children) +- `packages/dashboard/app/components/__tests__/WorkflowNodeEditor.test.tsx` (modify — add/name/toggle/fill) + +**Approach:** +- Mirror the `foreach`/`loop` group node: a React Flow `type: "group"` with `parentId` children using the + existing `foreachChildFlowId` namespacing (`workflow-flow-mapping.ts:75`). `irToFlow` renders the + template as children; `flowToIr` reassembles it (the `:454,:480` kind checks gain `optional-group`). +- Inspector: a `defaultOn` checkbox (labeled, focus ring) and the group name; the body is authored by + dropping nodes inside, identical to foreach. +- **Register the node type** in `WorkflowNodeTypes.tsx` — an unregistered kind renders as + `react-flow__node-default` with missing children (learning: worktree bundle `:24`). Verify against a + fresh `FUSION_CLIENT_DIR` bundle, non-4040 port. + +**Test scenarios:** +- Adding an optional-group, dropping a prompt node inside, and saving yields IR with an `optional-group` + node whose `template.nodes` contains the inner node (round-trip). +- Toggling `defaultOn` marks the editor dirty and persists on save. +- Deleting the group removes its `parentId` children (no orphans) — mirrors the foreach delete test. +- The node renders with its registered type (not `react-flow__node-default`) — asserted via node-type + registry presence. + +**Verification:** mapping + editor tests green; real-browser check that the container renders, accepts +child nodes, and the `defaultOn` toggle works (fresh worktree bundle; verify on a mobile viewport per +Risks R-4). + +--- + +### U5. Add-ons as insertable subgraphs (plus "insert as optional group") + +**Goal:** Make all seven `WORKFLOW_STEP_TEMPLATES` add-ons insertable from the palette as node subgraphs, +each also offerable wrapped in an `optional-group` (R5). + +**Requirements:** R5, KTD-5 + +**Dependencies:** U4 (optional-group authoring exists) + +**Files:** +- `packages/dashboard/app/components/WorkflowNodeEditor.tsx` (modify — the "Built-in steps" palette + section `:1002,:2741`, the existing `stepTemplateToNode()` projector `:249-275`, and the + `handleInsertStepTemplate`/`handleInsertFragment` handlers `:1425-1453`: add an "insert as optional + group" variant per add-on) +- `packages/dashboard/app/components/workflow-flow-mapping.ts` (modify — `insertFragment` `:1110-1219` + already expands subgraphs incl. group `parentId` children; add the wrap-in-`optional-group` helper that + builds the fragment IR from a projected add-on node) +- `packages/dashboard/app/components/__tests__/WorkflowNodeEditor.test.tsx` (modify — insert-as-node and + insert-as-optional-group for an add-on) + +**Approach:** +- Project each add-on to a node using the **existing `stepTemplateToNode()`** (`WorkflowNodeEditor.tsx: + 249-275`), which already maps a `WorkflowStepTemplate` → a `prompt`/`script` node carrying its + `prompt`/`scriptName`/`toolMode`/`gateMode`/model (KTD-5). "Insert as node" is today's behavior. +- "Insert as optional group" wraps that projected node in an `optional-group{ template:{ nodes:[node], + edges:[] }, defaultOn }` (seeded from the template's `defaultOn`) and inserts it through the existing + `insertFragment` path (`workflow-flow-mapping.ts:1110-1219`), which already remaps ids, rewires internal + edges, and expands group `parentId` children — so no new insertion engine is needed. +- Surface both variants in the palette's existing "Built-in steps" group; keep `data-testid` conventions. + +**Test scenarios:** +- Each of the seven add-ons appears in the palette and inserts a node carrying its template config. +- "Insert as optional group" for `security-audit` yields an `optional-group` whose `template` holds the + security-audit node and whose `defaultOn` matches the template default. +- Inserting an add-on subgraph remaps ids so two insertions of the same add-on do not collide. +- A plugin-contributed template (if present) still inserts as a node (catalog stays flat, KTD-5). + +**Verification:** editor tests green; real-browser insert of an add-on and an optional-group-wrapped add-on, +then save → reopen round-trip. + +--- + +### Phase C — Migrate built-ins, retire the legacy path + +### U6. Migrate coding + stepwise-coding built-ins to `optional-group` browser-verification + +**Goal:** Express `browser-verification` as an `optional-group` (default OFF) in both built-ins, preserving +runtime behavior, and update parity oracles (R6). + +**Requirements:** R6, R8 + +**Dependencies:** U2, U3 (execution + seeding), U1 (kind) + +**Files:** +- `packages/core/src/builtin-coding-workflow-ir.ts` (modify — replace `optionalSteps:[{templateId: + "browser-verification"}]` `:123` + the `workflow-step` seam node `:69` with a `browser-verification` + `optional-group` on the pre-merge path) +- `packages/core/src/builtin-stepwise-coding-workflow-ir.ts` (modify — add the `browser-verification` + optional-group on the pre-merge path; stepwise had no `workflow-step` seam at all) +- `packages/core/src/__tests__/` built-in IR snapshot/parity fixtures (modify — update byte-identity + oracles deliberately) +- `packages/engine/src/__tests__/` stepwise/coding execution parity test (modify — enabled-runs/disabled- + skips at the built-in level) + +**Approach:** +- Place the optional-group on the success path where `workflow-step` sat (pre-merge, after execute/steps, + before review), so an enabled task runs browser-verification pre-merge exactly as before. +- The stepwise IR is a documented byte-identity parity oracle — adding the construct shifts its snapshot; + update the fixture deliberately and confirm the group runs **once** post-foreach, not per step-instance + (learning `:55`, prior plan R-5). + +**Test scenarios:** +- `resolveWorkflowOptionalSteps(BUILTIN_CODING_WORKFLOW_IR)` returns one `browser-verification` group, + `defaultOn: false`; same for stepwise. +- Execution: a coding task with the group enabled runs browser-verification pre-merge; disabled does not + (two-task divergence at the built-in level). +- Stepwise: same divergence; the group runs once after the foreach completes. +- Both built-ins parse and pass `validateV2`. + +**Verification:** core + engine built-in tests green; parity oracles updated and passing. + +--- + +### U7. Retire the declaration-based optional-steps path + +**Goal:** Remove the now-dead `WorkflowOptionalStep`/`optionalSteps` declaration, the `workflow-step` seam +node + handler, and its compiler seam-anchor, keeping the four toggle surfaces working via U3's resolver +(R7). + +**Requirements:** R7 + +**Dependencies:** U3 (resolver re-pointed), U6 (built-ins migrated — nothing still declares `optionalSteps`) + +**Files:** +- `packages/core/src/workflow-ir-types.ts` (modify — remove `WorkflowOptionalStep` + `WorkflowIrV2. + optionalSteps`) +- `packages/core/src/workflow-compiler.ts` (modify — remove `workflow-step` from `SEAM_NAMES` `:45` and + `expectedSeamOrder` `:170`) +- `packages/engine/src/executor.ts` (modify — remove `runWorkflowSteps` + the `workflow-step` seam + dispatch, now unreachable) +- `packages/dashboard/app/components/workflow-flow-mapping.ts` (modify — drop `optionalSteps` threading in + `flowToIr`/`optionalStepsOf` if present from the prior plan) +- `packages/core/src/__tests__/`, `packages/engine/src/__tests__/` (modify — delete/replace tests asserting + the legacy path; keep the resolver/seeding tests now backed by groups) + +**Approach:** +- This is a **behavior-affecting removal** — follow the codebase's Surface Enumeration discipline (see the + section below). Enumerate every reader of `optionalSteps`/`workflow-step`/`runWorkflowSteps` and confirm + each is migrated or removed; do not leave a mock-masked dead path (learning: branch-group dead-wiring). +- Removing the seam anchor changes the compiler's accepted pipeline — confirm no remaining built-in or + fragment references `workflow-step`, then drop it from both anchor lists together with the built-in edits + from U6. + +**Test scenarios:** +- Grep proves zero remaining references to `optionalSteps`, `WorkflowOptionalStep`, `workflow-step` seam, + and `runWorkflowSteps` in non-test source. +- The compiler accepts the migrated built-ins with `workflow-step` removed from the seam order. +- The four toggle surfaces (inline card, New Task modal, Workflow tab, steps dropdown) still render and + submit `enabledWorkflowSteps` — now sourced from optional-group nodes (regression). +- A pre-existing workflow JSON that still carries `optionalSteps` (legacy persisted) does not crash parse — + the key is ignored, not fatal (back-compat decision: tolerate-and-drop). *(Confirm this stance in review; + alternative is a one-time parse upgrade.)* + +**Verification:** full core + engine + dashboard suites green; `pnpm lint`, `pnpm typecheck`, `pnpm build`, +`pnpm test:gate` pass. + +--- + +## Surface Enumeration + +Behavior-affecting change (new execution construct + removal of the legacy path) and a UI-affordance change +(new node kind + retired toggle source), so per AGENTS.md ("Fix the Invariant, Not the Repro", FN-5893) the +surfaces are enumerated: + +- **Workflow providers/graphs:** built-in **coding** and **stepwise-coding** (both migrated, U6); any + fragment or user workflow that declared `optionalSteps` (tolerated-and-dropped, U7). +- **Execution states:** group **enabled** (runs once), **disabled** (passes through), **stale id** in + `enabledWorkflowSteps` (ignored), **template failure** (routes group failure edge). +- **Editor breakpoints:** desktop and **mobile** node editor — container render, child placement, + `defaultOn` toggle, palette insert (both variants). +- **Per-task toggle surfaces:** inline quick-create card, New Task modal, task-detail Workflow tab, steps + dropdown — all re-sourced from optional-group nodes (U3/U7). +- **Validation:** subgraph walked (not just `ir.nodes`); no rework/seam inside a group; graphs without + optional groups byte-identical. +- **Compiler:** seam-anchor list with `workflow-step` removed; canonical pipeline still valid for migrated + built-ins. + +## Symptom Verification + +- **Original symptom:** optionality is invisible and unplaceable — enabled steps run in one lump at a + hidden seam, and add-ons cannot be composed or gated in the graph. +- **Exact reproduction:** build/inspect the coding workflow; `browser-verification` appears only as an + `optionalSteps` declaration, runs at the `workflow-step` seam, and is absent from the graph; add-ons + appear in the palette only as flat single steps. +- **Assertion it is gone:** an enabled optional-group runs its placed template once at its graph position + and a disabled one is inert (two-task divergence execution test, U2/U6); every add-on inserts as a + node/optional-group subgraph (U5); no `workflow-step`/`optionalSteps` path remains (U7 greps). + +--- + +## Scope Boundaries + +**In scope:** +- `optional-group` IR kind + validation (U1), executor run/bypass (U2), per-task resolution + seeding (U3). +- Editor container authoring + node-type registration (U4); add-ons as insertable subgraphs incl. + optional-group wrapping (U5). +- Built-in coding + stepwise migration (U6); retiring the declaration/seam/compiler-anchor path (U7). + +**Already built (reuse, not rebuilt):** +- Container-node toolchain: `foreach`/`loop` config, group rendering, `parentId` children, + `insertFragment` subgraph insertion. +- Per-task `enabledWorkflowSteps` column, create-surface toggles, the four toggle UIs, and + `resolveWorkflowOptionalSteps`'s output shape (source re-pointed in U3). +- Step→node projection (`workflow-steps-to-ir.ts`) reused to project add-ons (U5). + +### Deferred to Follow-Up Work +- **Nested/conditional groups** (an optional-group inside a split/foreach, or gated by a workflow field + rather than the per-task toggle) — single-level, per-task-toggle only for now. +- **Plugin-contributed add-ons as optional-group presets** beyond inserting them as flat nodes. +- The prior plan's deferred **unified per-task workflow-facet override** abstraction (optional steps + + auto-merge + column-agent overrides) — strong `/ce-compound` candidate once this lands. + +**Out of scope:** +- New persistence/migrations. Reuses `tasks.enabledWorkflowSteps`; `optional-group` is just a node kind. +- Rewriting `WorkflowStepTemplate` into a nodes+edges shape (KTD-5 keeps it flat). +- Changing unrelated execution (merge lifecycle, branch groups, PR nodes). + +--- + +## Risks & Dependencies + +- **R-1 — Subgraph-walking validation/readers miss template children.** Optional-group template nodes are + not in `ir.nodes`; any validator, reachability pass, or id parser that only sees top-level nodes will be + wrong. *Mitigation:* walk the subgraph explicitly and parse namespaced child ids candidate-style, + validating the template node exists, not just the container. + (`docs/solutions/architecture-patterns/per-entity-execution-principal-override-blast-radius.md:37,:56`) +- **R-2 — Per-task enable consulted only in the executor branch.** If skip-vs-run is read only where the + group executes and not at every gate/trigger that decides whether to run it, the toggle silently no-ops; + a slim SELECT omitting `enabledWorkflowSteps` reads `undefined`. *Mitigation:* grep every gate; ensure the + column is selected; ship the two-task divergence test. + (`docs/solutions/logic-errors/per-task-auto-merge-override-ignored-by-trigger-gates.md:100,:102,:105,:106`) +- **R-3 — Seam-anchor / parity-oracle drift on built-in migration.** `workflow-step` is a compiler seam + anchor and the stepwise IR is a byte-identity oracle; removing the seam and adding the construct shifts + snapshots and can break compile-order validation. *Mitigation:* edit built-ins, seam-anchor lists, and + parity fixtures together (KTD-6); confirm the group runs once post-foreach. + (`docs/solutions/architecture-patterns/workflow-native-runtime-primitives.md:117`; prior plan R-5) +- **R-4 — Editor node-type registration + mobile.** An unregistered `optional-group` renders as + `react-flow__node-default` with missing children, looking like a source bug; per-task toggle controls have + a real-browser-only mobile failure history. *Mitigation:* register the node type and verify against a + fresh `FUSION_CLIENT_DIR` worktree bundle on a non-4040 port; real-browser-check the `defaultOn` toggle on + a mobile viewport. + (`docs/solutions/developer-experience/browser-testing-dashboard-from-worktree-safely.md:24,:44`; + `docs/solutions/ui-bugs/mobile-auto-merge-toggle-document-scroll-blank.md:32,:53`) +- **R-5 — Mock-masked dead wiring on removal.** Retiring the legacy path risks a green suite over a feature + whose new path is never actually exercised (the branch-group failure class). *Mitigation:* execution-level + (not traversal-only) tests for enabled-runs/disabled-skips at both the construct and built-in levels; + grep-prove the old path is gone. + (`docs/solutions/integration-issues/branch-group-single-pr-synthetic-id-dead-wiring.md`) + +--- + +## Sources & Research + +- **Execution seam:** `packages/engine/src/workflow-graph-executor.ts:389-485` (`runNodeAndTraverse` + foreach/loop dispatch), `:657` (`shouldTraverseEdge`); `workflow-graph-foreach.ts`, + `workflow-graph-loop.ts` (template sub-walk to reuse). +- **IR + validation:** `packages/core/src/workflow-ir-types.ts:41-165` (node/edge/container types), + `:314-339` (legacy `optionalSteps`); `packages/core/src/workflow-ir.ts:190+` (seam-in-branch), + `:1218-1298` (`validateV2`, cycles/endpoints), `:1327` (`parseWorkflowIr`). +- **Per-task facet:** `packages/core/src/types.ts:2421,2663-2680` (`enabledWorkflowSteps`, `workflowId` + precedence), `db.ts:324`, `store.ts:424,2140`; `workflow-optional-steps.ts` (resolver to re-point); + `executor.ts` `runWorkflowSteps` (seam consumer to retire). +- **Add-on catalog:** `packages/core/src/types.ts:868-899` (`WorkflowStepTemplate`), `:902-1150` + (seven `WORKFLOW_STEP_TEMPLATES`: documentation-review, qa-check, security-audit, performance-review, + accessibility-check, browser-verification, frontend-ux-design); `WorkflowNodeEditor.tsx:249-275` + (`stepTemplateToNode` add-on→node projector to reuse). +- **Editor:** `packages/dashboard/app/components/workflow-flow-mapping.ts:46-82,266-269,316-403,431-550` + (group children, `groupTemplateConfigOf`), `:1110-1219` (`insertFragment` subgraph insertion); + `WorkflowNodeEditor.tsx:985-1010` (palette: fragments/steps/plugins, sourced from + `/api/workflow-step-templates`), `:1425-1453` (insert handlers); `nodes/WorkflowNodeTypes.tsx` (node-type + registry to extend). +- **Compiler:** `packages/core/src/workflow-compiler.ts:45,165-196` (seam anchors + canonical order). +- **Built-ins:** `packages/core/src/builtin-coding-workflow-ir.ts:69,123`, + `builtin-stepwise-coding-workflow-ir.ts:132`. +- **Prior art:** `docs/plans/2026-06-20-001-feat-workflow-optional-steps-node-editor-modal-plan.md` (the + declaration-based system this replaces); institutional learnings cited inline under Risks. diff --git a/packages/core/src/__tests__/workflow-ir-optional-group.test.ts b/packages/core/src/__tests__/workflow-ir-optional-group.test.ts new file mode 100644 index 0000000000..36590df04e --- /dev/null +++ b/packages/core/src/__tests__/workflow-ir-optional-group.test.ts @@ -0,0 +1,147 @@ +import { describe, expect, it } from "vitest"; +import { parseWorkflowIr, serializeWorkflowIr } from "../workflow-ir.js"; +import type { WorkflowIrEdge, WorkflowIrNode, WorkflowIrV2 } from "../workflow-ir-types.js"; + +/* +FNXC:WorkflowOptionalGroup 2026-06-21-11:00: +U1 validation contract for the `optional-group` container node — the single-pass, +toggle-gated subgraph that replaces the declaration-based optional-steps model. +Mirrors the loop validation suite minus loop-specific exit config. +*/ + +const columns: WorkflowIrV2["columns"] = [{ id: "work", name: "Work", traits: [] }]; + +function groupTemplate(): { nodes: WorkflowIrNode[]; edges: WorkflowIrEdge[] } { + return { + nodes: [ + { id: "verify", kind: "prompt", config: { prompt: "verify in browser" } }, + { id: "report", kind: "prompt", config: { prompt: "report" } }, + ], + edges: [{ from: "verify", to: "report" }], + }; +} + +function groupIr(config: Record = {}): WorkflowIrV2 { + return { + version: "v2", + name: "optional-group-test", + columns, + nodes: [ + { id: "start", kind: "start" }, + { + id: "browser-verification", + kind: "optional-group", + config: { + name: "Browser Verification", + defaultOn: false, + template: groupTemplate(), + ...config, + }, + }, + { id: "end", kind: "end" }, + ], + edges: [ + { from: "start", to: "browser-verification" }, + { from: "browser-verification", to: "end" }, + ], + }; +} + +describe("optional-group validation", () => { + it("parses and round-trips a valid optional-group node", () => { + const parsed = parseWorkflowIr(groupIr()) as WorkflowIrV2; + const group = parsed.nodes.find((n) => n.id === "browser-verification"); + + expect(group?.kind).toBe("optional-group"); + expect(parseWorkflowIr(serializeWorkflowIr(parsed))).toEqual(parsed); + }); + + it("does not require defaultOn (defaults to off via the resolver)", () => { + expect(() => parseWorkflowIr(groupIr({ defaultOn: undefined }))).not.toThrow(); + }); + + it("rejects a non-boolean defaultOn", () => { + expect(() => parseWorkflowIr(groupIr({ defaultOn: "yes" as unknown as boolean }))).toThrow( + /defaultOn must be a boolean/, + ); + }); + + it("rejects an empty template", () => { + expect(() => parseWorkflowIr(groupIr({ template: { nodes: [], edges: [] } }))).toThrow(/non-empty/); + }); + + it("rejects a missing template", () => { + expect(() => parseWorkflowIr(groupIr({ template: undefined }))).toThrow( + /must declare a template/, + ); + }); + + it("rejects duplicate template node ids", () => { + const template = groupTemplate(); + template.nodes.push({ id: "verify", kind: "script" }); + expect(() => parseWorkflowIr(groupIr({ template }))).toThrow(/duplicate node ids/); + }); + + it("rejects template edges that leave the template", () => { + const template = groupTemplate(); + template.edges.push({ from: "report", to: "end" }); + expect(() => parseWorkflowIr(groupIr({ template }))).toThrow(/references a node outside/); + }); + + it("rejects rework edges inside the template (single-pass guarantee)", () => { + const template = groupTemplate(); + template.edges.push({ from: "report", to: "verify", kind: "rework" }); + expect(() => parseWorkflowIr(groupIr({ template }))).toThrow(/may not contain rework edges/); + }); + + it("rejects nested loop/foreach/optional-group regions", () => { + const template = groupTemplate(); + template.nodes.push({ + id: "nested", + kind: "optional-group", + config: { template: groupTemplate() }, + }); + template.edges.push({ from: "report", to: "nested" }); + expect(() => parseWorkflowIr(groupIr({ template }))).toThrow( + /nested loop\/foreach\/optional-group/, + ); + }); + + it("rejects more than one entry node", () => { + const template: ReturnType = { + nodes: [ + { id: "a", kind: "prompt", config: { prompt: "a" } }, + { id: "b", kind: "prompt", config: { prompt: "b" } }, + { id: "join", kind: "prompt", config: { prompt: "join" } }, + ], + // a and b both have no incoming edge → two entries. + edges: [ + { from: "a", to: "join" }, + { from: "b", to: "join" }, + ], + }; + expect(() => parseWorkflowIr(groupIr({ template }))).toThrow(/exactly one entry node/); + }); + + it("rejects a template node id colliding with a top-level node id", () => { + const template = groupTemplate(); + template.nodes[0] = { id: "start", kind: "prompt", config: { prompt: "collide" } }; + template.edges = [{ from: "start", to: "report" }]; + expect(() => parseWorkflowIr(groupIr({ template }))).toThrow(/collides with a top-level node id/); + }); + + it("leaves graphs without optional-group nodes byte-identical", () => { + const ir: WorkflowIrV2 = { + version: "v2", + name: "plain", + columns, + nodes: [ + { id: "start", kind: "start" }, + { id: "end", kind: "end" }, + ], + edges: [{ from: "start", to: "end" }], + }; + const parsed = parseWorkflowIr(ir); + expect(parseWorkflowIr(serializeWorkflowIr(parsed))).toEqual(parsed); + }); +}); diff --git a/packages/core/src/workflow-ir-types.ts b/packages/core/src/workflow-ir-types.ts index f2d5c90e1d..689a7b25c9 100644 --- a/packages/core/src/workflow-ir-types.ts +++ b/packages/core/src/workflow-ir-types.ts @@ -23,6 +23,7 @@ export type WorkflowIrNodeKind = | "join" | "foreach" | "loop" + | "optional-group" | "step-review" | "parse-steps" | "code" @@ -164,6 +165,26 @@ export interface WorkflowLoopConfig { }; } +/* +FNXC:WorkflowOptionalGroup 2026-06-21-11:00: +An `optional-group` node is a container (mirroring `foreach`/`loop`) whose `template` subgraph the executor runs ONCE when the group is enabled for the task and passes through (skips) when disabled. +Enable state reuses the per-task `enabledWorkflowSteps` facet keyed by the group node id, seeded from `defaultOn` at task creation — this replaces the execution-inert declaration-based optional-steps model (`WorkflowOptionalStep`/`optionalSteps`). +Single pass only: no iteration, no rework budget. Rework edges are forbidden inside the template so the single-pass guarantee is unambiguous (validated in `validateOptionalGroup`). +*/ +/** Config for an `optional-group` container node. `defaultOn` seeds the per-task + * enable set at creation; the `template` is the subgraph run once when enabled. + * Unlike `foreach`/`loop`, there is no iteration or rework — a single pass. */ +export interface WorkflowOptionalGroupConfig { + /** Workflow-author default for whether new tasks enable this group. */ + defaultOn?: boolean; + /** Display name for the group (editor + per-task toggle surfaces). */ + name?: string; + template: { + nodes: WorkflowIrNode[]; + edges: WorkflowIrEdge[]; + }; +} + /** Step-inversion (KTD-12): a workflow-declared task document. Artifacts ride the * existing task-documents machinery; `step-source` artifacts feed `parse-steps`. */ export interface WorkflowIrArtifact { diff --git a/packages/core/src/workflow-ir.ts b/packages/core/src/workflow-ir.ts index 0a052a87a9..4f0a50a161 100644 --- a/packages/core/src/workflow-ir.ts +++ b/packages/core/src/workflow-ir.ts @@ -9,6 +9,7 @@ import type { WorkflowHoldRelease, WorkflowForeachConfig, WorkflowLoopConfig, + WorkflowOptionalGroupConfig, WorkflowFieldDefinition, WorkflowFieldType, WorkflowSettingDefinition, @@ -602,6 +603,108 @@ function validateLoop( } } +/* +FNXC:WorkflowOptionalGroup 2026-06-21-11:00: +Validate an `optional-group` container template, mirroring `validateLoop` minus the loop's exit/iteration config. +The template runs once when enabled, so rework edges (and any cycles) are forbidden inside, single entry/exit is required, and nested foreach/loop groups are rejected — keeping the single-pass guarantee unambiguous. +`defaultOn` must be boolean when present; `name` must be a string when present. +*/ +function validateOptionalGroup( + node: WorkflowIrNode, + topLevelNodeIds: Set, + columnIds: Set, +): void { + const cfg = node.config as Partial | undefined; + const template = cfg?.template; + if ( + !cfg || + !template || + !Array.isArray(template.nodes) || + !Array.isArray(template.edges) + ) { + throw new WorkflowIrError( + `optional-group node '${node.id}' must declare a template with nodes and edges arrays`, + ); + } + if (template.nodes.length === 0) { + throw new WorkflowIrError(`optional-group node '${node.id}' template must be non-empty`); + } + if (cfg.defaultOn !== undefined && typeof cfg.defaultOn !== "boolean") { + throw new WorkflowIrError(`optional-group node '${node.id}' defaultOn must be a boolean`); + } + if (cfg.name !== undefined && typeof cfg.name !== "string") { + throw new WorkflowIrError(`optional-group node '${node.id}' name must be a string`); + } + + const templateNodes = template.nodes; + const templateIds = new Set(templateNodes.map((n) => n.id)); + if (templateIds.size !== templateNodes.length) { + throw new WorkflowIrError(`optional-group node '${node.id}' template has duplicate node ids`); + } + for (const inner of templateNodes) { + if (inner.kind === "loop" || inner.kind === "foreach" || inner.kind === "optional-group") { + throw new WorkflowIrError( + `optional-group node '${node.id}' template may not contain nested loop/foreach/optional-group ('${inner.id}')`, + ); + } + if (isStepExecuteNode(inner)) { + throw new WorkflowIrError( + `step-execute seam node '${inner.id}' is only legal inside a foreach template`, + ); + } + if (inner.column !== undefined && !columnIds.has(inner.column)) { + throw new WorkflowIrError( + `Workflow node '${inner.id}' references undefined column '${inner.column}'`, + ); + } + } + for (const edge of template.edges) { + const fromInside = templateIds.has(edge.from); + const toInside = templateIds.has(edge.to); + if (!fromInside || !toInside) { + throw new WorkflowIrError( + `optional-group node '${node.id}' template edge '${edge.from}' -> '${edge.to}' references a node outside the template`, + ); + } + if (isReworkEdge(edge)) { + throw new WorkflowIrError(`optional-group node '${node.id}' template may not contain rework edges`); + } + } + + const incoming = new Map(); + const outgoingCount = new Map(); + for (const edge of template.edges) { + incoming.set(edge.to, (incoming.get(edge.to) ?? 0) + 1); + outgoingCount.set(edge.from, (outgoingCount.get(edge.from) ?? 0) + 1); + } + const entries = templateNodes.filter((n) => (incoming.get(n.id) ?? 0) === 0); + const exits = templateNodes.filter((n) => (outgoingCount.get(n.id) ?? 0) === 0); + if (entries.length !== 1) { + throw new WorkflowIrError( + `optional-group node '${node.id}' template must have exactly one entry node (found ${entries.length})`, + ); + } + if (exits.length !== 1) { + throw new WorkflowIrError( + `optional-group node '${node.id}' template must have exactly one exit node (found ${exits.length})`, + ); + } + + const templateById = new Map(templateNodes.map((n) => [n.id, n])); + const templateOutgoing = buildOutgoing(template.edges); + validateNoIllegalCycles(templateNodes, templateOutgoing); + validateParallelism(templateNodes, templateOutgoing, templateById); + validateStepReviewRouting(templateNodes, templateOutgoing, templateById, false); + + for (const id of templateIds) { + if (topLevelNodeIds.has(id)) { + throw new WorkflowIrError( + `optional-group node '${node.id}' template node id '${id}' collides with a top-level node id`, + ); + } + } +} + /** step-execute seam nodes are legal ONLY inside a foreach template (KTD-4): * reject any at the top level. (Inside-split-branch rejection is handled by * SEAM_FORBIDDEN_IN_BRANCH within validateParallelism.) */ @@ -1265,6 +1368,7 @@ function validateV2(ir: WorkflowIrV2): void { for (const node of ir.nodes) { if (node.kind === "foreach") validateForeach(node, topLevelIds, columnIds); if (node.kind === "loop") validateLoop(node, topLevelIds, columnIds); + if (node.kind === "optional-group") validateOptionalGroup(node, topLevelIds, columnIds); } validateStepReviewRouting(ir.nodes, outgoing, nodesById, false); validateParseStepsNodes(ir);