Merge branch 'main' into feature/add-request-headers-x-session-id-and-x-session-a
This commit is contained in:
7
.changeset/retire-optional-steps-declaration.md
Normal file
7
.changeset/retire-optional-steps-declaration.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": major
|
||||
---
|
||||
|
||||
**Breaking:** the `WorkflowOptionalStep` type, previously exported from `@runfusion/fusion`, is removed — any consumer importing it must migrate to `optional-group` nodes / `ResolvedWorkflowOptionalStep`.
|
||||
|
||||
Retire the legacy optional-step DECLARATION model now that optional steps are graph-native `optional-group` nodes. Remove the `WorkflowOptionalStep` type and the `WorkflowIrV2.optionalSteps` IR field, drop the workflow node editor's optional-step declaration authoring panel (sidebar section, mobile tab, and collapse state), and stop threading an `optionalSteps` array through `flowToIr`/`serializeGraph`. A legacy persisted `optionalSteps` key on an old v2 workflow row is now tolerated (ignored, not validated) at parse so old rows still load as v2, and the rollback-downgrade heuristic still treats such a row as v2. The per-task optional-step toggle surfaces are unchanged — they continue to list and toggle optional steps sourced from `optional-group` nodes via `resolveWorkflowOptionalSteps` (`ResolvedWorkflowOptionalStep`).
|
||||
5
.changeset/workflow-node-help.md
Normal file
5
.changeset/workflow-node-help.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@runfusion/fusion": minor
|
||||
---
|
||||
|
||||
Workflow editor: add a Help section to the node detail pane. Every node now documents what it does, how to configure it, and its inputs/outputs/edges — including the engine-managed merge-lifecycle nodes (auto-merge gate, branch-group member integration, branch-group promotion, PR and recovery nodes), which are surfaced read-only with an "Engine-managed" badge.
|
||||
5
.changeset/workflow-optional-group-subgraphs.md
Normal file
5
.changeset/workflow-optional-group-subgraphs.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@runfusion/fusion": minor
|
||||
---
|
||||
|
||||
Workflow editor: optional steps are now graph-native. A new `optional-group` container node (foreach/loop-style) holds a subgraph the executor runs once when the group is enabled for a task (per-task `enabledWorkflowSteps` + workflow `defaultOn`) and bypasses when disabled. All seven built-in add-ons (documentation-review, qa-check, security-audit, performance-review, accessibility-check, browser-verification, frontend-ux-design) are insertable from the node-editor palette as a node or wrapped in an optional-group. The built-in coding and stepwise-coding workflows now express `browser-verification` as an optional-group. Optional-group enable resolution correctly handles id collisions with add-on template ids, so a group's enable state is not silently bypassed during task creation/update. (The legacy declaration-based optional-steps model is retired in a sibling changeset; only the `workflow-step` seam infrastructure removal remains a follow-up.)
|
||||
@@ -260,6 +260,11 @@ A workflow graph node that reads a declared Artifact and runs a registry parser
|
||||
### Custom task field
|
||||
A workflow-declared, typed task field (`string | text | number | boolean | enum | multi-enum | date | url`, with enum options and render hints) whose values live in `tasks.customFields`, keyed by field id. The task model is thereby recast as core fields (title, description) + standard metadata + these workflow-defined fields. Writes pass through a single store authority (`updateTaskCustomFields`) that validates each value against the resolving workflow's schema and returns typed rejections (offending `fieldId` + `code`); agents write them via `fn_task_update`'s `custom_fields` patch. Editing a workflow's fields or switching a task's workflow orphans (never destroys) values for removed or type-incompatible ids — orphans are retained and surfaced under a detail disclosure, excluded from cards. Same id means the same field within a project; there is no cross-workflow shared field namespace.
|
||||
|
||||
### Optional step group
|
||||
A workflow graph container node (alongside `foreach`/`loop`) whose template subgraph runs once when a task has enabled it and is bypassed otherwise — the graph-native way to make a step optional per task. Enablement is a per-task toggle set seeded from the group's workflow-level default; the group's own node id is the toggle key. It replaces the earlier execution-inert *declaration* model (a separate optional-step list run through a hidden seam), so optional steps are now real, placeable nodes rather than an out-of-graph facet.
|
||||
|
||||
Single pass — no iteration or rework inside the template (this is what distinguishes it from `foreach`/`loop`). Because the toggle key is the node id, renaming or recreating a group resets its per-task enablement; and because that id may deliberately equal a built-in step-template id, the per-task enable set must keep group ids identity-stable rather than round-tripping them through legacy step-template materialization (which would remap the key and silently bypass the group).
|
||||
|
||||
## Persistence & migrations
|
||||
|
||||
### Schema-Version Sweep
|
||||
|
||||
@@ -0,0 +1,626 @@
|
||||
---
|
||||
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:<x>`) 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).
|
||||
|
||||
### Delivered cohort (this PR) vs. Deferred
|
||||
This PR delivers **U1–U6 plus U7a** (10 commits). U7a retired the legacy declaration *model*: the core
|
||||
`WorkflowOptionalStep` type + `WorkflowIrV2.optionalSteps` field + `validateOptionalSteps`, and the editor's
|
||||
declaration **authoring** surface (`WorkflowOptionalStepsPanel`, `optionalStepsOf`, the `flowToIr`
|
||||
`optionalSteps` threading). A code-review pass also fixed a P1 (the optional-group toggle-id collision in
|
||||
enable resolution) — captured in the commit history and in
|
||||
`docs/solutions/logic-errors/optional-group-toggle-id-remapped-by-step-materializer.md`. The per-task toggle
|
||||
surfaces (`WorkflowOptionalStepsDropdown`, inline card, modal, Workflow tab) stayed — they consume the
|
||||
distinct `ResolvedWorkflowOptionalStep`.
|
||||
|
||||
- **Deferred: the `workflow-step` seam infrastructure removal.** What remains of "full U7" is excising the
|
||||
`workflow-step` seam itself — a shared `WorkflowSeam` union member woven through ~9 engine runtime files
|
||||
(`runtime-primitives`, `step-session-executor`, `workflow-node-handlers`, `active-session-registry`,
|
||||
`workflow-graph-task-runner`, `executor.runWorkflowSteps`, the compiler seam-anchor). It is now orphaned
|
||||
(no built-in graph reaches it) but inert; excising it is its own focused refactor with its own blast radius.
|
||||
- **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.
|
||||
@@ -0,0 +1,102 @@
|
||||
---
|
||||
title: "Optional-group enable toggle silently bypassed — node id collided with a legacy step-template namespace and was remapped"
|
||||
date: 2026-06-21
|
||||
category: docs/solutions/logic-errors
|
||||
module: engine (workflow store + graph executor)
|
||||
problem_type: logic_error
|
||||
component: service_object
|
||||
symptoms:
|
||||
- "Enabling a built-in optional-group (browser-verification) on a coding/stepwise task did nothing — the group's steps never ran."
|
||||
- "The default-on seed path and direct graph-executor unit tests passed, masking the bug; only user-driven enable (create-with-enable or update/toggle) failed."
|
||||
- "No error surfaced — the enabled group was silently bypassed."
|
||||
root_cause: logic_error
|
||||
resolution_type: code_fix
|
||||
severity: high
|
||||
related_components:
|
||||
- workflow-store
|
||||
- graph-executor
|
||||
- optional-group
|
||||
tags:
|
||||
- optional-group
|
||||
- enabledworkflowsteps
|
||||
- per-task-override
|
||||
- id-collision
|
||||
- workflow-store
|
||||
- silent-bypass
|
||||
---
|
||||
|
||||
# Optional-group enable toggle silently bypassed — node id collided with a legacy step-template namespace and was remapped
|
||||
|
||||
## Problem
|
||||
|
||||
A graph-native `optional-group` workflow node is enabled per task via the `enabledWorkflowSteps` array, keyed by the group's **node id**. The graph executor runs the group only when `task.enabledWorkflowSteps.includes(node.id)`. But the store's `resolveEnabledWorkflowSteps` ran every id through the **legacy step-template materializer** (`getBuiltInWorkflowTemplate` → `ensureWorkflowStepForTemplate`). The built-in `browser-verification` group deliberately reused the template id `"browser-verification"` as its node id (for back-compat), so that id matched a `WORKFLOW_STEP_TEMPLATES` entry and was **remapped to a materialized `WorkflowStep` row id** (≠ the node id). The executor's membership check then never matched, and the enabled group was silently bypassed — the headline use case (turn the optional step on) did nothing, with no error.
|
||||
|
||||
## Symptoms
|
||||
|
||||
- Enabling `browser-verification` on a coding/stepwise task ran nothing pre-merge.
|
||||
- Direct graph-executor tests (which pass a raw `enabledWorkflowSteps: ["browser-verification"]`) and the default-on **seed** path passed — masking the defect.
|
||||
- Only the **user-driven** enable paths failed: create-with-explicit-enable and `updateTask({ enabledWorkflowSteps })` (the per-task toggle in the UI).
|
||||
|
||||
## What Didn't Work
|
||||
|
||||
- **Trusting the existing tests.** The unit tests used group ids like `og-on`/`og-off` that do **not** collide with any `WORKFLOW_STEP_TEMPLATES` id, so `getBuiltInWorkflowTemplate` returned undefined and the id passed through untouched — the tests were green precisely because they avoided the colliding id. The bug only fires when the group id equals a built-in template id.
|
||||
- **Assuming the executor test covered it.** The two-task divergence test enabled the group by writing `enabledWorkflowSteps` straight onto the task, bypassing the store's resolver — so it never exercised the remap. The defect lived entirely in the create/update **resolution** path, one layer above the executor.
|
||||
|
||||
## Solution
|
||||
|
||||
Pass a workflow's optional-group node ids through `resolveEnabledWorkflowSteps` **untouched** — they are executor toggle keys, not legacy step-template ids to be materialized.
|
||||
|
||||
```ts
|
||||
// NEW: enumerate every optional-group node id (regardless of defaultOn).
|
||||
export function resolveAllOptionalGroupIds(ir: WorkflowIr): string[] {
|
||||
return resolveWorkflowOptionalSteps(ir).map((step) => step.templateId); // templateId === group node id
|
||||
}
|
||||
|
||||
// store.ts — the resolver gains an optional pass-through set:
|
||||
private async resolveEnabledWorkflowSteps(
|
||||
stepIds?: string[],
|
||||
optionalGroupIds?: Set<string>,
|
||||
): Promise<string[] | undefined> {
|
||||
// ...
|
||||
// Optional-group toggle ids pass through raw — never materialized as legacy step rows.
|
||||
const template = optionalGroupIds?.has(stepId)
|
||||
? undefined
|
||||
: this.getBuiltInWorkflowTemplate(stepId);
|
||||
const resolvedId = template ? (await this.ensureWorkflowStepForTemplate(stepId)).id : stepId;
|
||||
// ...
|
||||
}
|
||||
|
||||
// helper resolving the task's workflow IR → its optional-group id set:
|
||||
private async optionalGroupIdSet(workflowId?: string | null): Promise<Set<string>> {
|
||||
const wfId = workflowId ?? (await this.getDefaultWorkflowId());
|
||||
if (!wfId) return new Set();
|
||||
const def = await this.getWorkflowDefinition(wfId);
|
||||
if (!def || def.kind === "fragment") return new Set();
|
||||
return new Set(resolveAllOptionalGroupIds(def.ir));
|
||||
}
|
||||
```
|
||||
|
||||
Both user-enable call sites supply the set: create (`optionalGroupIdSet(input.workflowId)`) and update (`optionalGroupIdSet(getTaskWorkflowSelection(task.id)?.workflowId)`).
|
||||
|
||||
**Regression test** — must use a **colliding** id (`browser-verification`), since non-colliding ids never reproduce it: create-with-enable and update/toggle both assert the raw group node id survives in `enabledWorkflowSteps`.
|
||||
|
||||
## Why This Works
|
||||
|
||||
The bug is a **per-task override that is read correctly at the action site but rewritten en route**. The override (`enabledWorkflowSteps`) was consulted exactly where the action runs (the graph executor), but the value was mutated in the **resolution path** before it got there, because two id namespaces overlap: graph-native optional-group **node ids** and legacy **`WorkflowStep` template ids**. The materializer is meaningful only for the retired declaration/`workflow-step`-seam execution model; for a graph-native group it is pure harm. Marking group ids as pass-through keeps the key **identity-stable** from definition through every consumer, so the executor's `includes(node.id)` check matches.
|
||||
|
||||
(Verified the related slim-projection trap does **not** apply: the executor reads `enabledWorkflowSteps` off the `TaskDetail` snapshot it is handed, not a column-narrowed SELECT, so the array is fully hydrated.)
|
||||
|
||||
## Prevention
|
||||
|
||||
- **When introducing a new identity/key that shares a namespace with an existing one, grep every reader AND every *transformer* of that key.** A silent remap in a resolver is as fatal as a missing read — the override "survives" but as the wrong value. Demand each consumer is either re-keyed or argued identity-stable.
|
||||
- **Regression tests for namespace collisions must use a *colliding* value.** A test with a deliberately distinct id proves nothing about the collision; pick the id that actually overlaps the legacy namespace (here, a built-in template id reused as a node id).
|
||||
- **Test the path the user actually takes, not just the layer under test.** The executor-level test bypassed the store resolver where the bug lived; a create/update round-trip through the store would have caught it. Prefer at least one end-to-end seam test per per-task facet.
|
||||
- **A facet that "works on seed/default but not on toggle" is the tell.** Asymmetry between the seed path (writes raw ids) and the user-enable path (runs the resolver) localizes the defect to the resolver.
|
||||
|
||||
## Related Issues
|
||||
|
||||
This is the **id-namespace-collision variant** of the per-task/per-entity override blast-radius class. Same disease (override invisible to the user, no error), different organ (key rewritten in resolution vs. not consulted at a trigger gate):
|
||||
|
||||
- [Per-task auto-merge override ignored by trigger-layer gates](../logic-errors/per-task-auto-merge-override-ignored-by-trigger-gates.md) — sibling: override dead from the user's perspective; theirs is a missed trigger gate, ours is a resolution-path key remap. Its "consult the override everywhere between definition and action" rule covers this case too.
|
||||
- [Per-entity execution-principal override: the full blast-radius checklist](../architecture-patterns/per-entity-execution-principal-override-blast-radius.md) — the generalizing checklist; closest prior art is its "validate composite node ids against the graph, never round-trip them" example. This bug is a new bullet for that checklist.
|
||||
- [Workflow-native execution through runtime primitives](../architecture-patterns/workflow-native-runtime-primitives.md) — context: the legacy-`WorkflowStep`-row vs. graph-node two-control-planes tension this collision exploits.
|
||||
@@ -38,11 +38,33 @@ describe("builtin coding workflow ir", () => {
|
||||
const seams = BUILTIN_CODING_WORKFLOW_IR.nodes
|
||||
.map((node) => String(node.config?.seam ?? ""))
|
||||
.filter((seam) => seam.length > 0);
|
||||
expect(seams).toEqual(expect.arrayContaining(["execute", "workflow-step", "review"]));
|
||||
expect(seams).toEqual(expect.arrayContaining(["execute", "review"]));
|
||||
// U6: the `workflow-step` seam was replaced by the browser-verification
|
||||
// optional-group; no node declares the legacy seam anymore.
|
||||
expect(seams).not.toContain("workflow-step");
|
||||
expect(seams).not.toContain("merge");
|
||||
expect(seams).not.toContain("triage");
|
||||
});
|
||||
|
||||
it("expresses pre-merge browser-verification as a default-off optional-group (U6)", () => {
|
||||
const byId = new Map(BUILTIN_CODING_WORKFLOW_IR.nodes.map((n) => [n.id, n]));
|
||||
expect(byId.get("workflow-step")).toBeUndefined();
|
||||
const group = byId.get("browser-verification");
|
||||
expect(group?.kind).toBe("optional-group");
|
||||
expect(group?.config?.name).toBe("Browser Verification");
|
||||
expect(group?.config?.defaultOn).toBe(false);
|
||||
// execute → browser-verification → review on the success path; failure → end.
|
||||
expect(BUILTIN_CODING_WORKFLOW_IR.edges).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ from: "execute", to: "browser-verification", condition: "success" }),
|
||||
expect.objectContaining({ from: "browser-verification", to: "review", condition: "success" }),
|
||||
expect.objectContaining({ from: "browser-verification", to: "end", condition: "failure" }),
|
||||
]),
|
||||
);
|
||||
// The legacy optionalSteps declaration is gone (the group replaces it).
|
||||
expect("optionalSteps" in BUILTIN_CODING_WORKFLOW_IR).toBe(false);
|
||||
});
|
||||
|
||||
it("defines the six legacy columns in legacy order (KTD-1)", () => {
|
||||
expect(BUILTIN_CODING_WORKFLOW_IR.version).toBe("v2");
|
||||
if (BUILTIN_CODING_WORKFLOW_IR.version !== "v2") throw new Error("expected v2");
|
||||
@@ -73,16 +95,17 @@ describe("builtin coding workflow ir", () => {
|
||||
it("places seam nodes in their columns", () => {
|
||||
const byId = new Map(BUILTIN_CODING_WORKFLOW_IR.nodes.map((n) => [n.id, n]));
|
||||
expect(byId.get("execute")?.column).toBe("in-progress");
|
||||
expect(byId.get("workflow-step")?.column).toBe("in-progress");
|
||||
// U6: browser-verification optional-group replaces the workflow-step seam.
|
||||
expect(byId.get("browser-verification")?.column).toBe("in-progress");
|
||||
expect(byId.get("review")?.column).toBe("in-review");
|
||||
expect(byId.get("merge-gate")?.column).toBe("in-review");
|
||||
expect(byId.get("merge-attempt")?.column).toBe("in-review");
|
||||
});
|
||||
|
||||
it("assigns descriptive names to execute/workflow-step/review/merge seam nodes", () => {
|
||||
it("assigns descriptive names to execute/review seam nodes and the browser-verification group", () => {
|
||||
const byId = new Map(BUILTIN_CODING_WORKFLOW_IR.nodes.map((n) => [n.id, n]));
|
||||
expect(byId.get("execute")?.config?.name).toBe("Execute");
|
||||
expect(byId.get("workflow-step")?.config?.name).toBe("Pre-merge workflow steps");
|
||||
expect(byId.get("browser-verification")?.config?.name).toBe("Browser Verification");
|
||||
expect(byId.get("review")?.config?.name).toBe("Review");
|
||||
});
|
||||
|
||||
@@ -94,9 +117,8 @@ describe("builtin coding workflow ir", () => {
|
||||
expect(config.maxRetries).toBeLessThanOrEqual(10);
|
||||
|
||||
const byId = new Map(BUILTIN_CODING_WORKFLOW_IR.nodes.map((n) => [n.id, n]));
|
||||
expect(byId.get("workflow-step")?.config?.name).toBe("Pre-merge workflow steps");
|
||||
expect(byId.get("browser-verification")?.config?.name).toBe("Browser Verification");
|
||||
expect(byId.get("review")?.config?.name).toBe("Review");
|
||||
expect(byId.get("workflow-step")?.config?.maxRetries).toBeUndefined();
|
||||
expect(byId.get("review")?.config?.maxRetries).toBeUndefined();
|
||||
expect(byId.get("merge-attempt")?.config?.maxReworkCycles).toBe(3);
|
||||
});
|
||||
|
||||
@@ -152,7 +152,11 @@ describe("built-in workflows", () => {
|
||||
|
||||
const byId = new Map(ir.nodes.map((node) => [node.id, node]));
|
||||
expect(byId.get("execute")?.column).toBe("in-progress");
|
||||
expect(byId.get("workflow-step")?.column).toBe("in-progress");
|
||||
// U6: the legacy `workflow-step` seam is replaced by the pre-merge
|
||||
// `browser-verification` optional-group, placed in the implementation column.
|
||||
expect(byId.get("workflow-step")).toBeUndefined();
|
||||
expect(byId.get("browser-verification")?.kind).toBe("optional-group");
|
||||
expect(byId.get("browser-verification")?.column).toBe("in-progress");
|
||||
expect(byId.get("review")?.column).toBe("in-review");
|
||||
// Merge is the native primitive region (FN-6035), placed in in-review.
|
||||
expect(byId.get("merge")).toBeUndefined();
|
||||
@@ -312,9 +316,12 @@ describe("built-in workflows", () => {
|
||||
expect(executeConfig?.maxRetries).toBeLessThanOrEqual(10);
|
||||
|
||||
const byId = new Map(candidate.nodes.map((node) => [node.id, node]));
|
||||
expect(byId.get("workflow-step")?.config?.name).toBe("Pre-merge workflow steps");
|
||||
// U6: pre-merge browser-verification is an optional-group (default OFF),
|
||||
// not the legacy `workflow-step` seam.
|
||||
expect(byId.get("workflow-step")).toBeUndefined();
|
||||
expect(byId.get("browser-verification")?.kind).toBe("optional-group");
|
||||
expect(byId.get("browser-verification")?.config?.name).toBe("Browser Verification");
|
||||
expect(byId.get("review")?.config?.name).toBe("Review");
|
||||
expect(byId.get("workflow-step")?.config?.maxRetries).toBeUndefined();
|
||||
expect(byId.get("review")?.config?.maxRetries).toBeUndefined();
|
||||
// The merge lifecycle is no longer a single `merge` seam node (FN-6035): it
|
||||
// is expressed as the merge-gate/merge-attempt/branch-group primitive region.
|
||||
@@ -600,17 +607,22 @@ describe("built-in workflows", () => {
|
||||
description: "implicit builtin default",
|
||||
});
|
||||
|
||||
// U6: builtin:coding now carries the `browser-verification` optional-group
|
||||
// (an interpreter-deferred construct), so its DEFAULT-workflow materialization
|
||||
// falls back to no legacy WorkflowStep rows and records no selection row —
|
||||
// identical to the stepwise built-in below. The group is defaultOn:false, so
|
||||
// enabledWorkflowSteps stays empty.
|
||||
await store.setDefaultWorkflowId("builtin:coding");
|
||||
const codingTask = await store.createTask({ description: "default builtin coding" });
|
||||
expect((await store.getTask(codingTask.id)).enabledWorkflowSteps ?? []).toEqual([]);
|
||||
expect(store.getTaskWorkflowSelection(codingTask.id)).toEqual({ workflowId: "builtin:coding", stepIds: [] });
|
||||
expect(store.getTaskWorkflowSelection(codingTask.id)).toBeUndefined();
|
||||
|
||||
const reservedCodingTask = await store.createTaskWithReservedId(
|
||||
{ description: "reserved default builtin coding" },
|
||||
{ taskId: "reserved-default-builtin-coding" },
|
||||
);
|
||||
expect((await store.getTask(reservedCodingTask.id)).enabledWorkflowSteps ?? []).toEqual([]);
|
||||
expect(store.getTaskWorkflowSelection(reservedCodingTask.id)).toEqual({ workflowId: "builtin:coding", stepIds: [] });
|
||||
expect(store.getTaskWorkflowSelection(reservedCodingTask.id)).toBeUndefined();
|
||||
|
||||
await store.setDefaultWorkflowId("builtin:stepwise-coding");
|
||||
const stepwiseTask = await store.createTask({ description: "default builtin stepwise" });
|
||||
|
||||
@@ -4,7 +4,6 @@ import { BUILTIN_CODING_WORKFLOW_IR } from "../builtin-coding-workflow-ir.js";
|
||||
import { BUILTIN_STEPWISE_CODING_WORKFLOW_IR } from "../builtin-stepwise-coding-workflow-ir.js";
|
||||
import {
|
||||
compileWorkflowToSteps,
|
||||
MERGE_REGION_NODE_KINDS,
|
||||
validateLinearity,
|
||||
WorkflowCompileError,
|
||||
WORKFLOW_INTERPRETER_DEFERRED_SUFFIX,
|
||||
@@ -127,33 +126,25 @@ describe("compileWorkflowToSteps (U2)", () => {
|
||||
expect(() => compileWorkflowToSteps(ir)).toThrow(/interpreter \(deferred\)/i);
|
||||
});
|
||||
|
||||
it("validates builtin workflow linearity while preserving stepwise interpreter deferral", () => {
|
||||
expect(validateLinearity(BUILTIN_CODING_WORKFLOW_IR)).toBeNull();
|
||||
it("defers both builtin coding and stepwise to the interpreter (U6: coding now carries an optional-group)", () => {
|
||||
// U6: builtin:coding gained the `browser-verification` optional-group on its
|
||||
// pre-merge path — a branching, single-pass container the linear WorkflowStep
|
||||
// runner cannot lower. Like stepwise, coding is now interpreter-deferred.
|
||||
const codingErr = validateLinearity(BUILTIN_CODING_WORKFLOW_IR);
|
||||
expect(codingErr).toBeInstanceOf(WorkflowCompileError);
|
||||
expect(codingErr?.message).toContain(WORKFLOW_INTERPRETER_DEFERRED_SUFFIX);
|
||||
|
||||
const stepwiseErr = validateLinearity(BUILTIN_STEPWISE_CODING_WORKFLOW_IR);
|
||||
expect(stepwiseErr).toBeInstanceOf(WorkflowCompileError);
|
||||
expect(stepwiseErr?.message).toContain(WORKFLOW_INTERPRETER_DEFERRED_SUFFIX);
|
||||
});
|
||||
|
||||
it("compiles the builtin coding workflow without merge-region steps", () => {
|
||||
const steps = compileWorkflowToSteps(BUILTIN_CODING_WORKFLOW_IR);
|
||||
const mergeRegionNodeIds = BUILTIN_CODING_WORKFLOW_IR.nodes
|
||||
.filter((node) => MERGE_REGION_NODE_KINDS.has(node.kind))
|
||||
.map((node) => node.id);
|
||||
|
||||
expect(steps.map((step) => step.name)).toEqual([]);
|
||||
expect(mergeRegionNodeIds).toEqual(
|
||||
expect.arrayContaining([
|
||||
"merge-gate",
|
||||
"merge-retry",
|
||||
"merge-manual-hold",
|
||||
"branch-group-member-integration",
|
||||
"branch-group-promotion",
|
||||
"merge-attempt",
|
||||
"recovery-router",
|
||||
]),
|
||||
);
|
||||
expect(steps.some((step) => mergeRegionNodeIds.includes(step.name))).toBe(false);
|
||||
it("defers compiling the builtin coding workflow to the interpreter (U6)", () => {
|
||||
// The browser-verification optional-group makes the graph non-linear, so
|
||||
// compileWorkflowToSteps throws the interpreter-deferred error rather than
|
||||
// producing a (previously empty) linear pre-merge step list.
|
||||
expect(() => compileWorkflowToSteps(BUILTIN_CODING_WORKFLOW_IR)).toThrow(WorkflowCompileError);
|
||||
expect(() => compileWorkflowToSteps(BUILTIN_CODING_WORKFLOW_IR)).toThrow(/interpreter \(deferred\)/i);
|
||||
});
|
||||
|
||||
it("compiles a workflow whose post-review merge region branches into primitives (FN-6035)", () => {
|
||||
|
||||
154
packages/core/src/__tests__/workflow-ir-optional-group.test.ts
Normal file
154
packages/core/src/__tests__/workflow-ir-optional-group.test.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
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<string, unknown> = {}): 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 failure-condition edges inside the template (single-pass bails before routing them)", () => {
|
||||
const template = groupTemplate();
|
||||
// A parallel failure edge that the single-pass walk would silently never take.
|
||||
template.edges.push({ from: "verify", to: "report", condition: "failure" });
|
||||
expect(() => parseWorkflowIr(groupIr({ template }))).toThrow(/may not contain failure-condition 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<typeof groupTemplate> = {
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -155,7 +155,12 @@ describe("parseWorkflowIr — v2 columns & placement", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseWorkflowIr — optionalSteps", () => {
|
||||
// FNXC:WorkflowOptionalGroup 2026-06-21-18:00:
|
||||
// The legacy `optionalSteps` declaration field is retired. A legacy persisted
|
||||
// `optionalSteps` key on an old v2 row is now TOLERATED — no longer validated or
|
||||
// required — so old rows still parse as v2 (optional steps are graph-native
|
||||
// `optional-group` nodes now).
|
||||
describe("parseWorkflowIr — legacy optionalSteps tolerated", () => {
|
||||
const columns = DEFAULT_WORKFLOW_COLUMN_IDS.map((id) => ({ id, name: id, traits: [] }));
|
||||
const base = (): WorkflowIrV2 => v2(
|
||||
columns,
|
||||
@@ -166,27 +171,25 @@ describe("parseWorkflowIr — optionalSteps", () => {
|
||||
[{ from: "start", to: "end" }],
|
||||
);
|
||||
|
||||
it("parses and serializes optionalSteps deterministically", () => {
|
||||
const ir: WorkflowIrV2 = {
|
||||
it("parses a legacy v2 row carrying an optionalSteps key without throwing", () => {
|
||||
const ir = {
|
||||
...base(),
|
||||
// Legacy declaration shapes — including ones the old validator rejected —
|
||||
// are now ignored, not validated.
|
||||
optionalSteps: [
|
||||
{ templateId: "browser-verification" },
|
||||
{ templateId: "plugin:example:step", defaultOn: true },
|
||||
{ defaultOn: "yes" },
|
||||
"nope",
|
||||
],
|
||||
};
|
||||
} as unknown as WorkflowIr;
|
||||
|
||||
expect(() => parseWorkflowIr(ir)).not.toThrow();
|
||||
const parsed = parseWorkflowIr(ir);
|
||||
expect(parsed).toEqual(ir);
|
||||
expect(parsed.version).toBe("v2");
|
||||
// The key passes through untouched (round-trips through serialize/parse).
|
||||
expect(JSON.parse(serializeWorkflowIr(parsed))).toEqual(ir);
|
||||
});
|
||||
|
||||
it("rejects malformed optionalSteps", () => {
|
||||
expect(() => parseWorkflowIr({ ...base(), optionalSteps: "nope" } as unknown as WorkflowIr)).toThrow(WorkflowIrError);
|
||||
expect(() => parseWorkflowIr({ ...base(), optionalSteps: [{}] } as unknown as WorkflowIr)).toThrow(/non-empty templateId/);
|
||||
expect(() => parseWorkflowIr({ ...base(), optionalSteps: [{ templateId: "" }] } as unknown as WorkflowIr)).toThrow(/non-empty templateId/);
|
||||
expect(() => parseWorkflowIr({ ...base(), optionalSteps: [{ templateId: "browser-verification", defaultOn: "yes" }] } as unknown as WorkflowIr)).toThrow(/defaultOn must be a boolean/);
|
||||
});
|
||||
|
||||
it("upgrades v1 graphs without optionalSteps", () => {
|
||||
const parsed = parseWorkflowIr({
|
||||
version: "v1",
|
||||
@@ -196,7 +199,7 @@ describe("parseWorkflowIr — optionalSteps", () => {
|
||||
});
|
||||
expect(parsed.version).toBe("v2");
|
||||
if (parsed.version !== "v2") throw new Error("expected v2");
|
||||
expect(parsed.optionalSteps).toBeUndefined();
|
||||
expect((parsed as { optionalSteps?: unknown }).optionalSteps).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { BUILTIN_CODING_WORKFLOW_IR } from "../builtin-coding-workflow-ir.js";
|
||||
import { BUILTIN_STEPWISE_CODING_WORKFLOW_IR } from "../builtin-stepwise-coding-workflow-ir.js";
|
||||
import { resolveWorkflowOptionalSteps } from "../workflow-optional-steps.js";
|
||||
import type { WorkflowIr, WorkflowIrV2 } from "../workflow-ir-types.js";
|
||||
import {
|
||||
resolveDefaultOnOptionalGroupIds,
|
||||
resolveWorkflowOptionalSteps,
|
||||
} from "../workflow-optional-steps.js";
|
||||
import type {
|
||||
WorkflowIr,
|
||||
WorkflowIrNode,
|
||||
WorkflowIrV2,
|
||||
WorkflowOptionalGroupConfig,
|
||||
} from "../workflow-ir-types.js";
|
||||
|
||||
const v1: WorkflowIr = {
|
||||
version: "v1",
|
||||
@@ -14,105 +22,125 @@ const v1: WorkflowIr = {
|
||||
edges: [{ from: "start", to: "end" }],
|
||||
};
|
||||
|
||||
function v2(optionalSteps?: WorkflowIrV2["optionalSteps"]): WorkflowIrV2 {
|
||||
/** Build an optional-group node with a trivial single-prompt template. */
|
||||
function optionalGroupNode(
|
||||
id: string,
|
||||
config: Partial<WorkflowOptionalGroupConfig>,
|
||||
): WorkflowIrNode {
|
||||
return {
|
||||
id,
|
||||
kind: "optional-group",
|
||||
column: "todo",
|
||||
config: {
|
||||
...config,
|
||||
template: config.template ?? {
|
||||
nodes: [{ id: `${id}-inner`, kind: "prompt" }],
|
||||
edges: [],
|
||||
},
|
||||
} satisfies WorkflowOptionalGroupConfig,
|
||||
};
|
||||
}
|
||||
|
||||
function v2(extraNodes: WorkflowIrNode[] = []): WorkflowIrV2 {
|
||||
return {
|
||||
version: "v2",
|
||||
name: "optional",
|
||||
columns: [{ id: "todo", name: "Todo", traits: [] }],
|
||||
nodes: [
|
||||
{ id: "start", kind: "start", column: "todo" },
|
||||
...extraNodes,
|
||||
{ id: "end", kind: "end", column: "todo" },
|
||||
],
|
||||
edges: [{ from: "start", to: "end" }],
|
||||
optionalSteps,
|
||||
};
|
||||
}
|
||||
|
||||
describe("resolveWorkflowOptionalSteps", () => {
|
||||
it("resolves the builtin coding browser verification optional step", () => {
|
||||
expect(resolveWorkflowOptionalSteps(BUILTIN_CODING_WORKFLOW_IR)).toEqual([
|
||||
describe("resolveWorkflowOptionalSteps (optional-group nodes)", () => {
|
||||
it("resolves two optional-group nodes with names + defaultOn from node config", () => {
|
||||
const ir = v2([
|
||||
optionalGroupNode("og-browser", { name: "Browser Verification", defaultOn: false }),
|
||||
optionalGroupNode("og-security", { name: "Security Audit", defaultOn: true }),
|
||||
]);
|
||||
|
||||
expect(resolveWorkflowOptionalSteps(ir)).toEqual([
|
||||
{
|
||||
templateId: "browser-verification",
|
||||
templateId: "og-browser",
|
||||
name: "Browser Verification",
|
||||
description: "Verify web application functionality using browser automation",
|
||||
icon: "globe",
|
||||
description: "",
|
||||
phase: "pre-merge",
|
||||
defaultOn: false,
|
||||
},
|
||||
{
|
||||
templateId: "og-security",
|
||||
name: "Security Audit",
|
||||
description: "",
|
||||
phase: "pre-merge",
|
||||
defaultOn: true,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("resolves the builtin stepwise-coding browser verification optional step", () => {
|
||||
expect(resolveWorkflowOptionalSteps(BUILTIN_STEPWISE_CODING_WORKFLOW_IR)).toEqual([
|
||||
{
|
||||
templateId: "browser-verification",
|
||||
name: "Browser Verification",
|
||||
description: "Verify web application functionality using browser automation",
|
||||
icon: "globe",
|
||||
phase: "pre-merge",
|
||||
defaultOn: false,
|
||||
},
|
||||
]);
|
||||
it("falls back to the node id when the group config omits a name", () => {
|
||||
const ir = v2([optionalGroupNode("og-unnamed", { defaultOn: true })]);
|
||||
const [resolved] = resolveWorkflowOptionalSteps(ir);
|
||||
expect(resolved.templateId).toBe("og-unnamed");
|
||||
expect(resolved.name).toBe("og-unnamed");
|
||||
expect(resolved.defaultOn).toBe(true);
|
||||
});
|
||||
|
||||
it("places a single workflow-step seam node between steps and review in stepwise", () => {
|
||||
const ir = BUILTIN_STEPWISE_CODING_WORKFLOW_IR;
|
||||
if (ir.version !== "v2") throw new Error("expected v2");
|
||||
const seamNodes = ir.nodes.filter(
|
||||
(n) => n.kind === "prompt" && n.config?.seam === "workflow-step",
|
||||
);
|
||||
expect(seamNodes).toHaveLength(1);
|
||||
// success path: steps -> workflow-step -> review
|
||||
expect(ir.edges).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ from: "steps", to: "workflow-step", condition: "success" }),
|
||||
expect.objectContaining({ from: "workflow-step", to: "review", condition: "success" }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("skips unknown template ids", () => {
|
||||
expect(
|
||||
resolveWorkflowOptionalSteps(v2([
|
||||
{ templateId: "missing" },
|
||||
{ templateId: "browser-verification" },
|
||||
])),
|
||||
).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("returns an empty array for v1 and v2 workflows without optional steps", () => {
|
||||
it("returns an empty array for v1 and v2 workflows without optional-group nodes", () => {
|
||||
expect(resolveWorkflowOptionalSteps(v1)).toEqual([]);
|
||||
expect(resolveWorkflowOptionalSteps(v2())).toEqual([]);
|
||||
});
|
||||
|
||||
it("preserves declaration order and resolves plugin templates", () => {
|
||||
const result = resolveWorkflowOptionalSteps(
|
||||
v2([
|
||||
{ templateId: "plugin:demo:first", defaultOn: true },
|
||||
{ templateId: "browser-verification" },
|
||||
]),
|
||||
[
|
||||
{
|
||||
id: "plugin:demo:first",
|
||||
name: "Plugin First",
|
||||
description: "Plugin optional verification",
|
||||
prompt: "Run plugin verification",
|
||||
category: "Quality",
|
||||
icon: "plug",
|
||||
phase: "post-merge",
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
expect(result.map((step) => step.templateId)).toEqual([
|
||||
"plugin:demo:first",
|
||||
"browser-verification",
|
||||
it("ignores a malformed (config-less) optional-group node without crashing", () => {
|
||||
// A stale/partial optional-group node must not throw; it resolves to a
|
||||
// defaultOn:false entry keyed by its id rather than breaking workflow loading.
|
||||
const ir = v2([{ id: "og-bare", kind: "optional-group", column: "todo" }]);
|
||||
expect(resolveWorkflowOptionalSteps(ir)).toEqual([
|
||||
{
|
||||
templateId: "og-bare",
|
||||
name: "og-bare",
|
||||
description: "",
|
||||
phase: "pre-merge",
|
||||
defaultOn: false,
|
||||
},
|
||||
]);
|
||||
expect(result[0]).toMatchObject({
|
||||
name: "Plugin First",
|
||||
icon: "plug",
|
||||
phase: "post-merge",
|
||||
defaultOn: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves the built-in coding/stepwise browser-verification optional-group (U6)", () => {
|
||||
// U6 migrated both built-ins: `browser-verification` is now an optional-group
|
||||
// node (default OFF), so the resolver advertises exactly one toggle entry per
|
||||
// built-in, keyed by the group node id `browser-verification`.
|
||||
const expected = [
|
||||
{
|
||||
templateId: "browser-verification",
|
||||
name: "Browser Verification",
|
||||
description: "",
|
||||
phase: "pre-merge" as const,
|
||||
defaultOn: false,
|
||||
},
|
||||
];
|
||||
expect(resolveWorkflowOptionalSteps(BUILTIN_CODING_WORKFLOW_IR)).toEqual(expected);
|
||||
expect(resolveWorkflowOptionalSteps(BUILTIN_STEPWISE_CODING_WORKFLOW_IR)).toEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveDefaultOnOptionalGroupIds (task-creation seeding)", () => {
|
||||
it("returns exactly the defaultOn:true group ids", () => {
|
||||
const ir = v2([
|
||||
optionalGroupNode("og-off", { defaultOn: false }),
|
||||
optionalGroupNode("og-on-a", { defaultOn: true }),
|
||||
optionalGroupNode("og-on-b", { defaultOn: true }),
|
||||
]);
|
||||
expect(resolveDefaultOnOptionalGroupIds(ir)).toEqual(["og-on-a", "og-on-b"]);
|
||||
});
|
||||
|
||||
it("seeds an empty set when no optional-group has defaultOn (or none exist)", () => {
|
||||
expect(resolveDefaultOnOptionalGroupIds(v2())).toEqual([]);
|
||||
expect(
|
||||
resolveDefaultOnOptionalGroupIds(v2([optionalGroupNode("og-off", { defaultOn: false })])),
|
||||
).toEqual([]);
|
||||
expect(resolveDefaultOnOptionalGroupIds(v1)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -176,6 +176,130 @@ describe("TaskStore workflow selection (U3)", () => {
|
||||
expect(store.getTaskWorkflowSelection(task.id)?.workflowId).toBe(wf.id);
|
||||
});
|
||||
|
||||
// FNXC:WorkflowOptionalGroup 2026-06-21-14:30: a new task seeds
|
||||
// `enabledWorkflowSteps` with exactly the defaultOn:true optional-group ids of
|
||||
// its selected workflow (U3, R3), alongside the compiled workflow step ids.
|
||||
describe("optional-group defaultOn seeding (U3/R3)", () => {
|
||||
/** v2 workflow whose success path threads through two optional-group nodes. */
|
||||
function optionalGroupIr(): WorkflowIr {
|
||||
const groupTemplate = (id: string) => ({
|
||||
nodes: [{ id: `${id}-inner`, kind: "prompt" as const, config: { prompt: "x" } }],
|
||||
edges: [],
|
||||
});
|
||||
return {
|
||||
version: "v2",
|
||||
name: "og-wf",
|
||||
columns: [{ id: "todo", name: "Todo", traits: [] }],
|
||||
nodes: [
|
||||
{ id: "start", kind: "start", column: "todo" },
|
||||
{
|
||||
id: "og-on",
|
||||
kind: "optional-group",
|
||||
column: "todo",
|
||||
config: { name: "On Group", defaultOn: true, template: groupTemplate("og-on") },
|
||||
},
|
||||
{
|
||||
id: "og-off",
|
||||
kind: "optional-group",
|
||||
column: "todo",
|
||||
config: { name: "Off Group", defaultOn: false, template: groupTemplate("og-off") },
|
||||
},
|
||||
{ id: "end", kind: "end", column: "todo" },
|
||||
],
|
||||
edges: [
|
||||
{ from: "start", to: "og-on", condition: "success" },
|
||||
{ from: "og-on", to: "og-off", condition: "success" },
|
||||
{ from: "og-off", to: "end", condition: "success" },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
it("seeds the defaultOn:true group id at creation from the default workflow", async () => {
|
||||
const wf = await store.createWorkflowDefinition({ name: "OG Default", ir: optionalGroupIr() });
|
||||
await store.setDefaultWorkflowId(wf.id);
|
||||
|
||||
const task = await store.createTask({ description: "seeded" });
|
||||
const detail = await store.getTask(task.id);
|
||||
expect(detail.enabledWorkflowSteps).toContain("og-on");
|
||||
expect(detail.enabledWorkflowSteps).not.toContain("og-off");
|
||||
});
|
||||
|
||||
it("seeds an empty set when the workflow has no optional groups", async () => {
|
||||
const wf = await store.createWorkflowDefinition({ name: "No OG", ir: linearIr() });
|
||||
await store.setDefaultWorkflowId(wf.id);
|
||||
|
||||
const task = await store.createTask({ description: "no groups" });
|
||||
const detail = await store.getTask(task.id);
|
||||
expect(detail.enabledWorkflowSteps ?? []).not.toContain("og-on");
|
||||
});
|
||||
|
||||
it("a stale optional-group id in enabledWorkflowSteps does not crash resolution", async () => {
|
||||
// Group since removed from the workflow: the toggle resolver ignores the
|
||||
// stale id rather than throwing, keeping create/edit surfaces alive.
|
||||
const task = await store.createTask({
|
||||
description: "stale",
|
||||
enabledWorkflowSteps: ["og-removed"],
|
||||
});
|
||||
const detail = await store.getTask(task.id);
|
||||
expect(detail.enabledWorkflowSteps).toContain("og-removed");
|
||||
});
|
||||
|
||||
// FNXC:WorkflowOptionalGroup 2026-06-21-16:30: code-review P1 regression. A
|
||||
// built-in optional-group id deliberately equals a WORKFLOW_STEP_TEMPLATES id
|
||||
// (the browser-verification migration). Enabling it on a task must keep the
|
||||
// RAW group node id in enabledWorkflowSteps — not a materialized WorkflowStep
|
||||
// row id — or the executor's `enabledWorkflowSteps.includes(node.id)` check
|
||||
// silently bypasses the group. (The og-on/og-off ids above don't collide, so
|
||||
// only a colliding id exercises the remap bug.)
|
||||
function collidingGroupIr(): WorkflowIr {
|
||||
return {
|
||||
version: "v2",
|
||||
name: "bv-wf",
|
||||
columns: [{ id: "todo", name: "Todo", traits: [] }],
|
||||
nodes: [
|
||||
{ id: "start", kind: "start", column: "todo" },
|
||||
{
|
||||
id: "browser-verification",
|
||||
kind: "optional-group",
|
||||
column: "todo",
|
||||
config: {
|
||||
name: "Browser Verification",
|
||||
defaultOn: false,
|
||||
template: { nodes: [{ id: "bv-inner", kind: "prompt", config: { prompt: "verify" } }], edges: [] },
|
||||
},
|
||||
},
|
||||
{ id: "end", kind: "end", column: "todo" },
|
||||
],
|
||||
edges: [
|
||||
{ from: "start", to: "browser-verification", condition: "success" },
|
||||
{ from: "browser-verification", to: "end", condition: "success" },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
it("keeps a built-in-colliding optional-group id unremapped on create-with-enable", async () => {
|
||||
const wf = await store.createWorkflowDefinition({ name: "BV", ir: collidingGroupIr() });
|
||||
await store.setDefaultWorkflowId(wf.id);
|
||||
|
||||
const task = await store.createTask({
|
||||
description: "enable bv",
|
||||
enabledWorkflowSteps: ["browser-verification"],
|
||||
});
|
||||
const detail = await store.getTask(task.id);
|
||||
expect(detail.enabledWorkflowSteps).toContain("browser-verification");
|
||||
});
|
||||
|
||||
it("keeps a built-in-colliding optional-group id unremapped on update/toggle", async () => {
|
||||
const wf = await store.createWorkflowDefinition({ name: "BV", ir: collidingGroupIr() });
|
||||
await store.setDefaultWorkflowId(wf.id);
|
||||
|
||||
const task = await store.createTask({ description: "toggle bv" });
|
||||
await store.updateTask(task.id, { enabledWorkflowSteps: ["browser-verification"] });
|
||||
const detail = await store.getTask(task.id);
|
||||
expect(detail.enabledWorkflowSteps).toContain("browser-verification");
|
||||
});
|
||||
});
|
||||
|
||||
it("explicit enabledWorkflowSteps overrides the project default", async () => {
|
||||
const wf = await store.createWorkflowDefinition({ name: "Default", ir: linearIr() });
|
||||
await store.setDefaultWorkflowId(wf.id);
|
||||
|
||||
79
packages/core/src/builtin-browser-verification-group.ts
Normal file
79
packages/core/src/builtin-browser-verification-group.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import type { WorkflowIrNode } from "./workflow-ir-types.js";
|
||||
import { WORKFLOW_STEP_TEMPLATES } from "./types.js";
|
||||
|
||||
/*
|
||||
FNXC:WorkflowOptionalGroup 2026-06-21-15:10:
|
||||
Both the built-in coding and stepwise-coding workflows express the optional
|
||||
`browser-verification` step as an `optional-group` container node on the pre-merge
|
||||
path (default OFF), REPLACING the legacy `optionalSteps: [{ templateId:
|
||||
"browser-verification" }]` declaration + the hidden `workflow-step` seam node (U6).
|
||||
Enabled (task's `enabledWorkflowSteps` includes the group id) → the browser-
|
||||
verification step runs ONCE pre-merge between implementation and review. Disabled →
|
||||
the group passes through (byte-inert), exactly preserving the prior runtime behavior
|
||||
where the step only ran when toggled on.
|
||||
|
||||
The group node id `browser-verification` is the STABLE per-task enable key (KTD-2):
|
||||
keeping it identical to the prior `optionalSteps` templateId preserves any persisted
|
||||
`enabledWorkflowSteps` entry. The inner template node carries a DISTINCT id
|
||||
(`browser-verification-step`) because a template node id may not collide with the
|
||||
group/top-level node id (U1 validation).
|
||||
|
||||
The inner node mirrors the dashboard's `stepTemplateToNode` projection of the
|
||||
canonical `browser-verification` WORKFLOW_STEP_TEMPLATE: a `prompt` node carrying the
|
||||
template's prompt, `toolMode` (coding), and `gateMode` (advisory default). Sourcing
|
||||
prompt/toolMode from the catalog keeps the built-in byte-identical to the template a
|
||||
human would insert from the palette (KTD-5).
|
||||
*/
|
||||
|
||||
function resolveBrowserVerificationTemplate() {
|
||||
const tpl = WORKFLOW_STEP_TEMPLATES.find((t) => t.id === "browser-verification");
|
||||
if (!tpl) {
|
||||
throw new Error("browser-verification WORKFLOW_STEP_TEMPLATE is missing");
|
||||
}
|
||||
return tpl;
|
||||
}
|
||||
|
||||
const BROWSER_VERIFICATION_TEMPLATE = resolveBrowserVerificationTemplate();
|
||||
|
||||
/** Stable per-task enable key + group node id (preserved from the prior templateId). */
|
||||
export const BROWSER_VERIFICATION_GROUP_ID = "browser-verification";
|
||||
|
||||
/** Inner template node id — distinct from the group id (template-node-id collision rule, U1). */
|
||||
export const BROWSER_VERIFICATION_STEP_NODE_ID = "browser-verification-step";
|
||||
|
||||
/**
|
||||
* Build the `browser-verification` optional-group node placed on a workflow's
|
||||
* pre-merge path. `column` matches where the legacy `workflow-step` seam sat
|
||||
* (in-progress) so the editor renders the group in the implementation column.
|
||||
*
|
||||
* Mirrors `stepTemplateToNode(browser-verification)`: a single `prompt` node whose
|
||||
* config carries the catalog prompt + `toolMode: "coding"` + `gateMode: "advisory"`.
|
||||
*/
|
||||
export function browserVerificationOptionalGroupNode(column: string): WorkflowIrNode {
|
||||
const tpl = BROWSER_VERIFICATION_TEMPLATE;
|
||||
return {
|
||||
id: BROWSER_VERIFICATION_GROUP_ID,
|
||||
kind: "optional-group",
|
||||
column,
|
||||
config: {
|
||||
name: tpl.name,
|
||||
defaultOn: false,
|
||||
template: {
|
||||
nodes: [
|
||||
{
|
||||
id: BROWSER_VERIFICATION_STEP_NODE_ID,
|
||||
kind: "prompt",
|
||||
config: {
|
||||
name: tpl.name,
|
||||
description: tpl.description,
|
||||
prompt: tpl.prompt ?? "",
|
||||
toolMode: tpl.toolMode === "coding" ? "coding" : "readonly",
|
||||
gateMode: tpl.gateMode ?? "advisory",
|
||||
},
|
||||
},
|
||||
],
|
||||
edges: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import type { WorkflowIr } from "./workflow-ir-types.js";
|
||||
import { parseWorkflowIr } from "./workflow-ir.js";
|
||||
import { BUILTIN_WORKFLOW_SETTINGS } from "./builtin-workflow-settings.js";
|
||||
import { builtinPromptConfig } from "./builtin-workflow-prompts.js";
|
||||
import { browserVerificationOptionalGroupNode } from "./builtin-browser-verification-group.js";
|
||||
|
||||
/**
|
||||
* The built-in default workflow as a v2 IR. Its six columns have ids that are
|
||||
@@ -20,9 +21,16 @@ import { builtinPromptConfig } from "./builtin-workflow-prompts.js";
|
||||
*
|
||||
* The lifecycle seam nodes are placed in their columns. Planning is explicit so
|
||||
* the built-in workflow owns the specification phase rather than relying on
|
||||
* triage code that runs outside the graph; workflow-step keeps the legacy
|
||||
* pre-merge quality gate between implementation and review; execute/review/
|
||||
* merge keep the same observable pipeline and failure routing.
|
||||
* triage code that runs outside the graph; execute/review/merge keep the same
|
||||
* observable pipeline and failure routing.
|
||||
*
|
||||
* FNXC:WorkflowOptionalGroup 2026-06-21-15:10:
|
||||
* The pre-merge optional `browser-verification` step is now an `optional-group`
|
||||
* container node (default OFF) sitting on the success path between execute and
|
||||
* review — REPLACING the legacy `workflow-step` seam node + the execution-inert
|
||||
* `optionalSteps: [{ templateId: "browser-verification" }]` declaration (U6). A
|
||||
* task whose `enabledWorkflowSteps` includes the group id runs browser
|
||||
* verification pre-merge exactly as before; a task with it off bypasses it.
|
||||
*/
|
||||
const RAW_BUILTIN_CODING_WORKFLOW_IR: WorkflowIr = {
|
||||
version: "v2",
|
||||
@@ -65,12 +73,8 @@ const RAW_BUILTIN_CODING_WORKFLOW_IR: WorkflowIr = {
|
||||
column: "in-progress",
|
||||
config: { ...builtinPromptConfig("execute", "Execute"), maxRetries: 2 },
|
||||
},
|
||||
{
|
||||
id: "workflow-step",
|
||||
kind: "prompt",
|
||||
column: "in-progress",
|
||||
config: builtinPromptConfig("workflow-step", "Pre-merge workflow steps"),
|
||||
},
|
||||
// Pre-merge optional browser-verification (optional-group, default OFF).
|
||||
browserVerificationOptionalGroupNode("in-progress"),
|
||||
{ id: "review", kind: "prompt", column: "in-review", config: builtinPromptConfig("review", "Review") },
|
||||
{ id: "merge-gate", kind: "merge-gate", column: "in-review", config: { gate: "auto-merge" } },
|
||||
{ id: "merge-retry", kind: "retry-backoff", column: "in-review", config: { policy: "merge", maxAttempts: 3 } },
|
||||
@@ -94,10 +98,10 @@ const RAW_BUILTIN_CODING_WORKFLOW_IR: WorkflowIr = {
|
||||
edges: [
|
||||
{ from: "start", to: "planning" },
|
||||
{ from: "planning", to: "execute", condition: "success" },
|
||||
{ from: "execute", to: "workflow-step", condition: "success" },
|
||||
{ from: "workflow-step", to: "review", condition: "success" },
|
||||
{ from: "workflow-step", to: "end", condition: "outcome:remediation-scheduled" },
|
||||
{ from: "workflow-step", to: "end", condition: "outcome:deferred-paused" },
|
||||
// execute → browser-verification (optional-group) → review. When the group is
|
||||
// disabled it passes through with outcome=success and routes straight to review.
|
||||
{ from: "execute", to: "browser-verification", condition: "success" },
|
||||
{ from: "browser-verification", to: "review", condition: "success" },
|
||||
{ from: "review", to: "merge-gate", condition: "success" },
|
||||
{ from: "merge-gate", to: "branch-group-member-integration", condition: "outcome:auto-on" },
|
||||
{ from: "merge-gate", to: "merge-manual-hold", condition: "outcome:auto-off" },
|
||||
@@ -113,14 +117,13 @@ const RAW_BUILTIN_CODING_WORKFLOW_IR: WorkflowIr = {
|
||||
{ from: "recovery-router", to: "merge-attempt", condition: "outcome:wake-merge", kind: "rework" },
|
||||
{ from: "planning", to: "end", condition: "failure" },
|
||||
{ from: "execute", to: "end", condition: "failure" },
|
||||
{ from: "workflow-step", to: "end", condition: "failure" },
|
||||
{ from: "browser-verification", to: "end", condition: "failure" },
|
||||
{ from: "review", to: "end", condition: "failure" },
|
||||
{ from: "merge-attempt", to: "end", condition: "failure" },
|
||||
],
|
||||
// Workflow-settings (U1, R4): declare the full moved-key catalog with defaults
|
||||
// byte-equal to today's DEFAULT_PROJECT_SETTINGS literals. Inert until U3.
|
||||
settings: BUILTIN_WORKFLOW_SETTINGS,
|
||||
optionalSteps: [{ templateId: "browser-verification" }],
|
||||
};
|
||||
|
||||
export const BUILTIN_CODING_WORKFLOW_IR = parseWorkflowIr(RAW_BUILTIN_CODING_WORKFLOW_IR);
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { WorkflowIr } from "./workflow-ir-types.js";
|
||||
import { parseWorkflowIr } from "./workflow-ir.js";
|
||||
import { BUILTIN_WORKFLOW_SETTINGS } from "./builtin-workflow-settings.js";
|
||||
import { builtinPromptConfig } from "./builtin-workflow-prompts.js";
|
||||
import { browserVerificationOptionalGroupNode } from "./builtin-browser-verification-group.js";
|
||||
|
||||
/**
|
||||
* The built-in **stepwise** coding workflow (KTD-9) — the demonstration of step
|
||||
@@ -123,16 +124,16 @@ const RAW_BUILTIN_STEPWISE_CODING_WORKFLOW_IR: WorkflowIr = {
|
||||
},
|
||||
// KTD-5: rework exhaustion escalates to a manual hold (a human releases it).
|
||||
{ id: "rework-hold", kind: "hold", column: "in-progress", config: { release: "manual" } },
|
||||
// FNXC:WorkflowOptionalSteps 2026-06-21-00:00:
|
||||
// The stepwise workflow must actually run a task's enabled optional steps (e.g.
|
||||
// browser verification), so it needs the same pre-merge workflow-step seam the
|
||||
// coding workflow has — declaring the optional step without this node would be a
|
||||
// dead toggle. Pre-merge workflow-step seam (parity with builtin-coding-workflow-ir):
|
||||
// the ONLY node that makes the graph invoke `runWorkflowSteps`, so a per-task
|
||||
// `enabledWorkflowSteps` (e.g. the optional browser-verification step declared
|
||||
// below) actually executes. Runs ONCE after the foreach completes, between
|
||||
// implementation and review — not per step-instance.
|
||||
{ id: "workflow-step", kind: "prompt", column: "in-progress", config: builtinPromptConfig("workflow-step", "Pre-merge workflow steps") },
|
||||
// FNXC:WorkflowOptionalGroup 2026-06-21-15:10:
|
||||
// Pre-merge optional browser-verification as an `optional-group` container
|
||||
// (default OFF), parity with builtin-coding-workflow-ir (U6). It REPLACES the
|
||||
// prior `workflow-step` seam + `optionalSteps` declaration. R-3 run-once
|
||||
// guarantee: the group sits on the post-foreach success path (steps → here →
|
||||
// review), so when enabled the browser-verification step runs EXACTLY ONCE
|
||||
// after every step-instance completes — never per step-instance — and when
|
||||
// disabled the group passes through inert. Both the normal foreach-success path
|
||||
// and the rework-exhausted manual-release path flow through this node.
|
||||
browserVerificationOptionalGroupNode("in-progress"),
|
||||
{ id: "review", kind: "prompt", column: "in-review", config: builtinPromptConfig("review", "Review") },
|
||||
{ id: "merge-gate", kind: "merge-gate", column: "in-review", config: { gate: "auto-merge" } },
|
||||
{ id: "merge-retry", kind: "retry-backoff", column: "in-review", config: { policy: "merge", maxAttempts: 3 } },
|
||||
@@ -163,17 +164,16 @@ const RAW_BUILTIN_STEPWISE_CODING_WORKFLOW_IR: WorkflowIr = {
|
||||
{ from: "parse", to: "steps", condition: "outcome:no-steps" },
|
||||
{ from: "parse", to: "end", condition: "failure" },
|
||||
{ from: "parse", to: "end", condition: "outcome:parse-error" },
|
||||
// Implementation complete → pre-merge workflow-step seam → review. Both the
|
||||
// normal foreach-success path and the rework-exhausted manual-release path flow
|
||||
// through the seam so enabled workflow steps run regardless of route.
|
||||
{ from: "steps", to: "workflow-step", condition: "success" },
|
||||
// KTD-5: bounded rework exhaustion → manual hold; release re-enters the seam.
|
||||
// Implementation complete → pre-merge browser-verification optional-group →
|
||||
// review. Both the normal foreach-success path and the rework-exhausted
|
||||
// manual-release path flow through the group so an enabled task runs the step
|
||||
// ONCE after the foreach (R-3), and a disabled task passes through to review.
|
||||
{ from: "steps", to: "browser-verification", condition: "success" },
|
||||
// KTD-5: bounded rework exhaustion → manual hold; release re-enters the group.
|
||||
{ from: "steps", to: "rework-hold", condition: "outcome:rework-exhausted" },
|
||||
{ from: "rework-hold", to: "workflow-step", condition: "success" },
|
||||
{ from: "workflow-step", to: "review", condition: "success" },
|
||||
{ from: "workflow-step", to: "end", condition: "outcome:remediation-scheduled" },
|
||||
{ from: "workflow-step", to: "end", condition: "outcome:deferred-paused" },
|
||||
{ from: "workflow-step", to: "end", condition: "failure" },
|
||||
{ from: "rework-hold", to: "browser-verification", condition: "success" },
|
||||
{ from: "browser-verification", to: "review", condition: "success" },
|
||||
{ from: "browser-verification", to: "end", condition: "failure" },
|
||||
{ from: "steps", to: "end", condition: "failure" },
|
||||
{ from: "review", to: "merge-gate", condition: "success" },
|
||||
{ from: "review", to: "end", condition: "failure" },
|
||||
@@ -193,9 +193,6 @@ const RAW_BUILTIN_STEPWISE_CODING_WORKFLOW_IR: WorkflowIr = {
|
||||
],
|
||||
// Workflow-settings (U1, R4): same moved-key catalog as the default builtin.
|
||||
settings: BUILTIN_WORKFLOW_SETTINGS,
|
||||
// Optional browser-verification step, parity with builtin-coding-workflow-ir.
|
||||
// Default OFF; runnable because the workflow-step seam node above is present.
|
||||
optionalSteps: [{ templateId: "browser-verification" }],
|
||||
};
|
||||
|
||||
export const BUILTIN_STEPWISE_CODING_WORKFLOW_IR = parseWorkflowIr(
|
||||
|
||||
@@ -86,13 +86,13 @@ export type {
|
||||
WorkflowForeachConfig,
|
||||
WorkflowLoopConfig,
|
||||
WorkflowLoopExitCondition,
|
||||
WorkflowOptionalGroupConfig,
|
||||
WorkflowIrArtifact,
|
||||
WorkflowFieldDefinition,
|
||||
WorkflowFieldType,
|
||||
WorkflowFieldOption,
|
||||
WorkflowFieldRender,
|
||||
// Workflow-settings (U1): typed setting declaration IR types.
|
||||
WorkflowOptionalStep,
|
||||
WorkflowSettingDefinition,
|
||||
WorkflowSettingType,
|
||||
WorkflowSettingOption,
|
||||
@@ -119,7 +119,10 @@ export type {
|
||||
} from "./column-agent-resolver.js";
|
||||
export { BUILTIN_CODING_WORKFLOW_IR } from "./builtin-coding-workflow-ir.js";
|
||||
export { BUILTIN_MARKETING_WORKFLOW_IR } from "./builtin-marketing-workflow-ir.js";
|
||||
export { resolveWorkflowOptionalSteps } from "./workflow-optional-steps.js";
|
||||
export {
|
||||
resolveWorkflowOptionalSteps,
|
||||
resolveDefaultOnOptionalGroupIds,
|
||||
} from "./workflow-optional-steps.js";
|
||||
export type { ResolvedWorkflowOptionalStep } from "./workflow-optional-steps.js";
|
||||
export {
|
||||
applyPromptOverridesToIr,
|
||||
|
||||
@@ -86,6 +86,7 @@ import type {
|
||||
WorkflowNodeLayout,
|
||||
} from "./workflow-definition-types.js";
|
||||
import { compileWorkflowToSteps, isInterpreterDeferredWorkflowCompileError } from "./workflow-compiler.js";
|
||||
import { resolveDefaultOnOptionalGroupIds, resolveAllOptionalGroupIds } from "./workflow-optional-steps.js";
|
||||
import {
|
||||
BUILTIN_WORKFLOWS,
|
||||
getBuiltinWorkflow,
|
||||
@@ -4286,7 +4287,26 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS}
|
||||
});
|
||||
}
|
||||
|
||||
private async resolveEnabledWorkflowSteps(stepIds?: string[]): Promise<string[] | undefined> {
|
||||
/*
|
||||
FNXC:WorkflowOptionalGroup 2026-06-21-16:30:
|
||||
`optionalGroupIds` are the optional-group node ids of the task's workflow. They are executor toggle keys (matched by node id in `enabledWorkflowSteps`), NOT legacy `WorkflowStep` template ids. A built-in group id can deliberately collide with a `WORKFLOW_STEP_TEMPLATES` id (e.g. "browser-verification"); without this pass-through the colliding id is materialized into a step row whose id differs from the group node id, so the executor's `enabledWorkflowSteps.includes(node.id)` check fails and an enabled group is silently bypassed (P1 from code review). Editor-authored group ids never collide (they come from `newNodeId()`), so they already passed through; this guards the built-in collision.
|
||||
*/
|
||||
/** Optional-group node ids for a workflow (its `enabledWorkflowSteps` toggle
|
||||
* keys). Falls back to the project default workflow when `workflowId` is
|
||||
* nullish; empty for missing/fragment workflows. Used to keep group ids out of
|
||||
* the legacy step-template materialization in {@link resolveEnabledWorkflowSteps}. */
|
||||
private async optionalGroupIdSet(workflowId?: string | null): Promise<Set<string>> {
|
||||
const wfId = workflowId ?? (await this.getDefaultWorkflowId());
|
||||
if (!wfId) return new Set();
|
||||
const def = await this.getWorkflowDefinition(wfId);
|
||||
if (!def || def.kind === "fragment") return new Set();
|
||||
return new Set(resolveAllOptionalGroupIds(def.ir));
|
||||
}
|
||||
|
||||
private async resolveEnabledWorkflowSteps(
|
||||
stepIds?: string[],
|
||||
optionalGroupIds?: Set<string>,
|
||||
): Promise<string[] | undefined> {
|
||||
if (!stepIds?.length) return undefined;
|
||||
|
||||
const resolved: string[] = [];
|
||||
@@ -4304,7 +4324,10 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS}
|
||||
continue;
|
||||
}
|
||||
|
||||
const template = this.getBuiltInWorkflowTemplate(stepId);
|
||||
// Optional-group toggle ids pass through raw — never materialized as legacy step rows.
|
||||
const template = optionalGroupIds?.has(stepId)
|
||||
? undefined
|
||||
: this.getBuiltInWorkflowTemplate(stepId);
|
||||
const resolvedId = template
|
||||
? (await this.ensureWorkflowStepForTemplate(stepId)).id
|
||||
: stepId;
|
||||
@@ -4444,7 +4467,10 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS}
|
||||
|
||||
// Determine enabledWorkflowSteps: explicit input takes precedence, otherwise auto-apply default-on steps
|
||||
let resolvedWorkflowSteps: string[] | undefined = input.enabledWorkflowSteps?.length
|
||||
? await this.resolveEnabledWorkflowSteps(input.enabledWorkflowSteps)
|
||||
? await this.resolveEnabledWorkflowSteps(
|
||||
input.enabledWorkflowSteps,
|
||||
await this.optionalGroupIdSet(input.workflowId),
|
||||
)
|
||||
: undefined;
|
||||
|
||||
// When a project default workflow is configured, new tasks inherit it
|
||||
@@ -4639,7 +4665,10 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS}
|
||||
|
||||
const title = input.title?.trim() || undefined;
|
||||
let resolvedWorkflowSteps: string[] | undefined = input.enabledWorkflowSteps?.length
|
||||
? await this.resolveEnabledWorkflowSteps(input.enabledWorkflowSteps)
|
||||
? await this.resolveEnabledWorkflowSteps(
|
||||
input.enabledWorkflowSteps,
|
||||
await this.optionalGroupIdSet(input.workflowId),
|
||||
)
|
||||
: undefined;
|
||||
|
||||
let pendingWorkflowSelection: { workflowId: string; stepIds: string[] } | undefined;
|
||||
@@ -8712,7 +8741,14 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS}
|
||||
task.nextRecoveryAt = updates.nextRecoveryAt;
|
||||
}
|
||||
if (updates.enabledWorkflowSteps !== undefined) {
|
||||
task.enabledWorkflowSteps = await this.resolveEnabledWorkflowSteps(updates.enabledWorkflowSteps);
|
||||
// Pass the task's own workflow optional-group ids through untouched so a
|
||||
// toggled built-in group id (e.g. "browser-verification") is not remapped
|
||||
// to a materialized step row the executor never matches (code-review P1).
|
||||
const taskWorkflowId = this.getTaskWorkflowSelection(task.id)?.workflowId;
|
||||
task.enabledWorkflowSteps = await this.resolveEnabledWorkflowSteps(
|
||||
updates.enabledWorkflowSteps,
|
||||
await this.optionalGroupIdSet(taskWorkflowId),
|
||||
);
|
||||
}
|
||||
if (updates.noCommitsExpected === null) {
|
||||
task.noCommitsExpected = undefined;
|
||||
@@ -15952,11 +15988,17 @@ ${stepsSection}`;
|
||||
if (isBuiltinWorkflowId(workflowId) && isInterpreterDeferredWorkflowCompileError(err)) return undefined;
|
||||
throw err;
|
||||
}
|
||||
// FNXC:WorkflowOptionalGroup 2026-06-21-14:20: seed `enabledWorkflowSteps`
|
||||
// with the ids of `optional-group` nodes whose `defaultOn` is true, mirroring
|
||||
// the prior `optionalStep.defaultOn ?? false` precedence (U3, R3). These group
|
||||
// ids are NOT WorkflowStep rows — they are toggle keys the executor reads at
|
||||
// the optional-group seam — so they ride alongside the compiled step ids.
|
||||
const defaultGroupIds = resolveDefaultOnOptionalGroupIds(def.ir);
|
||||
if (isBuiltinWorkflowId(workflowId) && inputs.length === 0) {
|
||||
return { workflowId, stepIds: [] };
|
||||
return { workflowId, stepIds: defaultGroupIds };
|
||||
}
|
||||
const stepIds = await this.materializeWorkflowSteps(workflowId, inputs);
|
||||
return { workflowId, stepIds };
|
||||
return { workflowId, stepIds: [...stepIds, ...defaultGroupIds] };
|
||||
}
|
||||
|
||||
/** Resolve an EXPLICITLY requested workflow id (U6/R3/KTD-4) into materialized
|
||||
@@ -15977,11 +16019,15 @@ ${stepsSection}`;
|
||||
try {
|
||||
inputs = compileWorkflowToSteps(def.ir);
|
||||
} catch (err) {
|
||||
if (isBuiltinWorkflowId(workflowId) && isInterpreterDeferredWorkflowCompileError(err)) return { workflowId, stepIds: [] };
|
||||
if (isBuiltinWorkflowId(workflowId) && isInterpreterDeferredWorkflowCompileError(err))
|
||||
return { workflowId, stepIds: resolveDefaultOnOptionalGroupIds(def.ir) };
|
||||
throw err;
|
||||
}
|
||||
// FNXC:WorkflowOptionalGroup 2026-06-21-14:20: same defaultOn-group seeding as
|
||||
// the default-workflow path, for an explicitly requested create-time workflow.
|
||||
const defaultGroupIds = resolveDefaultOnOptionalGroupIds(def.ir);
|
||||
const stepIds = await this.materializeWorkflowSteps(workflowId, inputs);
|
||||
return { workflowId, stepIds };
|
||||
return { workflowId, stepIds: [...stepIds, ...defaultGroupIds] };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 {
|
||||
@@ -311,13 +332,10 @@ export interface WorkflowIrV1 {
|
||||
edges: WorkflowIrEdge[];
|
||||
}
|
||||
|
||||
/** Workflow-declared optional step backed by a workflow-step template.
|
||||
* Execution-inert: consumed by create/edit UI to seed per-task
|
||||
* `enabledWorkflowSteps`, never by the graph executor. Absent on legacy graphs. */
|
||||
export interface WorkflowOptionalStep {
|
||||
templateId: string;
|
||||
defaultOn?: boolean;
|
||||
}
|
||||
/*
|
||||
FNXC:WorkflowOptionalGroup 2026-06-21-18:00:
|
||||
Retired the legacy declaration-based optional-steps model. The `WorkflowOptionalStep` interface and the `WorkflowIrV2.optionalSteps` field are removed — optional steps are now graph-native `optional-group` NODES (see `WorkflowOptionalGroupConfig` above), resolved by `resolveWorkflowOptionalSteps`. A legacy persisted `optionalSteps` key on an old v2 row is TOLERATED at parse (ignored, not validated) so old rows still load as v2.
|
||||
*/
|
||||
|
||||
/** A v2 workflow IR graph: v1 plus workflow-defined columns and node placement.
|
||||
* Step-inversion adds optional `artifacts` (KTD-12) and `fields` (KTD-13)
|
||||
@@ -333,9 +351,6 @@ export interface WorkflowIrV2 {
|
||||
/** Workflow-settings (U1, R1): typed setting declarations. Additive; absent on
|
||||
* legacy graphs. Values persist per-`(workflowId, projectId)` (U2), not here. */
|
||||
settings?: WorkflowSettingDefinition[];
|
||||
/** Optional workflow-step templates tasks may independently enable/disable via
|
||||
* `enabledWorkflowSteps`. Execution-inert; the graph executor ignores this facet. */
|
||||
optionalSteps?: WorkflowOptionalStep[];
|
||||
}
|
||||
|
||||
/** Either IR version. v1 graphs upgrade to v2 on parse (see parseWorkflowIr). */
|
||||
|
||||
@@ -9,11 +9,11 @@ import type {
|
||||
WorkflowHoldRelease,
|
||||
WorkflowForeachConfig,
|
||||
WorkflowLoopConfig,
|
||||
WorkflowOptionalGroupConfig,
|
||||
WorkflowFieldDefinition,
|
||||
WorkflowFieldType,
|
||||
WorkflowSettingDefinition,
|
||||
WorkflowSettingType,
|
||||
WorkflowOptionalStep,
|
||||
} from "./workflow-ir-types.js";
|
||||
import { getWorkflowExtensionRegistry } from "./workflow-extension-registry.js";
|
||||
import type { WorkflowExtensionConfigField } from "./workflow-extension-types.js";
|
||||
@@ -602,6 +602,120 @@ 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<string>,
|
||||
columnIds: Set<string>,
|
||||
): void {
|
||||
const cfg = node.config as Partial<WorkflowOptionalGroupConfig> | 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`);
|
||||
}
|
||||
// FNXC:WorkflowOptionalGroup 2026-06-22-09:00: the single-pass walk
|
||||
// (runOptionalGroup) surfaces a template-node failure as the GROUP's outcome
|
||||
// and bails before evaluating that node's edges — so a `failure`-condition
|
||||
// edge inside the template would silently never execute. Reject it as a typed
|
||||
// authoring error; failure routing belongs on the group's OUTER edges.
|
||||
// (Code review: Greptile P2.)
|
||||
if (edge.condition === "failure") {
|
||||
throw new WorkflowIrError(
|
||||
`optional-group node '${node.id}' template may not contain failure-condition edges — ` +
|
||||
`a template-node failure surfaces as the group's outcome and routes the group's outer failure edge`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const incoming = new Map<string, number>();
|
||||
const outgoingCount = new Map<string, number>();
|
||||
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.) */
|
||||
@@ -1071,29 +1185,6 @@ function validateSettings(settings: WorkflowSettingDefinition[] | undefined): vo
|
||||
}
|
||||
}
|
||||
|
||||
function validateOptionalSteps(optionalSteps: WorkflowOptionalStep[] | undefined): void {
|
||||
if (optionalSteps === undefined) return;
|
||||
if (!Array.isArray(optionalSteps)) {
|
||||
throw new WorkflowIrError("Workflow IR optionalSteps must be an array");
|
||||
}
|
||||
for (const optionalStep of optionalSteps) {
|
||||
if (!optionalStep || typeof optionalStep !== "object" || Array.isArray(optionalStep)) {
|
||||
throw new WorkflowIrError("Workflow optional step must be an object");
|
||||
}
|
||||
if (typeof optionalStep.templateId !== "string" || optionalStep.templateId === "") {
|
||||
throw new WorkflowIrError("Workflow optional step must have a non-empty templateId");
|
||||
}
|
||||
if (
|
||||
optionalStep.defaultOn !== undefined &&
|
||||
typeof optionalStep.defaultOn !== "boolean"
|
||||
) {
|
||||
throw new WorkflowIrError(
|
||||
`Workflow optional step '${optionalStep.templateId}' defaultOn must be a boolean`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function validateColumns(ir: WorkflowIrV2): void {
|
||||
if (!Array.isArray(ir.columns)) {
|
||||
throw new WorkflowIrError("Workflow IR v2 columns must be an array");
|
||||
@@ -1265,6 +1356,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);
|
||||
@@ -1272,7 +1364,11 @@ function validateV2(ir: WorkflowIrV2): void {
|
||||
validateNotifyNodes(ir.nodes);
|
||||
validateFields(ir.fields);
|
||||
validateSettings(ir.settings);
|
||||
validateOptionalSteps(ir.optionalSteps);
|
||||
// FNXC:WorkflowOptionalGroup 2026-06-21-18:00:
|
||||
// The legacy `optionalSteps` declaration field is retired (optional steps are
|
||||
// now graph-native `optional-group` nodes). A legacy persisted `optionalSteps`
|
||||
// key on an old v2 row is TOLERATED — no longer validated/required — so old
|
||||
// rows still parse as v2.
|
||||
|
||||
// Rework edges are legal intra-template (foreach, KTD-5) and — since U6
|
||||
// generalized the bounded-rework mechanism to the top-level walk — for a
|
||||
@@ -1381,12 +1477,20 @@ export function downgradeIrToV1IfPure(ir: WorkflowIr): WorkflowIr {
|
||||
}
|
||||
|
||||
// Step-inversion declarations (artifacts/fields), workflow settings (U1), and
|
||||
// optional workflow-step declarations are v2-only features.
|
||||
// any legacy persisted optional-step declarations are v2-only features.
|
||||
// FNXC:WorkflowOptionalGroup 2026-06-21-18:00 (updated 2026-06-22-09:00):
|
||||
// `optionalSteps` is no longer a typed IR field (retired declaration model), but
|
||||
// a legacy v2 row may still carry the key. Read it via an untyped cast so such a
|
||||
// row is still treated as v2 (kept on v2, never silently downgraded). The mere
|
||||
// PRESENCE of the key — including an empty `[]` — is the v2 signal: an author
|
||||
// who wrote the key intended v2, and downgrading an `optionalSteps: []` row to
|
||||
// v1 would still mutate its persisted shape. (Code review: CodeRabbit.)
|
||||
const legacyOptionalSteps = (ir as { optionalSteps?: unknown }).optionalSteps;
|
||||
if (
|
||||
(ir.artifacts && ir.artifacts.length > 0) ||
|
||||
(ir.fields && ir.fields.length > 0) ||
|
||||
(ir.settings && ir.settings.length > 0) ||
|
||||
(ir.optionalSteps && ir.optionalSteps.length > 0)
|
||||
legacyOptionalSteps !== undefined
|
||||
) {
|
||||
return ir;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import type { WorkflowIr } from "./workflow-ir-types.js";
|
||||
import { WORKFLOW_STEP_TEMPLATES, type WorkflowStepTemplate } from "./types.js";
|
||||
import type {
|
||||
WorkflowIr,
|
||||
WorkflowIrNode,
|
||||
WorkflowOptionalGroupConfig,
|
||||
} from "./workflow-ir-types.js";
|
||||
import type { WorkflowStepTemplate } from "./types.js";
|
||||
|
||||
export interface ResolvedWorkflowOptionalStep {
|
||||
templateId: string;
|
||||
@@ -10,37 +14,74 @@ export interface ResolvedWorkflowOptionalStep {
|
||||
defaultOn: boolean;
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:WorkflowOptionalGroup 2026-06-21-14:05:
|
||||
Re-pointed the per-task optional-step toggle SOURCE from the execution-inert `ir.optionalSteps` declaration to v2 `optional-group` NODES (one resolved entry per group). The legacy `WorkflowOptionalStep` type + `optionalSteps` IR field are now REMOVED (FNXC:WorkflowOptionalGroup 2026-06-21-18:00); a legacy persisted `optionalSteps` key on an old v2 row is tolerated/ignored at parse.
|
||||
KEYING: the resolved entry is keyed by the group node `id`. The output field is still named `templateId` (not renamed) so the four consuming UI surfaces — inline quick-create card, New Task modal/TaskForm, task-detail Workflow tab, and the optional-steps dropdown — keep reading the same shape unchanged; they now toggle group ids into `enabledWorkflowSteps` instead of template ids. Renaming/recreating a group resets per-task state, identical to the prior `templateId` keying.
|
||||
Display metadata: `name` comes from `config.name` (falling back to the node id), `defaultOn` from `config.defaultOn ?? false`. The group node carries no description/icon/phase, so `description` is "" and `phase` defaults to "pre-merge" — keeping every field the consumers read populated and non-blank.
|
||||
*/
|
||||
|
||||
function isOptionalGroupNode(
|
||||
node: WorkflowIrNode,
|
||||
): node is WorkflowIrNode & { config: WorkflowOptionalGroupConfig } {
|
||||
return node.kind === "optional-group";
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve workflow-declared optional step template ids into display metadata.
|
||||
* Resolve a workflow's `optional-group` nodes into per-task toggle display
|
||||
* metadata. Each enabled group's node id is what a task stores in
|
||||
* `enabledWorkflowSteps`; this resolver advertises which groups a task may
|
||||
* toggle plus their seed default.
|
||||
*
|
||||
* The declaration is intentionally execution-inert: it only advertises which
|
||||
* template-backed workflow steps a task may toggle into `enabledWorkflowSteps`.
|
||||
* Unknown template ids are skipped so stale/custom declarations never render
|
||||
* blank UI rows or break workflow loading.
|
||||
* Source: v2 `ir.nodes` where `kind === "optional-group"` (NOT the legacy
|
||||
* `ir.optionalSteps` declaration). Non-v2 graphs and graphs without any
|
||||
* optional-group node resolve to `[]`. A group with a missing or partial config
|
||||
* still resolves to a usable entry — `name` falls back to the node id and
|
||||
* `defaultOn` to false — rather than being dropped, so a stale/partial node never
|
||||
* silently disappears from the toggle UI or breaks workflow loading.
|
||||
*
|
||||
* `pluginTemplates` is accepted for signature compatibility with the prior
|
||||
* template-backed resolver; group nodes are self-describing, so it is currently
|
||||
* unused.
|
||||
*/
|
||||
export function resolveWorkflowOptionalSteps(
|
||||
ir: WorkflowIr,
|
||||
pluginTemplates: WorkflowStepTemplate[] = [],
|
||||
_pluginTemplates: WorkflowStepTemplate[] = [],
|
||||
): ResolvedWorkflowOptionalStep[] {
|
||||
if (ir.version !== "v2" || !ir.optionalSteps?.length) return [];
|
||||
|
||||
const templates = new Map<string, WorkflowStepTemplate>();
|
||||
for (const template of [...WORKFLOW_STEP_TEMPLATES, ...pluginTemplates]) {
|
||||
templates.set(template.id, template);
|
||||
}
|
||||
if (ir.version !== "v2" || !Array.isArray(ir.nodes)) return [];
|
||||
|
||||
const resolved: ResolvedWorkflowOptionalStep[] = [];
|
||||
for (const optionalStep of ir.optionalSteps) {
|
||||
const template = templates.get(optionalStep.templateId);
|
||||
if (!template) continue;
|
||||
for (const node of ir.nodes) {
|
||||
if (!isOptionalGroupNode(node)) continue;
|
||||
const config = (node.config ?? {}) as Partial<WorkflowOptionalGroupConfig>;
|
||||
resolved.push({
|
||||
templateId: optionalStep.templateId,
|
||||
name: template.name,
|
||||
description: template.description,
|
||||
icon: template.icon,
|
||||
phase: template.phase ?? "pre-merge",
|
||||
defaultOn: optionalStep.defaultOn ?? template.defaultOn ?? false,
|
||||
// Keyed by the group node id (documented above); field name preserved.
|
||||
templateId: node.id,
|
||||
name: typeof config.name === "string" && config.name.trim() ? config.name : node.id,
|
||||
description: "",
|
||||
phase: "pre-merge",
|
||||
defaultOn: config.defaultOn === true,
|
||||
});
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ids of `optional-group` nodes whose effective `defaultOn` is true. Used to
|
||||
* seed a new task's `enabledWorkflowSteps` at creation, mirroring the prior
|
||||
* `optionalStep.defaultOn ?? false` precedence (U3, R3). Defensive: non-v2
|
||||
* graphs and graphs without optional groups yield `[]`.
|
||||
*/
|
||||
export function resolveDefaultOnOptionalGroupIds(ir: WorkflowIr): string[] {
|
||||
return resolveWorkflowOptionalSteps(ir)
|
||||
.filter((step) => step.defaultOn)
|
||||
.map((step) => step.templateId);
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:WorkflowOptionalGroup 2026-06-21-16:30:
|
||||
Every optional-group node id in a workflow, regardless of `defaultOn`. These ids are executor toggle keys (the per-task `enabledWorkflowSteps` set), NOT legacy `WorkflowStep` template ids. A built-in group id can deliberately equal a `WORKFLOW_STEP_TEMPLATES` id (e.g. "browser-verification"), so the store must pass these through `resolveEnabledWorkflowSteps` untouched instead of materializing them into a step row whose id the executor would never match.
|
||||
*/
|
||||
export function resolveAllOptionalGroupIds(ir: WorkflowIr): string[] {
|
||||
return resolveWorkflowOptionalSteps(ir).map((step) => step.templateId);
|
||||
}
|
||||
|
||||
@@ -1171,6 +1171,80 @@ Built-in workflow prompts need visible override state and a reset action without
|
||||
color: var(--ws-warning);
|
||||
}
|
||||
|
||||
/* ── Per-node Help (FNXC:WorkflowEditor 2026-06-21-10:00) ───────────
|
||||
* Collapsible <details> teaching what the selected node does, how to
|
||||
* configure it, and its inputs/outputs/edges. Sits under the heading,
|
||||
* collapsed by default so it never pushes config fields below the fold. */
|
||||
.wf-inspector-help {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.wf-inspector-help-summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
padding: var(--space-xs) var(--space-sm);
|
||||
font-size: 0.78rem;
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.wf-inspector-help-summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.wf-inspector-help-summary:hover {
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
/* Engine-managed badge for graph-only policy nodes (read-only lifecycle). */
|
||||
.wf-inspector-help-badge {
|
||||
margin-left: auto;
|
||||
padding: 1px var(--space-xs);
|
||||
font-size: 0.66rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
color: var(--text-dim);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.wf-inspector-help-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-xs);
|
||||
padding: 0 var(--space-sm) var(--space-sm);
|
||||
font-size: 0.76rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.wf-inspector-help-summary-text {
|
||||
margin: 0;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.wf-inspector-help-dl {
|
||||
display: grid;
|
||||
grid-template-columns: max-content 1fr;
|
||||
gap: 2px var(--space-sm);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.wf-inspector-help-dl dt {
|
||||
font-weight: 600;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.wf-inspector-help-dl dd {
|
||||
margin: 0;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.wf-field--checkbox {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
|
||||
@@ -16,8 +16,8 @@ import {
|
||||
} from "@xyflow/react";
|
||||
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, 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 { 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, WorkflowIrNodeKind } from "@fusion/core";
|
||||
import { getErrorMessage } from "@fusion/core";
|
||||
import {
|
||||
fetchWorkflows,
|
||||
@@ -56,6 +56,7 @@ import { isMobileViewport, useViewportMode } from "../hooks/useViewportMode";
|
||||
import { workflowNodeTypes, type WorkflowFlowNodeData, type WorkflowEditorNodeKind } from "./nodes/WorkflowNodeTypes";
|
||||
import { WorkflowEditorCatalogContext } from "./nodes/WorkflowEditorCatalogContext";
|
||||
import { bareSkillName, type NodeSummaryCatalogs } from "./nodes/node-summary";
|
||||
import { nodeHelpForData } from "./nodes/node-help";
|
||||
import {
|
||||
irToFlow,
|
||||
flowToIr,
|
||||
@@ -63,11 +64,11 @@ import {
|
||||
emptyWorkflowLayout,
|
||||
copyIrWithFreshIds,
|
||||
insertFragment,
|
||||
optionalGroupFragmentIr,
|
||||
fragmentSeamConflicts,
|
||||
columnsOf,
|
||||
fieldsOf,
|
||||
settingsOf,
|
||||
optionalStepsOf,
|
||||
columnsToBandNodes,
|
||||
reconcileNodeColumns,
|
||||
strictColumnForY,
|
||||
@@ -91,7 +92,6 @@ import { fetchTraits, fetchStepParsers, type TraitCatalogEntry } from "../api";
|
||||
import { WorkflowColumnPanel } from "./WorkflowColumnPanel";
|
||||
import { WorkflowFieldsPanel } from "./WorkflowFieldsPanel";
|
||||
import { WorkflowSettingsPanel } from "./WorkflowSettingsPanel";
|
||||
import { WorkflowOptionalStepsPanel } from "./WorkflowOptionalStepsPanel";
|
||||
import type { WorkflowFieldDefinition, WorkflowSettingDefinition } from "../api";
|
||||
import { CustomModelDropdown } from "./CustomModelDropdown";
|
||||
import { MobileWorkflowGraphView } from "./MobileWorkflowGraphView";
|
||||
@@ -103,7 +103,9 @@ import {
|
||||
} from "./workflow-mobile-graph";
|
||||
|
||||
type ExecutorKind = "model" | "agent" | "skill" | "cli" | "cli-agent";
|
||||
type MobileWorkflowPanel = "graph" | "add" | "settings" | "fields" | "optional-steps" | "columns" | "actions";
|
||||
// FNXC:WorkflowOptionalGroup 2026-06-21-18:00: dropped the "optional-steps" mobile
|
||||
// panel — the declaration authoring surface is retired (optional-group nodes now).
|
||||
type MobileWorkflowPanel = "graph" | "add" | "settings" | "fields" | "columns" | "actions";
|
||||
|
||||
function builtinSeamPrompt(config: Record<string, unknown> | undefined): string {
|
||||
const seam = typeof config?.seam === "string" ? config.seam : "";
|
||||
@@ -190,7 +192,6 @@ function serializeGraph(
|
||||
columns: WorkflowIrColumn[],
|
||||
fields: WorkflowFieldDefinition[],
|
||||
settings: WorkflowSettingDefinition[],
|
||||
optionalSteps: WorkflowOptionalStep[],
|
||||
): string {
|
||||
const { ir, layout } = flowToIr(
|
||||
name,
|
||||
@@ -199,7 +200,6 @@ function serializeGraph(
|
||||
columns.length ? columns : undefined,
|
||||
fields.length ? fields : undefined,
|
||||
settings.length ? settings : undefined,
|
||||
optionalSteps.length ? optionalSteps : undefined,
|
||||
);
|
||||
return JSON.stringify({ name, description, ir, layout });
|
||||
}
|
||||
@@ -269,6 +269,8 @@ const PALETTE: Array<{ kind: WorkflowEditorNodeKind; label: string; icon: typeof
|
||||
// Step-inversion (KTD-3/4/12/15).
|
||||
{ kind: "foreach", label: "For-each step", icon: Repeat, presetConfig: { source: "task-steps" } },
|
||||
{ kind: "loop", label: "Loop", icon: Repeat, presetConfig: { maxIterations: 3, exitWhen: { type: "output-contains", value: "DONE" } } },
|
||||
// FNXC:WorkflowOptionalGroup 2026-06-21-11:30: An optional-group container holds a template subgraph run once when the task enables it (per-task `enabledWorkflowSteps`, seeded from `defaultOn`) and skipped otherwise.
|
||||
{ kind: "optional-group", label: "Optional group", icon: ToggleRight, presetConfig: { defaultOn: false } },
|
||||
{ kind: "step-review", label: "Step review", icon: ClipboardCheck, presetConfig: { type: "code" } },
|
||||
{ kind: "parse-steps", label: "Parse steps", icon: ListChecks, presetConfig: { artifact: "PROMPT.md", parser: "step-headings" } },
|
||||
{ kind: "code", label: "Code", icon: Code2, presetConfig: { source: "" } },
|
||||
@@ -320,6 +322,7 @@ const USER_NODE_KINDS: ReadonlySet<WorkflowEditorNodeKind> = new Set<WorkflowEdi
|
||||
"join",
|
||||
"foreach",
|
||||
"loop",
|
||||
"optional-group",
|
||||
"step-review",
|
||||
"parse-steps",
|
||||
"notify",
|
||||
@@ -783,7 +786,10 @@ function InnerEditor({
|
||||
// VALUES live per-project in the workflow_settings table (KTD-2) and are
|
||||
// managed by the panel's Values tab, not this declaration array.
|
||||
const [settings, setSettings] = useState<WorkflowSettingDefinition[]>([]);
|
||||
const [optionalSteps, setOptionalSteps] = useState<WorkflowOptionalStep[]>([]);
|
||||
/* FNXC:WorkflowOptionalGroup 2026-06-21-18:00:
|
||||
The legacy optional-step DECLARATION authoring state/panel is removed. Optional
|
||||
steps are graph-native `optional-group` nodes authored through the canvas; the
|
||||
editor no longer carries a separate `optionalSteps` declaration array. */
|
||||
// FNXC:WorkflowEditor 2026-06-21-20:06:
|
||||
// Built-in workflow graph structure remains read-only, but prompt/gate node prompts need a separate per-project override state so editing prompts does not mark structural graph edits dirty or use the read-only workflow PATCH authority.
|
||||
const [promptOverrides, setPromptOverrides] = useState<WorkflowPromptOverridesPayload | null>(null);
|
||||
@@ -834,7 +840,6 @@ function InnerEditor({
|
||||
const columnsCollapsedStorageKey = "fusion:wf-sidebar-columns-collapsed";
|
||||
const fieldsCollapsedStorageKey = "fusion:wf-sidebar-fields-collapsed";
|
||||
const settingsCollapsedStorageKey = "fusion:wf-sidebar-settings-collapsed";
|
||||
const optionalStepsCollapsedStorageKey = "fusion:wf-sidebar-optional-steps-collapsed";
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState<boolean>(() => {
|
||||
try {
|
||||
return localStorage.getItem(sidebarCollapsedStorageKey) === "1";
|
||||
@@ -863,13 +868,6 @@ function InnerEditor({
|
||||
return false;
|
||||
}
|
||||
});
|
||||
const [optionalStepsCollapsed, setOptionalStepsCollapsed] = useState<boolean>(() => {
|
||||
try {
|
||||
return localStorage.getItem(optionalStepsCollapsedStorageKey) === "1";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
useEffect(() => {
|
||||
try {
|
||||
localStorage.setItem(sidebarCollapsedStorageKey, sidebarCollapsed ? "1" : "0");
|
||||
@@ -898,13 +896,6 @@ function InnerEditor({
|
||||
// localStorage unavailable (private mode / SSR): non-fatal.
|
||||
}
|
||||
}, [settingsCollapsed]);
|
||||
useEffect(() => {
|
||||
try {
|
||||
localStorage.setItem(optionalStepsCollapsedStorageKey, optionalStepsCollapsed ? "1" : "0");
|
||||
} catch {
|
||||
// localStorage unavailable (private mode / SSR): non-fatal.
|
||||
}
|
||||
}, [optionalStepsCollapsed]);
|
||||
// React Flow instance for programmatic viewport control (auto-layout on load).
|
||||
const { setViewport } = useReactFlow();
|
||||
// Wrapper around <ReactFlow> so keyboard deletion can return focus to the
|
||||
@@ -1092,10 +1083,10 @@ function InnerEditor({
|
||||
if (isBuiltin) return false;
|
||||
if (!activeWorkflow || loadedSnapshotRef.current === null) return false;
|
||||
return (
|
||||
serializeGraph(name, description, nodes, edges, columns, fields, settings, optionalSteps) !==
|
||||
serializeGraph(name, description, nodes, edges, columns, fields, settings) !==
|
||||
loadedSnapshotRef.current
|
||||
);
|
||||
}, [isBuiltin, activeWorkflow, name, description, nodes, edges, columns, fields, settings, optionalSteps]);
|
||||
}, [isBuiltin, activeWorkflow, name, description, nodes, edges, columns, fields, settings]);
|
||||
|
||||
const loadWorkflows = useCallback(async () => {
|
||||
setLoading(true);
|
||||
@@ -1243,7 +1234,6 @@ function InnerEditor({
|
||||
setColumns([]);
|
||||
setFields([]);
|
||||
setSettings([]);
|
||||
setOptionalSteps([]);
|
||||
setPromptOverrides(null);
|
||||
setPromptOverrideSavingNodeId(null);
|
||||
setName("");
|
||||
@@ -1255,7 +1245,6 @@ function InnerEditor({
|
||||
const loadedColumns = columnsOf(activeWorkflow);
|
||||
const loadedFields = fieldsOf(activeWorkflow);
|
||||
const loadedSettings = settingsOf(activeWorkflow);
|
||||
const loadedOptionalSteps = optionalStepsOf(activeWorkflow);
|
||||
// Auto-layout on load: compute tidy positions and apply them before the
|
||||
// first render so nodes are visible in the top-left viewport.
|
||||
const layoutPositions = autoLayout(flow.nodes, flow.edges, loadedColumns);
|
||||
@@ -1265,7 +1254,6 @@ function InnerEditor({
|
||||
setColumns(loadedColumns);
|
||||
setFields(loadedFields);
|
||||
setSettings(loadedSettings);
|
||||
setOptionalSteps(loadedOptionalSteps);
|
||||
setName(activeWorkflow.name);
|
||||
setDescription(activeWorkflow.description ?? "");
|
||||
setEditingName(false);
|
||||
@@ -1280,7 +1268,6 @@ function InnerEditor({
|
||||
loadedColumns,
|
||||
loadedFields,
|
||||
loadedSettings,
|
||||
loadedOptionalSteps,
|
||||
);
|
||||
setSelectedNodeId(null);
|
||||
setSelectedEdgeId(null);
|
||||
@@ -1455,16 +1442,19 @@ function InnerEditor({
|
||||
const baseConfig = kind === "gate" ? { gateMode: "gate" } : {};
|
||||
const config = presetConfig ? { ...baseConfig, ...presetConfig } : baseConfig;
|
||||
|
||||
if (kind === "foreach" || kind === "loop") {
|
||||
if (kind === "foreach" || kind === "loop" || kind === "optional-group") {
|
||||
// Template groups render as React Flow group nodes. Foreach seeds the
|
||||
// required step-execute seam; loop seeds a regular prompt so authors can
|
||||
// wire the repeated body immediately. The group node must precede its
|
||||
// child for React Flow's parent extent to apply.
|
||||
// required step-execute seam; loop + optional-group seed a regular prompt
|
||||
// so authors can wire the body immediately. The group node must precede
|
||||
// its child for React Flow's parent extent to apply.
|
||||
// FNXC:WorkflowOptionalGroup 2026-06-21-11:30: An optional-group is authored exactly like a foreach/loop region — drop nodes inside; the subgraph runs once when the task enables the group.
|
||||
const childId = foreachChildFlowId(id, newNodeId());
|
||||
const childLabel =
|
||||
kind === "foreach"
|
||||
? t("workflowNodes.stepExecuteLabel", "Step execute")
|
||||
: t("workflowNodes.loopStepLabel", "Loop step");
|
||||
: kind === "optional-group"
|
||||
? t("workflowNodes.optionalGroupStepLabel", "Optional step")
|
||||
: t("workflowNodes.loopStepLabel", "Loop step");
|
||||
const childConfig = kind === "foreach" ? { seam: "step-execute" } : { prompt: "" };
|
||||
setNodes((ns) => [
|
||||
...ns,
|
||||
@@ -1521,6 +1511,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
|
||||
@@ -1619,11 +1637,12 @@ function InnerEditor({
|
||||
setEdges(flow.edges);
|
||||
setColumns(columnsOf({ ...targetWorkflow, ir: result.ir }));
|
||||
setFields(fieldsOf({ ...targetWorkflow, ir: result.ir }));
|
||||
// Hydrate settings + optionalSteps on the fragment/generate path too — it
|
||||
// previously dropped both, which silently lost the declarations on the next
|
||||
// save (the round-trip data loss U2 fixes for the primary load path).
|
||||
// Hydrate settings on the fragment/generate path too — it previously dropped
|
||||
// them, which silently lost the declarations on the next save (the round-trip
|
||||
// data loss U2 fixes for the primary load path). Optional steps need no
|
||||
// separate hydration: they are graph-native `optional-group` nodes carried by
|
||||
// the node/edge mapping above (FNXC:WorkflowOptionalGroup 2026-06-21-18:00).
|
||||
setSettings(settingsOf({ ...targetWorkflow, ir: result.ir }));
|
||||
setOptionalSteps(optionalStepsOf({ ...targetWorkflow, ir: result.ir }));
|
||||
setSelectedNodeId(null);
|
||||
setSelectedEdgeId(null);
|
||||
setValidationError(null);
|
||||
@@ -1996,7 +2015,6 @@ function InnerEditor({
|
||||
columns.length ? columns : undefined,
|
||||
fields.length ? fields : undefined,
|
||||
settings.length ? settings : undefined,
|
||||
optionalSteps.length ? optionalSteps : undefined,
|
||||
);
|
||||
// Include name/description in the PATCH only when they changed from the
|
||||
// loaded workflow (KTD-10 inline rename/description persist here).
|
||||
@@ -2014,7 +2032,6 @@ function InnerEditor({
|
||||
columns,
|
||||
fields,
|
||||
settings,
|
||||
optionalSteps,
|
||||
);
|
||||
setName(updated.name);
|
||||
setDescription(updated.description ?? "");
|
||||
@@ -2084,7 +2101,7 @@ function InnerEditor({
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [activeWorkflow, name, description, nodes, edges, columns, fields, settings, optionalSteps, unplaced, blockingViolationCount, projectId, addToast, t]);
|
||||
}, [activeWorkflow, name, description, nodes, edges, columns, fields, settings, unplaced, blockingViolationCount, projectId, addToast, t]);
|
||||
|
||||
// Stamp the shared error-state badge onto offending nodes: unplaced step
|
||||
// nodes and any node the server flagged (seam-in-branch). One component
|
||||
@@ -2101,11 +2118,14 @@ function InnerEditor({
|
||||
let errorBadge: string | undefined;
|
||||
if (unplacedSet.has(n.id)) errorBadge = t("workflowColumns.nodeUnplaced", "Not placed in a column");
|
||||
if (serverNodeError?.nodeId === n.id) errorBadge = serverNodeError.message;
|
||||
const isTemplateGroup = n.data.kind === "foreach" || n.data.kind === "loop";
|
||||
const isTemplateGroup =
|
||||
n.data.kind === "foreach" || n.data.kind === "loop" || n.data.kind === "optional-group";
|
||||
const emptyHint =
|
||||
n.data.kind === "loop"
|
||||
? t("workflowNodes.loopEmptyHint", "Drag loop steps here")
|
||||
: t("workflowNodes.foreachEmptyHint", "Drag a step-execute node here");
|
||||
: n.data.kind === "optional-group"
|
||||
? t("workflowNodes.optionalGroupEmptyHint", "Drag optional steps here")
|
||||
: t("workflowNodes.foreachEmptyHint", "Drag a step-execute node here");
|
||||
const templateEmpty = isTemplateGroup ? (childCount.get(n.id) ?? 0) === 0 : undefined;
|
||||
if (
|
||||
errorBadge === n.data.errorBadge &&
|
||||
@@ -2129,6 +2149,8 @@ function InnerEditor({
|
||||
* The structural start node needs an inspector because its entry column is editable and persisted in the workflow IR. Keep end structural-only until it has a meaningful editable property.
|
||||
*/
|
||||
const selectedNodeHasInspector = selectedNode !== null && selectedNode.data.kind !== "end";
|
||||
// FNXC:WorkflowEditor 2026-06-21-10:00: Help content for the inspector, keyed by the node's effective kind (preserved IR kind when a graph-only policy node collapsed onto a generic merge/gate/hold shape).
|
||||
const selectedNodeHelp = selectedNode !== null ? nodeHelpForData(selectedNode.data) : null;
|
||||
const selectedEdge = edges.find((e) => e.id === selectedEdgeId) ?? null;
|
||||
const mobileNodeDetailStage = isMobileMode && selectedNodeHasInspector && !inspectorCollapsed;
|
||||
const mobileEdgeDetailStage = isMobileMode && selectedEdge !== null;
|
||||
@@ -2726,26 +2748,9 @@ function InnerEditor({
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="wf-sidebar-section" data-testid="wf-sidebar-optional-steps-section">
|
||||
<button
|
||||
type="button"
|
||||
className="wf-sidebar-section-toggle"
|
||||
aria-expanded={!optionalStepsCollapsed}
|
||||
data-testid="wf-sidebar-optional-steps-toggle"
|
||||
onClick={() => setOptionalStepsCollapsed((c) => !c)}
|
||||
>
|
||||
{optionalStepsCollapsed ? <ChevronRight size={13} /> : <ChevronDown size={13} />}
|
||||
<span>{t("workflowOptionalSteps.title", "Optional steps")}</span>
|
||||
</button>
|
||||
{!optionalStepsCollapsed && (
|
||||
<WorkflowOptionalStepsPanel
|
||||
optionalSteps={optionalSteps}
|
||||
onChange={setOptionalSteps}
|
||||
readOnly={isBuiltin}
|
||||
pluginTemplates={pluginTemplates.map((p) => p.template)}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
{/* FNXC:WorkflowOptionalGroup 2026-06-21-18:00: The optional-step
|
||||
DECLARATION authoring sidebar section is removed. Optional steps
|
||||
are authored as graph-native `optional-group` nodes on the canvas. */}
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
@@ -2881,7 +2886,6 @@ function InnerEditor({
|
||||
["add", t("workflowNodes.mobileAdd", "Add")],
|
||||
["settings", t("workflowSettings.title", "Settings")],
|
||||
["fields", t("workflowFields.title", "Fields")],
|
||||
["optional-steps", t("workflowOptionalSteps.title", "Optional steps")],
|
||||
["columns", t("workflowColumns.title", "Columns")],
|
||||
["actions", t("workflowNodes.mobileActions", "Actions")],
|
||||
] as Array<[MobileWorkflowPanel, string]>).map(([panel, label]) => (
|
||||
@@ -2992,19 +2996,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>
|
||||
)}
|
||||
@@ -3060,16 +3082,6 @@ function InnerEditor({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{mobilePanel === "optional-steps" && (
|
||||
<div className="wf-mobile-destination">
|
||||
<WorkflowOptionalStepsPanel
|
||||
optionalSteps={optionalSteps}
|
||||
onChange={setOptionalSteps}
|
||||
readOnly={isBuiltin}
|
||||
pluginTemplates={pluginTemplates.map((p) => p.template)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{mobilePanel === "columns" && (
|
||||
<div className="wf-mobile-destination">
|
||||
@@ -3396,22 +3408,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>
|
||||
@@ -3587,7 +3625,8 @@ function InnerEditor({
|
||||
!(compactLayoutEnabled && !isMobileMode) && (
|
||||
<aside className="wf-editor-inspector" data-testid="wf-node-inspector">
|
||||
<div className="wf-inspector-heading">
|
||||
<h3>{t("workflowNodes.nodeInspector", "Node")}</h3>
|
||||
{/* FNXC:WorkflowEditor 2026-06-21-10:00: Heading shows the node-kind title (from the help registry) so the pane names what is selected, falling back to the generic "Node" label. */}
|
||||
<h3>{selectedNodeHelp?.title ?? t("workflowNodes.nodeInspector", "Node")}</h3>
|
||||
{isMobileMode && (
|
||||
<button
|
||||
type="button"
|
||||
@@ -3607,6 +3646,37 @@ function InnerEditor({
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{/* FNXC:WorkflowEditor 2026-06-21-10:00: Per-node Help — what the node does, how to configure it, and its inputs/outputs/edges. Collapsed by default so it never pushes config fields below the fold; remembered open/closed within the session is intentionally not persisted (cheap to reopen). Engine-managed graph-only nodes (merge gate, branch-group integration/promotion, PR/recovery nodes) get an "Engine-managed" badge since they are read-only. */}
|
||||
{selectedNodeHelp && (
|
||||
<details className="wf-inspector-help" data-testid="wf-node-help">
|
||||
<summary className="wf-inspector-help-summary">
|
||||
<HelpCircle size={13} aria-hidden />
|
||||
<span>{t("workflowNodes.helpTitle", "What does this node do?")}</span>
|
||||
{selectedNodeHelp.graphOnly && (
|
||||
<span className="wf-inspector-help-badge" data-testid="wf-node-help-engine-managed">
|
||||
{t("workflowNodes.helpEngineManaged", "Engine-managed")}
|
||||
</span>
|
||||
)}
|
||||
</summary>
|
||||
<div className="wf-inspector-help-body">
|
||||
<p className="wf-inspector-help-summary-text">{selectedNodeHelp.summary}</p>
|
||||
<dl className="wf-inspector-help-dl">
|
||||
{selectedNodeHelp.configure && (
|
||||
<>
|
||||
<dt>{t("workflowNodes.helpConfigure", "Configure")}</dt>
|
||||
<dd>{selectedNodeHelp.configure}</dd>
|
||||
</>
|
||||
)}
|
||||
<dt>{t("workflowNodes.helpInputs", "Inputs")}</dt>
|
||||
<dd>{selectedNodeHelp.inputs}</dd>
|
||||
<dt>{t("workflowNodes.helpOutputs", "Outputs")}</dt>
|
||||
<dd>{selectedNodeHelp.outputs}</dd>
|
||||
<dt>{t("workflowNodes.helpEdges", "Edges")}</dt>
|
||||
<dd>{selectedNodeHelp.edges}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
{isBuiltin && (
|
||||
<p className="wf-inspector-note wf-inspector-note--info">
|
||||
{t("workflowNodes.readOnlyDuplicateToEdit", "Built-in structure is read-only — prompts on prompt and gate nodes can be edited here.")}
|
||||
@@ -4371,6 +4441,27 @@ function InnerEditor({
|
||||
})()
|
||||
) : null}
|
||||
|
||||
{/* FNXC:WorkflowOptionalGroup 2026-06-21-11:30: The optional-group inspector exposes the workflow-author `defaultOn` default (whether new tasks enable the group). The group name reuses the shared Name field above; the body is authored by dropping nodes inside, identical to foreach/loop. */}
|
||||
{selectedNode.data.kind === "optional-group" ? (
|
||||
<>
|
||||
<label className="wf-field wf-field--checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
data-testid="wf-optional-group-default-on"
|
||||
checked={Boolean(selectedNode.data.config?.defaultOn)}
|
||||
onChange={(e) => updateSelectedData({ config: { defaultOn: e.target.checked } })}
|
||||
/>
|
||||
<span>{t("workflowNodes.optionalGroupDefaultOn", "Enabled by default for new tasks")}</span>
|
||||
</label>
|
||||
<p className="wf-inspector-note wf-inspector-note--info">
|
||||
{t(
|
||||
"workflowNodes.optionalGroupNote",
|
||||
"Runs the steps inside this group once when the task enables it (seeded from this default), and skips them when disabled. Drop the optional steps into the region.",
|
||||
)}
|
||||
</p>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{selectedNode.data.kind === "step-review" ? (
|
||||
<>
|
||||
<label className="wf-field">
|
||||
|
||||
@@ -1,107 +0,0 @@
|
||||
/* WorkflowOptionalStepsPanel — sibling of WorkflowFieldsPanel; mirrors its layout
|
||||
* so the optional-steps panel reads consistently alongside Fields/Settings. */
|
||||
|
||||
.wf-optional-steps-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
padding: var(--space-md);
|
||||
}
|
||||
|
||||
.wf-optional-steps-header h3 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.wf-optional-steps-hint,
|
||||
.wf-optional-steps-empty {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.wf-optional-steps-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.wf-optional-step-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: var(--space-sm);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm, 6px);
|
||||
}
|
||||
|
||||
.wf-optional-step-item.is-unknown {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.wf-optional-step-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.wf-optional-step-title {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.wf-optional-step-name {
|
||||
font-weight: 600;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.wf-optional-step-name--unknown {
|
||||
font-style: italic;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.wf-optional-step-description {
|
||||
font-size: 0.72rem;
|
||||
color: var(--text-muted);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.wf-optional-step-default {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.wf-optional-step-remove {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.wf-optional-step-remove:hover:not(:disabled) {
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
.wf-optional-steps-add {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.wf-optional-steps-add-label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
@@ -1,177 +0,0 @@
|
||||
/**
|
||||
* FNXC:WorkflowOptionalSteps 2026-06-21-00:00:
|
||||
* Workflow authors need to declare which step templates are optional and set each
|
||||
* one's defaultOn from the visual editor (persisted on the IR's `optionalSteps`
|
||||
* array) so optional steps are authorable without hand-editing IR.
|
||||
*
|
||||
* WorkflowOptionalStepsPanel — the workflow editor's optional-step authoring
|
||||
* surface. Sibling to {@link WorkflowFieldsPanel} / WorkflowSettingsPanel: lives
|
||||
* alongside the canvas in {@link WorkflowNodeEditor} and mutates the IR's
|
||||
* `optionalSteps` array through the same state/save flow (preserved across the
|
||||
* round-trip by `flowToIr`).
|
||||
*
|
||||
* A declaration is just `{ templateId, defaultOn? }`. Display metadata
|
||||
* (name/description/phase) is resolved from the built-in step-template catalog at
|
||||
* render time — never duplicated into the IR — so the resolver stays the single
|
||||
* source of truth. Unknown/stale template ids render a muted, still-removable row
|
||||
* rather than being silently dropped.
|
||||
*/
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Plus, Trash2 } from "lucide-react";
|
||||
import { WORKFLOW_STEP_TEMPLATES, type WorkflowOptionalStep, type WorkflowStepTemplate } from "@fusion/core";
|
||||
import { phaseBadge } from "./workflow-phase-badge";
|
||||
import "./WorkflowOptionalStepsPanel.css";
|
||||
|
||||
interface WorkflowOptionalStepsPanelProps {
|
||||
optionalSteps: WorkflowOptionalStep[];
|
||||
onChange: (next: WorkflowOptionalStep[]) => void;
|
||||
readOnly: boolean;
|
||||
/** Plugin-contributed templates, merged into the catalog when available. */
|
||||
pluginTemplates?: WorkflowStepTemplate[];
|
||||
}
|
||||
|
||||
export function WorkflowOptionalStepsPanel({
|
||||
optionalSteps,
|
||||
onChange,
|
||||
readOnly,
|
||||
pluginTemplates = [],
|
||||
}: WorkflowOptionalStepsPanelProps) {
|
||||
const { t } = useTranslation("app");
|
||||
|
||||
const templatesById = useMemo(() => {
|
||||
const map = new Map<string, WorkflowStepTemplate>();
|
||||
for (const tpl of [...WORKFLOW_STEP_TEMPLATES, ...pluginTemplates]) map.set(tpl.id, tpl);
|
||||
return map;
|
||||
}, [pluginTemplates]);
|
||||
|
||||
const declaredIds = useMemo(() => new Set(optionalSteps.map((s) => s.templateId)), [optionalSteps]);
|
||||
|
||||
// Catalog entries not already declared — the "Add optional step" picker source.
|
||||
const available = useMemo(
|
||||
() => [...templatesById.values()].filter((tpl) => !declaredIds.has(tpl.id)),
|
||||
[templatesById, declaredIds],
|
||||
);
|
||||
|
||||
const addStep = useCallback(
|
||||
(templateId: string) => {
|
||||
if (!templateId || declaredIds.has(templateId)) return;
|
||||
onChange([...optionalSteps, { templateId, defaultOn: false }]);
|
||||
},
|
||||
[optionalSteps, onChange, declaredIds],
|
||||
);
|
||||
|
||||
const removeStep = useCallback(
|
||||
(templateId: string) => onChange(optionalSteps.filter((s) => s.templateId !== templateId)),
|
||||
[optionalSteps, onChange],
|
||||
);
|
||||
|
||||
const toggleDefaultOn = useCallback(
|
||||
(templateId: string, defaultOn: boolean) =>
|
||||
onChange(optionalSteps.map((s) => (s.templateId === templateId ? { ...s, defaultOn } : s))),
|
||||
[optionalSteps, onChange],
|
||||
);
|
||||
|
||||
return (
|
||||
<aside className="wf-optional-steps-panel" data-testid="wf-optional-steps-panel">
|
||||
<header className="wf-optional-steps-header">
|
||||
<h3>{t("workflowOptionalSteps.title", "Optional steps")}</h3>
|
||||
<p className="wf-optional-steps-hint">
|
||||
{t(
|
||||
"workflowOptionalSteps.hint",
|
||||
"Steps a task can toggle on or off. Default sets the initial state for new tasks.",
|
||||
)}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{optionalSteps.length === 0 ? (
|
||||
<p className="wf-optional-steps-empty">
|
||||
{t("workflowOptionalSteps.empty", "No optional steps. Add one to let tasks opt in or out.")}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="wf-optional-steps-list">
|
||||
{optionalSteps.map((step) => {
|
||||
const tpl = templatesById.get(step.templateId);
|
||||
const defaultOn = step.defaultOn ?? tpl?.defaultOn ?? false;
|
||||
return (
|
||||
<li
|
||||
key={step.templateId}
|
||||
className={`wf-optional-step-item${tpl ? "" : " is-unknown"}`}
|
||||
data-testid={`wf-optional-step-${step.templateId}`}
|
||||
>
|
||||
<div className="wf-optional-step-head">
|
||||
<div className="wf-optional-step-title">
|
||||
{tpl ? (
|
||||
<>
|
||||
<span className="wf-optional-step-name">{tpl.name}</span>
|
||||
{phaseBadge(tpl.phase ?? "pre-merge", step.templateId, "wf-optional-step-phase", t)}
|
||||
</>
|
||||
) : (
|
||||
<span className="wf-optional-step-name wf-optional-step-name--unknown">
|
||||
{t("workflowOptionalSteps.unknown", "Unknown step ({{id}})", { id: step.templateId })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="wf-optional-step-remove"
|
||||
aria-label={t("workflowOptionalSteps.remove", "Remove optional step")}
|
||||
disabled={readOnly}
|
||||
onClick={() => removeStep(step.templateId)}
|
||||
>
|
||||
<Trash2 size={13} />
|
||||
</button>
|
||||
</div>
|
||||
{tpl?.description && (
|
||||
<p className="wf-optional-step-description">{tpl.description}</p>
|
||||
)}
|
||||
<label className="wf-optional-step-default">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={defaultOn}
|
||||
disabled={readOnly}
|
||||
aria-label={t("workflowOptionalSteps.defaultOnFor", "Default on for {{name}}", {
|
||||
name: tpl?.name ?? step.templateId,
|
||||
})}
|
||||
onChange={(e) => toggleDefaultOn(step.templateId, e.target.checked)}
|
||||
/>
|
||||
<span>{t("workflowOptionalSteps.defaultOn", "Default on")}</span>
|
||||
</label>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{available.length > 0 && (
|
||||
<div className="wf-optional-steps-add">
|
||||
{/* Picker resets to placeholder after each add (value stays ""). */}
|
||||
<label className="wf-optional-steps-add-label" htmlFor="wf-optional-steps-add-select">
|
||||
<Plus size={13} /> {t("workflowOptionalSteps.add", "Add optional step")}
|
||||
</label>
|
||||
<select
|
||||
id="wf-optional-steps-add-select"
|
||||
data-testid="wf-optional-steps-add-select"
|
||||
value=""
|
||||
disabled={readOnly}
|
||||
onChange={(e) => {
|
||||
addStep(e.target.value);
|
||||
e.target.value = "";
|
||||
}}
|
||||
>
|
||||
<option value="" disabled>
|
||||
{t("workflowOptionalSteps.addPlaceholder", "Select a step…")}
|
||||
</option>
|
||||
{available.map((tpl) => (
|
||||
<option key={tpl.id} value={tpl.id}>
|
||||
{tpl.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
export default WorkflowOptionalStepsPanel;
|
||||
@@ -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,
|
||||
@@ -170,13 +170,9 @@ function v2Def(): WorkflowDefinition {
|
||||
};
|
||||
}
|
||||
|
||||
function v2DefWithOptional(): WorkflowDefinition {
|
||||
const base = v2Def();
|
||||
return {
|
||||
...base,
|
||||
ir: { ...(base.ir as object), optionalSteps: [{ templateId: "browser-verification" }] } as WorkflowDefinition["ir"],
|
||||
};
|
||||
}
|
||||
// FNXC:WorkflowOptionalGroup 2026-06-21-18:00: `v2DefWithOptional` and its
|
||||
// optional-step DECLARATION hydration/save test are removed — the declaration
|
||||
// authoring panel is retired (optional-group nodes now).
|
||||
|
||||
function builtinDef(): WorkflowDefinition {
|
||||
return {
|
||||
@@ -392,12 +388,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);
|
||||
@@ -806,34 +803,6 @@ describe("WorkflowNodeEditor", () => {
|
||||
expect(start?.column).toBe("done");
|
||||
});
|
||||
|
||||
it("hydrates declared optional steps and preserves them through a dirty save (round-trip)", async () => {
|
||||
vi.mocked(fetchWorkflows).mockResolvedValue([v2DefWithOptional()]);
|
||||
vi.mocked(updateWorkflow).mockImplementation(async (_id, updates) => ({
|
||||
...v2DefWithOptional(),
|
||||
...(updates as object),
|
||||
}));
|
||||
vi.mocked(compileWorkflow).mockResolvedValue({ steps: [] });
|
||||
|
||||
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />);
|
||||
|
||||
await screen.findByText("Save");
|
||||
// The declared optional step is hydrated into the panel (optionalStepsOf).
|
||||
const row = await screen.findByTestId("wf-optional-step-browser-verification");
|
||||
expect(within(row).getByText("Browser Verification")).toBeTruthy();
|
||||
|
||||
// Toggling defaultOn must mark the editor dirty (serializeGraph threading) so
|
||||
// the Save button enables and persists the change.
|
||||
fireEvent.click(within(row).getByRole("checkbox"));
|
||||
fireEvent.click(screen.getByText("Save").closest("button")!);
|
||||
|
||||
await waitFor(() => expect(updateWorkflow).toHaveBeenCalled());
|
||||
const [, updates] = vi.mocked(updateWorkflow).mock.calls[0];
|
||||
const ir = (updates as { ir: WorkflowDefinition["ir"] }).ir as {
|
||||
optionalSteps?: { templateId: string; defaultOn?: boolean }[];
|
||||
};
|
||||
expect(ir.optionalSteps).toEqual([{ templateId: "browser-verification", defaultOn: true }]);
|
||||
});
|
||||
|
||||
it("renders the start inspector without the entry-column select for v1 workflows", async () => {
|
||||
vi.mocked(fetchWorkflows).mockResolvedValue([def()]);
|
||||
|
||||
@@ -850,6 +819,25 @@ describe("WorkflowNodeEditor", () => {
|
||||
expect(within(inspector).queryByLabelText("Name")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// FNXC:WorkflowEditor 2026-06-21-10:00: Every node's detail pane carries a Help section describing what it does and its inputs/outputs/edges.
|
||||
it("renders a Help section in the node detail pane", async () => {
|
||||
vi.mocked(fetchWorkflows).mockResolvedValue([def()]);
|
||||
|
||||
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />);
|
||||
|
||||
await screen.findByText("Save");
|
||||
fireEvent.click(await screen.findByTestId("wf-node-start"));
|
||||
|
||||
const inspector = await screen.findByTestId("wf-node-inspector");
|
||||
const help = within(inspector).getByTestId("wf-node-help");
|
||||
expect(help).toHaveTextContent("What does this node do?");
|
||||
expect(help).toHaveTextContent("Inputs");
|
||||
expect(help).toHaveTextContent("Outputs");
|
||||
expect(help).toHaveTextContent("Edges");
|
||||
// Editor (non-policy) nodes are not flagged engine-managed.
|
||||
expect(within(inspector).queryByTestId("wf-node-help-engine-managed")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps built-in start node entry-column controls read-only", async () => {
|
||||
vi.mocked(fetchWorkflows).mockResolvedValue([builtinDef()]);
|
||||
|
||||
@@ -1696,6 +1684,51 @@ function stepwiseDef(): WorkflowDefinition {
|
||||
};
|
||||
}
|
||||
|
||||
/** A v2 workflow with an optional-group container (defaultOn:false) holding one
|
||||
* template child, so the editor's optional-group surfaces have something to
|
||||
* render, toggle, and delete. */
|
||||
function optionalGroupDef(): WorkflowDefinition {
|
||||
return {
|
||||
id: "WF-OPT",
|
||||
kind: "workflow",
|
||||
name: "Optional",
|
||||
description: "",
|
||||
ir: {
|
||||
version: "v2",
|
||||
name: "Optional",
|
||||
columns: [
|
||||
{ id: "plan", name: "Plan", traits: [{ trait: "intake" }] },
|
||||
{ id: "in-progress", name: "In progress", traits: [] },
|
||||
{ id: "done", name: "Done", traits: [{ trait: "complete" }] },
|
||||
],
|
||||
nodes: [
|
||||
{ id: "start", kind: "start", column: "plan" },
|
||||
{
|
||||
id: "opt",
|
||||
kind: "optional-group",
|
||||
column: "in-progress",
|
||||
config: {
|
||||
defaultOn: false,
|
||||
name: "Browser verification",
|
||||
template: {
|
||||
nodes: [{ id: "verify", kind: "prompt", config: { prompt: "verify in browser" } }],
|
||||
edges: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
{ id: "end", kind: "end", column: "done" },
|
||||
],
|
||||
edges: [
|
||||
{ from: "start", to: "opt", condition: "success" },
|
||||
{ from: "opt", to: "end", condition: "success" },
|
||||
],
|
||||
},
|
||||
layout: {},
|
||||
createdAt: "2026-06-04T00:00:00.000Z",
|
||||
updatedAt: "2026-06-04T00:00:00.000Z",
|
||||
};
|
||||
}
|
||||
|
||||
describe("WorkflowNodeEditor — U8 step-inversion authoring", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(fetchTraits).mockResolvedValue(TRAIT_CATALOG);
|
||||
@@ -1752,6 +1785,80 @@ describe("WorkflowNodeEditor — U8 step-inversion authoring", () => {
|
||||
expect(template.nodes[0].config?.seam).toBe("step-execute");
|
||||
});
|
||||
|
||||
// FNXC:WorkflowOptionalGroup 2026-06-21-11:30: An optional-group must be
|
||||
// authorable like a foreach/loop — added from the palette as a registered group
|
||||
// container (not react-flow__node-default), filled with nodes, named, toggled
|
||||
// for defaultOn, and deleted with its children cascaded.
|
||||
it("adds an optional-group from the palette and round-trips its template on save", async () => {
|
||||
vi.mocked(fetchWorkflows).mockResolvedValue([v2Def()]);
|
||||
vi.mocked(updateWorkflow).mockImplementation(async (_id, updates) => ({ ...v2Def(), ...(updates as object) }));
|
||||
vi.mocked(compileWorkflow).mockResolvedValue({ steps: [] });
|
||||
|
||||
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />);
|
||||
await screen.findByText("Save");
|
||||
expect(await screen.findByTestId("wf-column-panel")).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByText("Optional group").closest("button")!);
|
||||
// Renders via the registered group component (wf-node-optional-group), NOT
|
||||
// React Flow's default fallback.
|
||||
await waitFor(() => expect(screen.getByTestId("wf-node-optional-group")).toBeInTheDocument(), { timeout: 5000 });
|
||||
// No empty hint — the palette seeded an optional step inside.
|
||||
expect(screen.queryByTestId("wf-optional-group-empty")).not.toBeInTheDocument();
|
||||
|
||||
await waitFor(() => expect(screen.getAllByLabelText(/Column name/i).length).toBeGreaterThan(0));
|
||||
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; config?: Record<string, unknown> }[] } }).ir;
|
||||
const group = ir.nodes.find((n) => n.kind === "optional-group");
|
||||
expect(group).toBeTruthy();
|
||||
const template = group!.config!.template as { nodes: unknown[] };
|
||||
expect(template.nodes).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("toggles optional-group defaultOn, marks the editor dirty, and persists on save", async () => {
|
||||
vi.mocked(fetchWorkflows).mockResolvedValue([optionalGroupDef()]);
|
||||
vi.mocked(updateWorkflow).mockImplementation(async (_id, updates) => ({ ...optionalGroupDef(), ...(updates as object) }));
|
||||
vi.mocked(compileWorkflow).mockResolvedValue({ steps: [] });
|
||||
|
||||
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />);
|
||||
await screen.findByText("Save");
|
||||
const group = await screen.findByTestId("wf-node-optional-group");
|
||||
fireEvent.click(group);
|
||||
|
||||
const toggle = await screen.findByTestId("wf-optional-group-default-on");
|
||||
expect((toggle as HTMLInputElement).checked).toBe(false);
|
||||
fireEvent.click(toggle);
|
||||
expect((toggle as HTMLInputElement).checked).toBe(true);
|
||||
|
||||
await waitFor(() => expect(screen.getAllByLabelText(/Column name/i).length).toBeGreaterThan(0));
|
||||
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 opt = ir.nodes.find((n) => n.kind === "optional-group");
|
||||
expect(opt!.config!.defaultOn).toBe(true);
|
||||
});
|
||||
|
||||
it("deletes an optional-group and removes its parentId children (no orphans)", async () => {
|
||||
vi.mocked(fetchWorkflows).mockResolvedValue([optionalGroupDef()]);
|
||||
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />);
|
||||
const group = await screen.findByTestId("wf-node-optional-group");
|
||||
// The seeded template child renders as a parented flow node.
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
document.querySelector(`.react-flow__node[data-id="${foreachChildFlowId("opt", "verify")}"]`),
|
||||
).toBeInTheDocument(),
|
||||
);
|
||||
fireEvent.click(group);
|
||||
fireEvent.click(await screen.findByTestId("wf-delete-node"));
|
||||
await waitFor(() => expect(screen.queryByTestId("wf-node-optional-group")).not.toBeInTheDocument());
|
||||
// The template child is gone too (cascade) — no orphaned parentId node.
|
||||
expect(
|
||||
document.querySelector(`.react-flow__node[data-id="${foreachChildFlowId("opt", "verify")}"]`),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("edits foreach mode/isolation/concurrency/maxReworkCycles inspector fields", async () => {
|
||||
vi.mocked(fetchWorkflows).mockResolvedValue([stepwiseDef()]);
|
||||
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />);
|
||||
@@ -2995,11 +3102,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();
|
||||
@@ -3038,6 +3147,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 ──────────────────────────────────
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, cleanup, within } from "@testing-library/react";
|
||||
import { useState } from "react";
|
||||
import type { WorkflowOptionalStep } from "@fusion/core";
|
||||
import { WorkflowOptionalStepsPanel } from "../WorkflowOptionalStepsPanel";
|
||||
|
||||
// Controlled host mirroring how WorkflowNodeEditor drives the panel.
|
||||
function Host({
|
||||
initial,
|
||||
readOnly = false,
|
||||
onState,
|
||||
}: {
|
||||
initial: WorkflowOptionalStep[];
|
||||
readOnly?: boolean;
|
||||
onState?: (s: WorkflowOptionalStep[]) => void;
|
||||
}) {
|
||||
const [optionalSteps, setOptionalSteps] = useState<WorkflowOptionalStep[]>(initial);
|
||||
return (
|
||||
<WorkflowOptionalStepsPanel
|
||||
optionalSteps={optionalSteps}
|
||||
readOnly={readOnly}
|
||||
onChange={(next) => {
|
||||
setOptionalSteps(next);
|
||||
onState?.(next);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("WorkflowOptionalStepsPanel", () => {
|
||||
it("renders the empty state and an add picker when no steps are declared", () => {
|
||||
render(<Host initial={[]} />);
|
||||
expect(screen.getByText(/No optional steps/i)).toBeTruthy();
|
||||
const select = screen.getByTestId("wf-optional-steps-add-select") as HTMLSelectElement;
|
||||
// browser-verification is in the catalog and not yet declared → available.
|
||||
expect(within(select).getByRole("option", { name: "Browser Verification" })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("adds a step from the picker (defaultOn false) and removes it from the picker", () => {
|
||||
const onState = vi.fn();
|
||||
render(<Host initial={[]} onState={onState} />);
|
||||
fireEvent.change(screen.getByTestId("wf-optional-steps-add-select"), {
|
||||
target: { value: "browser-verification" },
|
||||
});
|
||||
expect(onState).toHaveBeenCalledWith([{ templateId: "browser-verification", defaultOn: false }]);
|
||||
// The declared row is shown with the resolved template name…
|
||||
const row = screen.getByTestId("wf-optional-step-browser-verification");
|
||||
expect(within(row).getByText("Browser Verification")).toBeTruthy();
|
||||
// …and the picker no longer offers it.
|
||||
const select = screen.getByTestId("wf-optional-steps-add-select") as HTMLSelectElement;
|
||||
expect(within(select).queryByRole("option", { name: "Browser Verification" })).toBeNull();
|
||||
});
|
||||
|
||||
it("toggles defaultOn for a declared step", () => {
|
||||
const onState = vi.fn();
|
||||
render(<Host initial={[{ templateId: "browser-verification", defaultOn: false }]} onState={onState} />);
|
||||
const row = screen.getByTestId("wf-optional-step-browser-verification");
|
||||
fireEvent.click(within(row).getByRole("checkbox"));
|
||||
expect(onState).toHaveBeenCalledWith([{ templateId: "browser-verification", defaultOn: true }]);
|
||||
});
|
||||
|
||||
it("removes a declared step and returns it to the picker", () => {
|
||||
render(<Host initial={[{ templateId: "browser-verification" }]} />);
|
||||
const row = screen.getByTestId("wf-optional-step-browser-verification");
|
||||
fireEvent.click(within(row).getByRole("button", { name: /Remove optional step/i }));
|
||||
expect(screen.queryByTestId("wf-optional-step-browser-verification")).toBeNull();
|
||||
const select = screen.getByTestId("wf-optional-steps-add-select") as HTMLSelectElement;
|
||||
expect(within(select).getByRole("option", { name: "Browser Verification" })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders an unknown/stale templateId as a muted, still-removable row", () => {
|
||||
const onState = vi.fn();
|
||||
render(<Host initial={[{ templateId: "does-not-exist" }]} onState={onState} />);
|
||||
const row = screen.getByTestId("wf-optional-step-does-not-exist");
|
||||
expect(row.className).toContain("is-unknown");
|
||||
expect(within(row).getByText(/Unknown step/i)).toBeTruthy();
|
||||
fireEvent.click(within(row).getByRole("button", { name: /Remove optional step/i }));
|
||||
expect(onState).toHaveBeenCalledWith([]);
|
||||
});
|
||||
|
||||
it("disables editing when readOnly", () => {
|
||||
render(<Host initial={[{ templateId: "browser-verification" }]} readOnly />);
|
||||
const row = screen.getByTestId("wf-optional-step-browser-verification");
|
||||
expect((within(row).getByRole("checkbox") as HTMLInputElement).disabled).toBe(true);
|
||||
expect((within(row).getByRole("button", { name: /Remove optional step/i }) as HTMLButtonElement).disabled).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -6,10 +6,10 @@ import {
|
||||
irToFlow,
|
||||
flowToIr,
|
||||
insertFragment,
|
||||
optionalGroupFragmentIr,
|
||||
fragmentSeamConflicts,
|
||||
copyIrWithFreshIds,
|
||||
columnsOf,
|
||||
optionalStepsOf,
|
||||
columnForY,
|
||||
bandTop,
|
||||
columnsToBandNodes,
|
||||
@@ -766,6 +766,104 @@ describe("workflow-flow-mapping foreach + rework round-trip", () => {
|
||||
expect(template.edges).toEqual([{ from: "try", to: "check", condition: "success" }]);
|
||||
});
|
||||
|
||||
// FNXC:WorkflowOptionalGroup 2026-06-21-11:30: An optional-group's template
|
||||
// subgraph must round-trip through the editor's parentId-child rendering exactly
|
||||
// like foreach/loop — irToFlow renders the template as parented children;
|
||||
// flowToIr reassembles them into config.template, preserving defaultOn/name.
|
||||
it("round-trips an optional-group template (children partitioned by parentId) losslessly", () => {
|
||||
const optionalIr: WorkflowDefinition["ir"] = {
|
||||
version: "v2",
|
||||
name: "optional",
|
||||
columns: ir.columns,
|
||||
nodes: [
|
||||
{ id: "start", kind: "start", column: "plan" },
|
||||
{
|
||||
id: "opt",
|
||||
kind: "optional-group",
|
||||
column: "in-progress",
|
||||
config: {
|
||||
defaultOn: true,
|
||||
name: "Browser verification",
|
||||
template: {
|
||||
nodes: [
|
||||
{ id: "verify", kind: "prompt", config: { prompt: "verify in browser" } },
|
||||
{ id: "check", kind: "gate", config: { prompt: "ok?" } },
|
||||
],
|
||||
edges: [{ from: "verify", to: "check", condition: "success" }],
|
||||
},
|
||||
},
|
||||
},
|
||||
{ id: "end", kind: "end", column: "done" },
|
||||
],
|
||||
edges: [
|
||||
{ from: "start", to: "opt", condition: "success" },
|
||||
{ from: "opt", to: "end", condition: "success" },
|
||||
],
|
||||
};
|
||||
const def = makeDef(optionalIr);
|
||||
const { nodes, edges } = irToFlow(def);
|
||||
const columns = columnsOf(def);
|
||||
|
||||
// The optional-group renders via the registered group component (type
|
||||
// "optional-group", NOT react-flow__node-default) with parented children.
|
||||
const group = nodes.find((n) => n.id === "opt");
|
||||
expect(group?.type).toBe("optional-group");
|
||||
expect(group?.data.kind).toBe("optional-group");
|
||||
// The group node keeps defaultOn/name; the template is stripped onto children.
|
||||
expect(group?.data.config?.defaultOn).toBe(true);
|
||||
expect((group?.data.config as Record<string, unknown>)?.template).toBeUndefined();
|
||||
const children = nodes.filter((n) => n.parentId === "opt");
|
||||
expect(children.map((c) => templateNodeIdFromChild("opt", c.id)).sort()).toEqual(["check", "verify"]);
|
||||
|
||||
const { ir: out } = flowToIr("optional", nodes, edges, columns);
|
||||
if (out.version !== "v2") throw new Error("expected v2");
|
||||
const opt = out.nodes.find((n) => n.id === "opt");
|
||||
expect(opt?.kind).toBe("optional-group");
|
||||
const cfg = opt?.config as Record<string, unknown>;
|
||||
expect(cfg.defaultOn).toBe(true);
|
||||
expect(cfg.name).toBe("Browser verification");
|
||||
const template = cfg.template as { nodes: { id: string }[]; edges: { from: string; to: string }[] };
|
||||
expect(template.nodes.map((n) => n.id)).toEqual(["verify", "check"]);
|
||||
expect(template.edges).toEqual([{ from: "verify", to: "check", condition: "success" }]);
|
||||
// Top-level edges exclude the intra-template ones.
|
||||
expect(out.edges.map((e) => `${e.from}->${e.to}`)).toEqual(["start->opt", "opt->end"]);
|
||||
});
|
||||
|
||||
// FNXC:WorkflowOptionalGroup 2026-06-21-11:30: Deleting an optional-group must
|
||||
// cascade its parentId children (no orphans) — same rule foreach/loop follow.
|
||||
it("cascade-deletes an optional-group's template children", () => {
|
||||
const optionalIr: WorkflowDefinition["ir"] = {
|
||||
version: "v2",
|
||||
name: "optional-del",
|
||||
columns: ir.columns,
|
||||
nodes: [
|
||||
{ id: "start", kind: "start", column: "plan" },
|
||||
{
|
||||
id: "opt",
|
||||
kind: "optional-group",
|
||||
column: "in-progress",
|
||||
config: {
|
||||
defaultOn: false,
|
||||
template: {
|
||||
nodes: [{ id: "verify", kind: "prompt", config: { prompt: "verify" } }],
|
||||
edges: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
{ id: "end", kind: "end", column: "done" },
|
||||
],
|
||||
edges: [
|
||||
{ from: "start", to: "opt", condition: "success" },
|
||||
{ from: "opt", to: "end", condition: "success" },
|
||||
],
|
||||
};
|
||||
const { nodes, edges } = irToFlow(makeDef(optionalIr));
|
||||
expect(nodes.some((n) => n.parentId === "opt")).toBe(true);
|
||||
const result = cascadeDelete(nodes, edges, ["opt"]);
|
||||
expect(result.nodes.some((n) => n.id === "opt")).toBe(false);
|
||||
expect(result.nodes.some((n) => n.parentId === "opt")).toBe(false);
|
||||
});
|
||||
|
||||
it("inserts loop fragments with their template children intact", () => {
|
||||
const fragment: WorkflowDefinition["ir"] = {
|
||||
version: "v2",
|
||||
@@ -1338,6 +1436,46 @@ 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: BOTH inserted groups carry defaultOn + a single-node template,
|
||||
// so a regression that breaks the second insert can't pass on the first.
|
||||
const { ir: out } = flowToIr("wf", second.nodes, second.edges);
|
||||
// An optional-group is a v2-only kind: its presence forces v2 serialization
|
||||
// even with no columns/fields/settings, or it would serialize as v1 and fail
|
||||
// parse. (Code review: CodeRabbit.)
|
||||
expect(out.version).toBe("v2");
|
||||
const ogs = out.nodes.filter((n) => n.kind === "optional-group");
|
||||
expect(ogs).toHaveLength(2);
|
||||
for (const og of ogs) {
|
||||
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", () => {
|
||||
@@ -1505,58 +1643,12 @@ describe("copyIrWithFreshIds", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("optionalSteps round-trip (U2)", () => {
|
||||
const v2WithOptional = (optionalSteps?: { templateId: string; defaultOn?: boolean }[]) =>
|
||||
makeDef(
|
||||
parseWorkflowIr({
|
||||
version: "v2",
|
||||
name: "wf-opt",
|
||||
columns: [
|
||||
{ id: "triage", name: "Triage", traits: [] },
|
||||
{ id: "done", name: "Done", traits: [{ trait: "complete" }] },
|
||||
],
|
||||
nodes: [
|
||||
{ id: "start", kind: "start", column: "triage" },
|
||||
{ id: "end", kind: "end", column: "done" },
|
||||
],
|
||||
edges: [{ from: "start", to: "end" }],
|
||||
...(optionalSteps ? { optionalSteps } : {}),
|
||||
}),
|
||||
);
|
||||
|
||||
it("optionalStepsOf reads declarations from a v2 IR and returns a copy", () => {
|
||||
const def = v2WithOptional([{ templateId: "browser-verification", defaultOn: true }]);
|
||||
const read = optionalStepsOf(def);
|
||||
expect(read).toEqual([{ templateId: "browser-verification", defaultOn: true }]);
|
||||
// mutating the result does not mutate the source IR
|
||||
read[0].defaultOn = false;
|
||||
expect(optionalStepsOf(def)).toEqual([{ templateId: "browser-verification", defaultOn: true }]);
|
||||
});
|
||||
|
||||
it("optionalStepsOf returns [] for v1 and for v2 without optionalSteps", () => {
|
||||
const v1 = makeDef({
|
||||
version: "v1",
|
||||
name: "legacy",
|
||||
nodes: [
|
||||
{ id: "start", kind: "start" },
|
||||
{ id: "end", kind: "end" },
|
||||
],
|
||||
edges: [{ from: "start", to: "end" }],
|
||||
});
|
||||
expect(optionalStepsOf(v1)).toEqual([]);
|
||||
expect(optionalStepsOf(v2WithOptional())).toEqual([]);
|
||||
});
|
||||
|
||||
it("flowToIr preserves optionalSteps across a full irToFlow round-trip", () => {
|
||||
const def = v2WithOptional([{ templateId: "browser-verification", defaultOn: true }]);
|
||||
const { nodes, edges } = irToFlow(def);
|
||||
const { ir: out } = flowToIr("wf-opt", nodes, edges, columnsOf(def), [], [], optionalStepsOf(def));
|
||||
expect((out as { optionalSteps?: unknown }).optionalSteps).toEqual([
|
||||
{ templateId: "browser-verification", defaultOn: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it("serializes as v2 when optionalSteps present but no custom columns/fields/settings", () => {
|
||||
// FNXC:WorkflowOptionalGroup 2026-06-21-18:00:
|
||||
// The legacy optional-step DECLARATION authoring surface is retired: `optionalStepsOf`
|
||||
// is removed and `flowToIr` no longer accepts/emits an `optionalSteps` array. Optional
|
||||
// steps are graph-native `optional-group` nodes carried by the normal node/edge mapping.
|
||||
describe("optionalSteps declaration authoring removed (U7)", () => {
|
||||
it("flowToIr never emits a legacy optionalSteps key", () => {
|
||||
const { ir: out } = flowToIr(
|
||||
"opt-only",
|
||||
[
|
||||
@@ -1567,21 +1659,7 @@ describe("optionalSteps round-trip (U2)", () => {
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
[{ templateId: "browser-verification" }],
|
||||
);
|
||||
expect(out.version).toBe("v2");
|
||||
expect((out as { optionalSteps?: unknown }).optionalSteps).toEqual([
|
||||
{ templateId: "browser-verification" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("omits the optionalSteps key entirely when empty (R6 byte-identity)", () => {
|
||||
const def = v2WithOptional();
|
||||
const { nodes, edges } = irToFlow(def);
|
||||
const { ir: out } = flowToIr("wf-opt", nodes, edges, columnsOf(def), [], [], []);
|
||||
expect("optionalSteps" in out).toBe(false);
|
||||
// and with the arg omitted entirely
|
||||
const { ir: out2 } = flowToIr("wf-opt", nodes, edges, columnsOf(def));
|
||||
expect("optionalSteps" in out2).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Handle, Position, type NodeProps } from "@xyflow/react";
|
||||
import { Play, Flag, MessageSquare, Terminal, Shield, GitMerge, PauseCircle, Split, Merge, AlertTriangle, Repeat, ClipboardCheck, ListChecks, Code2, Bell } from "lucide-react";
|
||||
import { Play, Flag, MessageSquare, Terminal, Shield, GitMerge, PauseCircle, Split, Merge, AlertTriangle, Repeat, ClipboardCheck, ListChecks, Code2, Bell, ToggleRight } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { nodeConfigSummary } from "./node-summary";
|
||||
import { useWorkflowEditorCatalogs } from "./WorkflowEditorCatalogContext";
|
||||
@@ -28,6 +28,7 @@ export type WorkflowEditorNodeKind =
|
||||
| "join"
|
||||
| "foreach"
|
||||
| "loop"
|
||||
| "optional-group"
|
||||
| WorkflowNodeKindStepReview
|
||||
| WorkflowNodeKindParseSteps
|
||||
| "code"
|
||||
@@ -65,6 +66,7 @@ const KIND_ICON: Record<WorkflowEditorNodeKind, typeof Play> = {
|
||||
join: Merge,
|
||||
foreach: Repeat,
|
||||
loop: Repeat,
|
||||
"optional-group": ToggleRight,
|
||||
[WORKFLOW_NODE_KIND_STEP_REVIEW]: ClipboardCheck,
|
||||
[WORKFLOW_NODE_KIND_PARSE_STEPS]: ListChecks,
|
||||
code: Code2,
|
||||
@@ -197,6 +199,42 @@ function LoopGroupNode({ data }: { data: WorkflowFlowNodeData }) {
|
||||
);
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:WorkflowOptionalGroup 2026-06-21-11:30:
|
||||
An `optional-group` renders as a React Flow group container (mirroring `ForeachGroupNode`/`LoopGroupNode`): template nodes are children (parentId = group id). The header shows the group name plus a `defaultOn` badge ("default on" / "default off") so an author can see, at a glance, whether new tasks enable this group. An unregistered kind falls back to `react-flow__node-default` with missing children — registration in `workflowNodeTypes` (below) is what keeps the container rendering with its body.
|
||||
*/
|
||||
function OptionalGroupNode({ data }: { data: WorkflowFlowNodeData }) {
|
||||
const { t } = useTranslation("app");
|
||||
const defaultOn = data.config?.defaultOn === true;
|
||||
const isEmpty = data.templateEmpty === true;
|
||||
return (
|
||||
<div
|
||||
className={`wf-foreach-group wf-optional-group${data.errorBadge ? " wf-node--error" : ""}`}
|
||||
data-testid="wf-node-optional-group"
|
||||
>
|
||||
<Handle type="target" position={Position.Left} />
|
||||
<div className="wf-foreach-header">
|
||||
<span className="wf-node-icon">
|
||||
<ToggleRight size={14} aria-hidden />
|
||||
</span>
|
||||
<span className="wf-node-label">{data.label || "optional-group"}</span>
|
||||
<span className="wf-node-badge" data-testid="wf-optional-group-default-badge">
|
||||
{defaultOn
|
||||
? t("workflowNodes.optionalGroupDefaultOn", "default on")
|
||||
: t("workflowNodes.optionalGroupDefaultOff", "default off")}
|
||||
</span>
|
||||
</div>
|
||||
{isEmpty && (
|
||||
<div className="wf-foreach-empty" data-testid="wf-optional-group-empty">
|
||||
{data.emptyHint || "Drag optional steps here"}
|
||||
</div>
|
||||
)}
|
||||
{data.errorBadge && <WorkflowNodeErrorBadge message={data.errorBadge} />}
|
||||
<Handle type="source" position={Position.Right} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const workflowNodeTypes = {
|
||||
start: ({ data }: NodeProps) => <NodeShell data={data as WorkflowFlowNodeData} kind="start" />,
|
||||
end: ({ data }: NodeProps) => <NodeShell data={data as WorkflowFlowNodeData} kind="end" />,
|
||||
@@ -209,6 +247,7 @@ export const workflowNodeTypes = {
|
||||
join: ({ data }: NodeProps) => <NodeShell data={data as WorkflowFlowNodeData} kind="join" />,
|
||||
foreach: ({ data }: NodeProps) => <ForeachGroupNode data={data as WorkflowFlowNodeData} />,
|
||||
loop: ({ data }: NodeProps) => <LoopGroupNode data={data as WorkflowFlowNodeData} />,
|
||||
"optional-group": ({ data }: NodeProps) => <OptionalGroupNode data={data as WorkflowFlowNodeData} />,
|
||||
"step-review": ({ data }: NodeProps) => <NodeShell data={data as WorkflowFlowNodeData} kind="step-review" />,
|
||||
"parse-steps": ({ data }: NodeProps) => <NodeShell data={data as WorkflowFlowNodeData} kind="parse-steps" />,
|
||||
code: ({ data }: NodeProps) => <NodeShell data={data as WorkflowFlowNodeData} kind="code" />,
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { effectiveNodeKind, nodeHelpFor, nodeHelpForData } from "../node-help";
|
||||
import type { WorkflowFlowNodeData } from "../WorkflowNodeTypes";
|
||||
|
||||
/** All editor kinds plus the graph-only IR kinds the help registry must cover.
|
||||
* Kept inline (not imported from core) so a missing entry fails loudly here. */
|
||||
const EDITOR_KINDS = [
|
||||
"start",
|
||||
"end",
|
||||
"prompt",
|
||||
"script",
|
||||
"gate",
|
||||
"merge",
|
||||
"hold",
|
||||
"split",
|
||||
"join",
|
||||
"foreach",
|
||||
"loop",
|
||||
"optional-group",
|
||||
"step-review",
|
||||
"parse-steps",
|
||||
"code",
|
||||
"notify",
|
||||
] as const;
|
||||
|
||||
const GRAPH_ONLY_KINDS = [
|
||||
"merge-gate",
|
||||
"merge-attempt",
|
||||
"manual-merge-hold",
|
||||
"retry-backoff",
|
||||
"recovery-router",
|
||||
"branch-group-member-integration",
|
||||
"branch-group-promotion",
|
||||
"pr-create",
|
||||
"pr-respond",
|
||||
"pr-merge",
|
||||
] as const;
|
||||
|
||||
describe("nodeHelpFor", () => {
|
||||
it("returns help for every editor node kind", () => {
|
||||
for (const kind of EDITOR_KINDS) {
|
||||
const help = nodeHelpFor(kind);
|
||||
expect(help, `missing help for editor kind ${kind}`).not.toBeNull();
|
||||
// Every node documents what it does and its I/O + edges.
|
||||
expect(help!.title).toBeTruthy();
|
||||
expect(help!.summary).toBeTruthy();
|
||||
expect(help!.inputs).toBeTruthy();
|
||||
expect(help!.outputs).toBeTruthy();
|
||||
expect(help!.edges).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
it("returns help for every graph-only policy node kind, flagged engine-managed", () => {
|
||||
for (const kind of GRAPH_ONLY_KINDS) {
|
||||
const help = nodeHelpFor(kind);
|
||||
expect(help, `missing help for graph-only kind ${kind}`).not.toBeNull();
|
||||
expect(help!.graphOnly).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("editor kinds are not flagged engine-managed", () => {
|
||||
for (const kind of EDITOR_KINDS) {
|
||||
expect(nodeHelpFor(kind)!.graphOnly).toBeFalsy();
|
||||
}
|
||||
});
|
||||
|
||||
it("returns null for an unknown kind", () => {
|
||||
expect(nodeHelpFor("not-a-kind")).toBeNull();
|
||||
});
|
||||
|
||||
it("describes branch-group promotion's single-managed-PR idempotency", () => {
|
||||
const help = nodeHelpFor("branch-group-promotion")!;
|
||||
expect(help.summary).toMatch(/single managed PR/i);
|
||||
expect(help.summary).toMatch(/never creates a second PR/i);
|
||||
expect(help.edges).toMatch(/merge attempt/i);
|
||||
});
|
||||
|
||||
it("distinguishes member integration (off-switch exempt) from promotion (gated)", () => {
|
||||
expect(nodeHelpFor("branch-group-member-integration")!.summary).toMatch(/even when global auto-merge is off/i);
|
||||
expect(nodeHelpFor("branch-group-promotion")!.summary).toMatch(/[Gg]ated by group\/global auto-merge/);
|
||||
});
|
||||
|
||||
it("merge gate documents its auto-on / auto-off routing", () => {
|
||||
const help = nodeHelpFor("merge-gate")!;
|
||||
expect(help.edges).toMatch(/auto-on/);
|
||||
expect(help.edges).toMatch(/auto-off/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("effectiveNodeKind / nodeHelpForData", () => {
|
||||
function data(kind: WorkflowFlowNodeData["kind"], irKind?: string): WorkflowFlowNodeData {
|
||||
return { kind, label: kind, ...(irKind ? { irKind } : {}) };
|
||||
}
|
||||
|
||||
it("prefers the preserved IR kind over the collapsed editor kind", () => {
|
||||
// A branch-group-promotion node renders as a generic "merge" shape but
|
||||
// preserves its IR kind so the help stays specific.
|
||||
const d = data("merge", "branch-group-promotion");
|
||||
expect(effectiveNodeKind(d)).toBe("branch-group-promotion");
|
||||
expect(nodeHelpForData(d)!.title).toBe("Branch group · promotion");
|
||||
});
|
||||
|
||||
it("falls back to the editor kind when no IR kind is preserved", () => {
|
||||
const d = data("merge");
|
||||
expect(effectiveNodeKind(d)).toBe("merge");
|
||||
expect(nodeHelpForData(d)!.title).toBe("Merge boundary");
|
||||
});
|
||||
});
|
||||
306
packages/dashboard/app/components/nodes/node-help.ts
Normal file
306
packages/dashboard/app/components/nodes/node-help.ts
Normal file
@@ -0,0 +1,306 @@
|
||||
import type { WorkflowEditorNodeKind, WorkflowFlowNodeData } from "./WorkflowNodeTypes";
|
||||
|
||||
/*
|
||||
FNXC:WorkflowEditor 2026-06-21-10:00:
|
||||
The node detail pane must teach, not just edit. Every workflow node — including the engine-managed graph-only policy nodes (merge gate, branch-group member integration / promotion, PR nodes, recovery/retry) — needs an in-editor Help section describing what it does, how to configure it, and its inputs/outputs/edges. This was prompted by a user unable to tell what "branch-group-member-integration", "branch-group-promotion", and "merge gate" meant in the editor.
|
||||
|
||||
Help is keyed by the node's EFFECTIVE kind: the preserved original IR kind (`data.irKind`) when present, else the editor kind (`data.kind`). Graph-only IR kinds collapse to merge/gate/hold editor shapes via GRAPH_ONLY_EDITOR_KIND, so without the preserved kind the branch-group/PR/merge nodes would all read as a generic "merge"/"gate".
|
||||
|
||||
Per-node body text is English reference documentation (analogous to node-summary's raw, untranslated config values); only the repeated structural section labels are routed through i18n by the inspector. Keep this content in sync when node config fields or edge routing change.
|
||||
*/
|
||||
|
||||
/** A node's effective kind for help lookup: the preserved original IR kind when
|
||||
* the editor collapsed a graph-only policy node onto a generic shape, else the
|
||||
* editor kind. Mirrors workflow-flow-mapping's `preservedIrKind`. */
|
||||
export function effectiveNodeKind(data: WorkflowFlowNodeData): string {
|
||||
return typeof data.irKind === "string" ? data.irKind : data.kind;
|
||||
}
|
||||
|
||||
export interface NodeHelp {
|
||||
/** Human title for the node kind (the inspector heading reuses this). */
|
||||
title: string;
|
||||
/** One- to two-sentence description of what the node does. */
|
||||
summary: string;
|
||||
/** How to configure it. Omitted for structural nodes with no config. */
|
||||
configure?: string;
|
||||
/** What arrives at the node (incoming edges / available context). */
|
||||
inputs: string;
|
||||
/** What the node produces / passes downstream. */
|
||||
outputs: string;
|
||||
/** Outgoing edges and the conditions/outcomes that route them. */
|
||||
edges: string;
|
||||
/** Engine-managed policy node: surfaced read-only, not hand-authored. The
|
||||
* inspector shows an "Engine-managed" badge for these. */
|
||||
graphOnly?: boolean;
|
||||
}
|
||||
|
||||
/** Help content keyed by effective node kind. Covers every editor kind plus the
|
||||
* graph-only IR kinds (merge lifecycle, branch groups, PR mode, recovery). */
|
||||
const NODE_HELP: Record<string, NodeHelp> = {
|
||||
// ── Editor (user-authored) kinds ──────────────────────────────────────────
|
||||
start: {
|
||||
title: "Start",
|
||||
summary: "Marks where a task enters the workflow. Every workflow has exactly one start node.",
|
||||
configure:
|
||||
"Set the Entry column to choose which board column a task lands in when it enters (v2 workflows). Leave on Auto to use the first column.",
|
||||
inputs: "None — this is the entry point.",
|
||||
outputs: "Hands the task to the first downstream node.",
|
||||
edges: "One outgoing edge (success). No incoming edges.",
|
||||
},
|
||||
end: {
|
||||
title: "End",
|
||||
summary: "A terminal state. A task that reaches an end node is finished on that path.",
|
||||
inputs: "One or more incoming edges.",
|
||||
outputs: "None — the task stops here.",
|
||||
edges: "Incoming edges only; no outgoing edges.",
|
||||
},
|
||||
prompt: {
|
||||
title: "Prompt (agent step)",
|
||||
summary:
|
||||
"Runs a unit of work against the task — an AI model, a named agent, a skill, or a CLI command. The workhorse node for executing, planning, and reviewing.",
|
||||
configure:
|
||||
"Write the Prompt, then pick an Executor (model, agent, skill, CLI, or CLI-agent) and its options (model, agent, skill, or command). Optionally set Gate mode (advisory vs blocking), Max retries, Auto-approve, or Wait for user input.",
|
||||
inputs: "The task plus any prior step output and context.",
|
||||
outputs: "The step's result, passed downstream; may record a gate verdict.",
|
||||
edges: "success / failure outgoing edges. As a blocking gate it can stop the task on failure.",
|
||||
},
|
||||
script: {
|
||||
title: "Script",
|
||||
summary: "Runs a named project script (defined in project settings) as a workflow step.",
|
||||
configure:
|
||||
"Set Script name to a script from project settings. Set Gate mode to choose whether a non-zero exit blocks the task. The node prompt is passed to the script via FUSION_NODE_PROMPT.",
|
||||
inputs: "The task; the node prompt via FUSION_NODE_PROMPT.",
|
||||
outputs: "The script's exit status and output.",
|
||||
edges: "success / failure.",
|
||||
},
|
||||
gate: {
|
||||
title: "Gate",
|
||||
summary:
|
||||
"A decision checkpoint that evaluates a prompt and routes the task by its verdict, optionally blocking progress.",
|
||||
configure:
|
||||
"Write the gate Prompt. Set Gate mode to Advisory (records a verdict but never blocks) or Gate (blocks the task on failure).",
|
||||
inputs: "The task plus prior context.",
|
||||
outputs: "A pass/fail (or outcome) verdict.",
|
||||
edges: "success / failure; a blocking gate holds the task on failure.",
|
||||
},
|
||||
merge: {
|
||||
title: "Merge boundary",
|
||||
summary:
|
||||
"A marker separating pre-merge from post-merge steps. Steps before it run before the branch merges; steps after run after.",
|
||||
configure: "No fields to set — placement is what matters. Position it where the merge happens in your pipeline.",
|
||||
inputs: "The task after upstream steps complete.",
|
||||
outputs: "Passes the task to post-merge steps.",
|
||||
edges: "One outgoing edge (success).",
|
||||
},
|
||||
hold: {
|
||||
title: "Hold",
|
||||
summary:
|
||||
"Pauses the task until a release condition is met — a manual promote, a timer, downstream capacity, a dependency, or an external event.",
|
||||
configure:
|
||||
"Pick a Release condition: Manual promote, Timer, Downstream capacity, Dependency complete, or External event.",
|
||||
inputs: "The task arriving from upstream.",
|
||||
outputs: "Releases the task downstream once the condition is satisfied.",
|
||||
edges: "One outgoing edge (success), taken once released.",
|
||||
},
|
||||
split: {
|
||||
title: "Split (parallel branch)",
|
||||
summary:
|
||||
"Fans the task out into multiple branches that run concurrently. Pair with a Join downstream to recombine them.",
|
||||
configure: "No fields to set — connect multiple outgoing edges; each becomes a parallel branch.",
|
||||
inputs: "A single task path.",
|
||||
outputs: "Multiple concurrent branches.",
|
||||
edges: "Multiple outgoing edges, one per branch. Recombine with a Join.",
|
||||
},
|
||||
join: {
|
||||
title: "Join",
|
||||
summary: "Waits for parallel branches (from a Split) and recombines them according to a join policy.",
|
||||
configure:
|
||||
"Set Join mode: All branches, Any branch, or Quorum (n) with a count. Set On branch failure to Collect (wait for all) or Fail-fast (cancel siblings).",
|
||||
inputs: "Multiple parallel branches.",
|
||||
outputs: "A single resumed path once the join policy is satisfied.",
|
||||
edges: "One outgoing edge (success), taken when the join condition is met.",
|
||||
},
|
||||
foreach: {
|
||||
title: "For-each",
|
||||
summary:
|
||||
"Runs a template of steps once per item (e.g. per parsed step), sequentially or in parallel. Renders as a group you drop step nodes into.",
|
||||
configure:
|
||||
"Set Mode (sequential/parallel), Isolation (shared or per-step worktree), Concurrency (parallel only), and Max rework cycles (the bound on rework loop-backs). Drop a step-execute node inside.",
|
||||
inputs: "A collection of items (e.g. parsed steps) plus the task.",
|
||||
outputs: "Aggregated per-item results.",
|
||||
edges:
|
||||
"success once all iterations finish. Internal rework edges loop back within a step instance, bounded by Max rework cycles.",
|
||||
},
|
||||
loop: {
|
||||
title: "Loop",
|
||||
summary:
|
||||
"Repeats a template of steps until an exit condition is met or a cap is hit. Renders as a group you drop loop steps into.",
|
||||
configure:
|
||||
"Set the Exit condition (output contains / output matches regex) and its value or pattern, an optional Watch node id, Max iterations, and Timeout (ms).",
|
||||
inputs: "The task plus the loop body steps.",
|
||||
outputs: "The final iteration's result.",
|
||||
edges: "One outgoing edge (success) on exit. Exits on condition match, max iterations, or timeout.",
|
||||
},
|
||||
// FNXC:WorkflowOptionalGroup 2026-06-21-11:30: An optional-group is a container whose body runs once when the task enables it and is skipped otherwise. Enable state is the per-task `enabledWorkflowSteps` facet, seeded from the group's `defaultOn`.
|
||||
"optional-group": {
|
||||
title: "Optional group",
|
||||
summary:
|
||||
"Holds a group of steps that run only when the task has this group enabled. Enabled tasks run the group's steps once at this position; disabled tasks pass straight through. Renders as a group you drop step nodes into.",
|
||||
configure:
|
||||
"Set the group Name and whether it is Enabled by default for new tasks (defaultOn). A task can override the default per-task. Drop the optional steps inside the region.",
|
||||
inputs: "The task arriving from upstream, plus prior context.",
|
||||
outputs: "The group's result when enabled; an unchanged pass-through when disabled.",
|
||||
edges:
|
||||
"success once the group finishes (or is skipped). A template failure inside an enabled group routes the group's failure edge.",
|
||||
},
|
||||
"step-review": {
|
||||
title: "Step review",
|
||||
summary:
|
||||
"An AI review gate that emits a verdict (approve / revise / rethink / unavailable) used to route the task — typically back for rework or forward on approval.",
|
||||
configure:
|
||||
"Set Review type (plan or code) and an optional Review model. Route each outgoing edge by verdict; mark a loop-back edge as Rework.",
|
||||
inputs: "The artifact or step output to review.",
|
||||
outputs: "A verdict: approve, revise, rethink, or unavailable.",
|
||||
edges:
|
||||
"Verdict edges (outcome:approve / revise / rethink / unavailable). A rework edge loops back, bounded by Max rework cycles.",
|
||||
},
|
||||
"parse-steps": {
|
||||
title: "Parse steps",
|
||||
summary:
|
||||
"Parses a task artifact (e.g. PROMPT.md) into discrete steps a downstream for-each can iterate over.",
|
||||
configure: "Pick the Artifact to parse (e.g. PROMPT.md) and the Parser (e.g. step-headings, plus any plugin parsers).",
|
||||
inputs: "A task artifact or document.",
|
||||
outputs: "A list of parsed steps for a downstream for-each.",
|
||||
edges: "success / failure.",
|
||||
},
|
||||
code: {
|
||||
title: "Code",
|
||||
summary:
|
||||
"Runs a sandboxed TypeScript snippet as a workflow step — for lightweight transforms, routing, or computed values.",
|
||||
configure: "Write the TypeScript Source and an optional Timeout (ms). Syntax is validated at save.",
|
||||
inputs: "Task context available to the snippet.",
|
||||
outputs: "The snippet's return value.",
|
||||
edges: "success / failure.",
|
||||
},
|
||||
notify: {
|
||||
title: "Notify",
|
||||
summary:
|
||||
"Emits a notification event (and optional title/message) without changing the task's path — for pings on state changes.",
|
||||
configure:
|
||||
"Pick an Event type (or a Custom event) and optional Title/Message. Templates may use {{taskTitle}}, {{taskId}}, {{workflowName}}, and {{context:key}}.",
|
||||
inputs: "The task at this point in the flow.",
|
||||
outputs: "A notification event; the task continues unchanged.",
|
||||
edges: "One outgoing edge (success); the node is pass-through.",
|
||||
},
|
||||
|
||||
// ── Graph-only (engine-managed) IR kinds ──────────────────────────────────
|
||||
"merge-gate": {
|
||||
title: "Auto-merge gate",
|
||||
summary:
|
||||
"Checks whether the task is ready to auto-merge: a live PR/merge entity exists, auto-merge is opted in, and the entity is merge-ready (approved, checks green, mergeable clean).",
|
||||
configure: "Engine-managed checkpoint — not hand-edited. Governed by the project and task auto-merge settings.",
|
||||
inputs: "An approved task with its PR/merge entity.",
|
||||
outputs: "An auto-on / auto-off decision.",
|
||||
edges:
|
||||
"outcome:auto-on → branch-group member integration; auto-off → parks at the manual merge hold for a human.",
|
||||
graphOnly: true,
|
||||
},
|
||||
"merge-attempt": {
|
||||
title: "Merge attempt",
|
||||
summary:
|
||||
"Performs the actual merge of the task's branch toward the integration/default branch (squash by project default), with conflict and post-merge audit handling.",
|
||||
configure: "Engine-managed — not hand-edited. Follows the project's merge strategy and audit settings.",
|
||||
inputs: "A promotion-ready branch.",
|
||||
outputs: "A merged branch, or a conflict requiring manual resolution.",
|
||||
edges: "success → end; conflict/failure → manual merge hold.",
|
||||
graphOnly: true,
|
||||
},
|
||||
"manual-merge-hold": {
|
||||
title: "Manual merge hold",
|
||||
summary:
|
||||
"Parks the task in review for a human to merge when auto-merge is off or a step needs manual resolution. While auto-merge is off, in-review is terminal until a person merges.",
|
||||
configure: "Engine-managed park state — not hand-edited.",
|
||||
inputs: "A task blocked from auto-merge, or one with a merge conflict.",
|
||||
outputs: "A human-resolved merge that resumes the flow.",
|
||||
edges: "On manual resolution, loops back into integration/merge (rework).",
|
||||
graphOnly: true,
|
||||
},
|
||||
"retry-backoff": {
|
||||
title: "Retry backoff",
|
||||
summary: "Waits a backoff interval before retrying a failed step, bounded by a retry budget.",
|
||||
configure: "Engine-managed — not hand-edited.",
|
||||
inputs: "A failed step eligible for retry.",
|
||||
outputs: "A delayed retry of the step.",
|
||||
edges: "Loops back to the step until the retry budget is exhausted.",
|
||||
graphOnly: true,
|
||||
},
|
||||
"recovery-router": {
|
||||
title: "Recovery router",
|
||||
summary:
|
||||
"A self-healing decision point that routes a stuck or interrupted task onto the right recovery path (retry, rebound, or escalate).",
|
||||
configure: "Engine-managed — not hand-edited.",
|
||||
inputs: "A task in an anomalous or interrupted state.",
|
||||
outputs: "A recovery-route decision.",
|
||||
edges: "Branches to retry, rebound, or manual paths by recovery outcome.",
|
||||
graphOnly: true,
|
||||
},
|
||||
"branch-group-member-integration": {
|
||||
title: "Branch group · member integration",
|
||||
summary:
|
||||
"For a task in a shared branch group, integrates this member's work onto the group's shared branch. A soft pre-integration step that runs even when global auto-merge is off (it only assembles the group branch).",
|
||||
configure: "Engine-managed — not hand-edited. Active only for shared-branch-group members.",
|
||||
inputs: "An approved group-member task and the group's shared branch.",
|
||||
outputs: "The member's work landed on the shared branch.",
|
||||
edges: "success → branch group promotion; manual-required → manual merge hold.",
|
||||
graphOnly: true,
|
||||
},
|
||||
"branch-group-promotion": {
|
||||
title: "Branch group · promotion",
|
||||
summary:
|
||||
"Once all members have landed on the shared branch, carries the complete group forward — merging the group branch toward the integration branch and creating-or-reusing the group's single managed PR. Idempotent: re-running never creates a second PR. Gated by group/global auto-merge.",
|
||||
configure: "Engine-managed — not hand-edited. Runs once the group is complete and auto-merge is eligible.",
|
||||
inputs: "A complete shared branch group (all members landed).",
|
||||
outputs: "The group promoted toward the integration branch, plus its single managed PR.",
|
||||
edges: "success → merge attempt; manual-required → manual merge hold.",
|
||||
graphOnly: true,
|
||||
},
|
||||
"pr-create": {
|
||||
title: "PR create",
|
||||
summary: "Creates (or reuses) the pull request for the task in pull-request merge mode.",
|
||||
configure: "Engine-managed — not hand-edited. Active in pull-request merge mode.",
|
||||
inputs: "A task branch ready for review.",
|
||||
outputs: "An open PR entity (created or reused).",
|
||||
edges: "success → the PR review/merge path.",
|
||||
graphOnly: true,
|
||||
},
|
||||
"pr-respond": {
|
||||
title: "PR respond",
|
||||
summary:
|
||||
"Responds to PR review feedback — addressing comments and pushing follow-up commits — during the PR review cycle.",
|
||||
configure: "Engine-managed — not hand-edited.",
|
||||
inputs: "PR review comments and threads.",
|
||||
outputs: "Replies and follow-up commits on the PR.",
|
||||
edges: "Loops within the PR review cycle until feedback is resolved.",
|
||||
graphOnly: true,
|
||||
},
|
||||
"pr-merge": {
|
||||
title: "PR merge",
|
||||
summary: "Merges the pull request once it is approved and all checks pass, in pull-request mode.",
|
||||
configure: "Engine-managed — not hand-edited. Governed by auto-merge readiness.",
|
||||
inputs: "An approved, green PR.",
|
||||
outputs: "A merged PR.",
|
||||
edges: "success → end; blocked → manual merge hold.",
|
||||
graphOnly: true,
|
||||
},
|
||||
};
|
||||
|
||||
/** Resolve help for a node by its effective kind, or null when none is known
|
||||
* (callers skip rendering the Help section). */
|
||||
export function nodeHelpFor(kind: WorkflowEditorNodeKind | string): NodeHelp | null {
|
||||
return NODE_HELP[kind] ?? null;
|
||||
}
|
||||
|
||||
/** Resolve help for a flow node, honoring the preserved IR kind. */
|
||||
export function nodeHelpForData(data: WorkflowFlowNodeData): NodeHelp | null {
|
||||
return nodeHelpFor(effectiveNodeKind(data));
|
||||
}
|
||||
@@ -9,7 +9,6 @@ import type {
|
||||
WorkflowDefinition,
|
||||
WorkflowFieldDefinition,
|
||||
WorkflowSettingDefinition,
|
||||
WorkflowOptionalStep,
|
||||
} from "@fusion/core";
|
||||
import type { WorkflowFlowNodeData, WorkflowEditorNodeKind } from "./nodes/WorkflowNodeTypes";
|
||||
|
||||
@@ -38,6 +37,16 @@ interface WorkflowLoopConfig {
|
||||
template: { nodes: WorkflowIrNode[]; edges: WorkflowIrEdge[] };
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:WorkflowOptionalGroup 2026-06-21-11:30:
|
||||
An `optional-group` is a third container kind alongside `foreach`/`loop`. It carries `defaultOn`/`name` plus a `template:{nodes,edges}` subgraph authored inline as React Flow `parentId` children (reusing the `foreachChildFlowId` namespacing). It is special-cased everywhere foreach/loop are: group-template detection, child reassembly in flowToIr, intra-template edge folding, cascade delete, and condition-editability. Single-pass, no rework/iteration — but the editor mapping treats its template identically to foreach/loop.
|
||||
*/
|
||||
interface WorkflowOptionalGroupConfig {
|
||||
defaultOn?: boolean;
|
||||
name?: string;
|
||||
template: { nodes: WorkflowIrNode[]; edges: WorkflowIrEdge[] };
|
||||
}
|
||||
|
||||
// WorkflowFieldDefinition is imported from @fusion/core above (KTD-13/14).
|
||||
// Re-exported so existing importers that reference WorkflowFieldDefinitionShape
|
||||
// can migrate; callers should prefer WorkflowFieldDefinition directly.
|
||||
@@ -170,6 +179,7 @@ const SAME_KIND_EDITOR_NODE_KINDS = new Set<WorkflowIrNodeKind>([
|
||||
"join",
|
||||
"foreach",
|
||||
"loop",
|
||||
"optional-group",
|
||||
"step-review",
|
||||
"parse-steps",
|
||||
"code",
|
||||
@@ -263,10 +273,17 @@ function loopConfigOf(node: WorkflowIrNode): WorkflowLoopConfig | undefined {
|
||||
return cfg as WorkflowLoopConfig;
|
||||
}
|
||||
|
||||
function optionalGroupConfigOf(node: WorkflowIrNode): WorkflowOptionalGroupConfig | undefined {
|
||||
if (node.kind !== "optional-group") return undefined;
|
||||
const cfg = node.config as Partial<WorkflowOptionalGroupConfig> | undefined;
|
||||
if (!cfg || !cfg.template) return undefined;
|
||||
return cfg as WorkflowOptionalGroupConfig;
|
||||
}
|
||||
|
||||
function groupTemplateConfigOf(
|
||||
node: WorkflowIrNode,
|
||||
): WorkflowForeachConfig | WorkflowLoopConfig | undefined {
|
||||
return foreachConfigOf(node) ?? loopConfigOf(node);
|
||||
): WorkflowForeachConfig | WorkflowLoopConfig | WorkflowOptionalGroupConfig | undefined {
|
||||
return foreachConfigOf(node) ?? loopConfigOf(node) ?? optionalGroupConfigOf(node);
|
||||
}
|
||||
|
||||
/** CSS class for an edge given its condition + rework kind. Rework takes
|
||||
@@ -435,7 +452,6 @@ export function flowToIr(
|
||||
columns?: WorkflowIrColumn[],
|
||||
fields?: WorkflowFieldDefinition[],
|
||||
settings?: WorkflowSettingDefinition[],
|
||||
optionalSteps?: WorkflowOptionalStep[],
|
||||
): { ir: WorkflowIr; layout: Record<string, { x: number; y: number }> } {
|
||||
const realNodes = nodes.filter((n) => !isColumnBandNode(n.id));
|
||||
// Partition by parentId: foreach group children reassemble into that group's
|
||||
@@ -451,19 +467,25 @@ export function flowToIr(
|
||||
}
|
||||
}
|
||||
const groupIds = new Set(
|
||||
topNodes.filter((n) => n.data.kind === "foreach" || n.data.kind === "loop").map((n) => n.id),
|
||||
topNodes
|
||||
.filter((n) => n.data.kind === "foreach" || n.data.kind === "loop" || n.data.kind === "optional-group")
|
||||
.map((n) => n.id),
|
||||
);
|
||||
const hasFields = Array.isArray(fields) && fields.length > 0;
|
||||
const hasSettings = Array.isArray(settings) && settings.length > 0;
|
||||
const hasOptionalSteps = Array.isArray(optionalSteps) && optionalSteps.length > 0;
|
||||
// FNXC:WorkflowOptionalSteps 2026-06-21-00:00:
|
||||
// Optional steps must round-trip through the node editor without data loss, yet
|
||||
// must never upgrade a legacy v1 graph. Fields, settings, and optional steps are
|
||||
// v2-only declarations: a workflow with any of them but no custom columns still
|
||||
// serializes as v2 (with the synthesized default columns). Empty/absent → not a
|
||||
// v2 signal, and the key is omitted entirely (R6 byte-identity for legacy graphs).
|
||||
// FNXC:WorkflowOptionalGroup 2026-06-21-18:00:
|
||||
// The editor no longer AUTHORS legacy `optionalSteps` declarations — optional
|
||||
// steps are graph-native `optional-group` nodes carried through the normal
|
||||
// node/edge mapping. Fields and settings remain v2-only declarations: a workflow
|
||||
// with either but no custom columns still serializes as v2 (with the synthesized
|
||||
// default columns). Empty/absent → not a v2 signal (R6 byte-identity for legacy).
|
||||
// FNXC:WorkflowOptionalGroup 2026-06-22-09:00: a container/group node
|
||||
// (foreach/loop/optional-group) is a v2-ONLY kind — its presence must force v2,
|
||||
// or an inserted optional-group on an otherwise-plain workflow would serialize
|
||||
// as v1 and fail parse (validateOptionalGroup runs only on v2). (Code review:
|
||||
// CodeRabbit — corroborated by the pre-merge correctness review's residual risk.)
|
||||
const v2 =
|
||||
(Array.isArray(columns) && columns.length > 0) || hasFields || hasSettings || hasOptionalSteps;
|
||||
(Array.isArray(columns) && columns.length > 0) || hasFields || hasSettings || groupIds.size > 0;
|
||||
const layout: Record<string, { x: number; y: number }> = {};
|
||||
|
||||
/** Project one flow node (top-level or template child) into an IR node. */
|
||||
@@ -477,8 +499,19 @@ export function flowToIr(
|
||||
}
|
||||
return { id: localId, kind: "prompt", config: { ...(config ?? {}), seam: "merge" } };
|
||||
}
|
||||
if (data.kind === "foreach" || data.kind === "loop" || originalKind === "retry-backoff") {
|
||||
if (originalKind && originalKind !== "foreach" && originalKind !== "loop" && originalKind !== "retry-backoff") {
|
||||
if (
|
||||
data.kind === "foreach" ||
|
||||
data.kind === "loop" ||
|
||||
data.kind === "optional-group" ||
|
||||
originalKind === "retry-backoff"
|
||||
) {
|
||||
if (
|
||||
originalKind &&
|
||||
originalKind !== "foreach" &&
|
||||
originalKind !== "loop" &&
|
||||
originalKind !== "optional-group" &&
|
||||
originalKind !== "retry-backoff"
|
||||
) {
|
||||
return { id: localId, kind: originalKind, config: config && Object.keys(config).length ? config : undefined };
|
||||
}
|
||||
// Reassemble the template from this group's children.
|
||||
@@ -564,12 +597,6 @@ export function flowToIr(
|
||||
render: s.render ? { ...s.render } : undefined,
|
||||
}));
|
||||
}
|
||||
if (hasOptionalSteps) {
|
||||
// Optional-step DECLARATIONS round-trip through the editor opaquely (they are
|
||||
// not graph nodes; the resolver + server validator are the source of truth).
|
||||
// Omitted entirely when empty so legacy graphs stay byte-identical (R6).
|
||||
(ir as { optionalSteps?: unknown }).optionalSteps = optionalSteps!.map((o) => ({ ...o }));
|
||||
}
|
||||
return { ir, layout };
|
||||
}
|
||||
|
||||
@@ -608,9 +635,10 @@ function isProtectedFromDelete(node: FlowNode<WorkflowFlowNodeData>): boolean {
|
||||
* Delete the requested node and/or edge ids from the flow graph, applying R6's
|
||||
* cascade rules:
|
||||
* - Deleting a node removes ALL edges incident to it (no auto-bridging).
|
||||
* - Deleting a `foreach`/`loop` group node also deletes its template children
|
||||
* (nodes with `parentId === groupId`) and every edge incident to those
|
||||
* children (React Flow does not cascade parents — handled explicitly).
|
||||
* - Deleting a `foreach`/`loop`/`optional-group` group node also deletes its
|
||||
* template children (nodes with `parentId === groupId`) and every edge
|
||||
* incident to those children (React Flow does not cascade parents — handled
|
||||
* explicitly).
|
||||
* - `start`/`end` nodes and column band nodes are never deleted: they are
|
||||
* filtered out of the requested ids up front (and their incident edges are
|
||||
* therefore preserved).
|
||||
@@ -633,7 +661,7 @@ export function cascadeDelete(
|
||||
const node = nodeById.get(id);
|
||||
if (!node || isProtectedFromDelete(node)) continue;
|
||||
deleteNodeIds.add(id);
|
||||
if (node.data.kind === "foreach" || node.data.kind === "loop") {
|
||||
if (node.data.kind === "foreach" || node.data.kind === "loop" || node.data.kind === "optional-group") {
|
||||
for (const child of nodes) {
|
||||
if (child.parentId === id) deleteNodeIds.add(child.id);
|
||||
}
|
||||
@@ -660,7 +688,7 @@ export function cascadeDelete(
|
||||
|
||||
/** Editor node kinds whose edges expose a success/failure condition select
|
||||
* (KTD-2). step-review uses verdict controls; all other kinds are read-only. */
|
||||
const CONDITION_EDITABLE_KINDS = new Set<string>(["prompt", "script", "gate", "code", "foreach", "loop"]);
|
||||
const CONDITION_EDITABLE_KINDS = new Set<string>(["prompt", "script", "gate", "code", "foreach", "loop", "optional-group"]);
|
||||
|
||||
/** Decide what the edge inspector renders for an edge sourced from `sourceKind`:
|
||||
* - "verdicts": step-review verdict select + rework checkbox (existing);
|
||||
@@ -981,15 +1009,11 @@ export function settingsOf(def: WorkflowDefinition): WorkflowSettingDefinition[]
|
||||
}));
|
||||
}
|
||||
|
||||
/** Extract the editor's working optional-step declaration list from a definition.
|
||||
* v2 with `optionalSteps` → a shallow copy; v1 or none → empty. Display metadata
|
||||
* (name/icon/phase) is NOT carried here — it is resolved from the step-template
|
||||
* catalog at render time so the resolver stays the single source of truth. */
|
||||
export function optionalStepsOf(def: WorkflowDefinition): WorkflowOptionalStep[] {
|
||||
const ir = def.ir as { optionalSteps?: WorkflowOptionalStep[] };
|
||||
if (!isV2(def.ir) || !Array.isArray(ir.optionalSteps)) return [];
|
||||
return ir.optionalSteps.map((o) => ({ ...o }));
|
||||
}
|
||||
/* FNXC:WorkflowOptionalGroup 2026-06-21-18:00:
|
||||
`optionalStepsOf` (the editor's legacy `optionalSteps` declaration extractor)
|
||||
is removed. Optional steps are graph-native `optional-group` nodes now; the
|
||||
editor reads/writes them through the normal node/edge mapping, and the per-task
|
||||
toggle surfaces resolve them via `resolveWorkflowOptionalSteps`. */
|
||||
|
||||
/** Seed graph for a brand-new workflow: start → end with room to insert steps. */
|
||||
export function emptyWorkflowIr(name: string): WorkflowIr {
|
||||
@@ -1218,6 +1242,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
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { BUILTIN_CODING_WORKFLOW_IR } from "@fusion/core";
|
||||
import type { TaskDetail } from "@fusion/core";
|
||||
|
||||
import { WorkflowGraphExecutor, type WorkflowNodeHandler } from "../workflow-graph-executor.js";
|
||||
|
||||
/*
|
||||
FNXC:WorkflowOptionalGroup 2026-06-21-15:10:
|
||||
Built-in-level execution coverage for U6: the coding workflow now expresses the
|
||||
pre-merge browser-verification step as an `optional-group` (default OFF). This is
|
||||
the dead-toggle / two-task divergence guard at the BUILT-IN level (not just the
|
||||
generic construct): two coding tasks identical except `enabledWorkflowSteps` must
|
||||
diverge — the one including the group id runs the browser-verification prompt node
|
||||
pre-merge; the sibling runs NONE and still reaches review. Real executor runs (not
|
||||
traversal-only) so a mock-masked dead path cannot pass.
|
||||
|
||||
The inner template node id is `browser-verification-step` (distinct from the group
|
||||
id `browser-verification` per the U1 template-node-id collision rule), and its
|
||||
materialized visited id is `browser-verification::browser-verification-step`.
|
||||
*/
|
||||
|
||||
const settingsOn = () => ({ experimentalFeatures: { workflowGraphExecutor: true } });
|
||||
|
||||
const GROUP_ID = "browser-verification";
|
||||
const INNER_STEP_VISITED_ID = "browser-verification::browser-verification-step";
|
||||
|
||||
function codingTask(enabledWorkflowSteps?: string[]): TaskDetail {
|
||||
return {
|
||||
id: "FN-CODING",
|
||||
...(enabledWorkflowSteps ? { enabledWorkflowSteps } : {}),
|
||||
} as unknown as TaskDetail;
|
||||
}
|
||||
|
||||
/** Count how many times the inner browser-verification prompt node ran. A prompt
|
||||
* handler keyed on the inner template node id; everything else succeeds. */
|
||||
function makeExecutor(onInnerStep: () => void) {
|
||||
const prompt = vi.fn<WorkflowNodeHandler>(async (node) => {
|
||||
if (node.id === "browser-verification-step") onInnerStep();
|
||||
return { outcome: "success" };
|
||||
});
|
||||
return new WorkflowGraphExecutor({ handlers: { prompt } });
|
||||
}
|
||||
|
||||
describe("builtin coding browser-verification optional-group (U6)", () => {
|
||||
it("two-task divergence: the enabled task runs browser-verification pre-merge; the disabled task does not", async () => {
|
||||
// Enabled.
|
||||
let enabledRuns = 0;
|
||||
const enabledResult = await makeExecutor(() => {
|
||||
enabledRuns++;
|
||||
}).run(codingTask([GROUP_ID]), settingsOn(), BUILTIN_CODING_WORKFLOW_IR);
|
||||
|
||||
// Disabled (no enabledWorkflowSteps).
|
||||
let disabledRuns = 0;
|
||||
const disabledResult = await makeExecutor(() => {
|
||||
disabledRuns++;
|
||||
}).run(codingTask(), settingsOn(), BUILTIN_CODING_WORKFLOW_IR);
|
||||
|
||||
// The browser-verification step ran exactly once when enabled, never when off.
|
||||
expect(enabledRuns).toBe(1);
|
||||
expect(disabledRuns).toBe(0);
|
||||
|
||||
// Enabled: the inner template node is visited pre-merge (before review).
|
||||
expect(enabledResult.visitedNodeIds).toContain(INNER_STEP_VISITED_ID);
|
||||
const innerIdx = enabledResult.visitedNodeIds.indexOf(INNER_STEP_VISITED_ID);
|
||||
const reviewIdxEnabled = enabledResult.visitedNodeIds.indexOf("review");
|
||||
const executeIdxEnabled = enabledResult.visitedNodeIds.indexOf("execute");
|
||||
expect(executeIdxEnabled).toBeLessThan(innerIdx);
|
||||
expect(innerIdx).toBeLessThan(reviewIdxEnabled);
|
||||
|
||||
// Disabled: the group node is traversed (bypassed) but its body never runs;
|
||||
// both tasks reach the same downstream review node.
|
||||
expect(disabledResult.visitedNodeIds).toContain(GROUP_ID);
|
||||
expect(disabledResult.visitedNodeIds).not.toContain(INNER_STEP_VISITED_ID);
|
||||
expect(disabledResult.visitedNodeIds).toContain("review");
|
||||
expect(enabledResult.visitedNodeIds).toContain("review");
|
||||
});
|
||||
|
||||
it("a browser-verification failure surfaces as the group's outcome and routes its failure edge to end", async () => {
|
||||
// The inner step fails → the group's failure edge (browser-verification → end)
|
||||
// fires, so review is never reached.
|
||||
const prompt = vi.fn<WorkflowNodeHandler>(async (node) => {
|
||||
if (node.id === "browser-verification-step") return { outcome: "failure", value: "verify-failed" };
|
||||
return { outcome: "success" };
|
||||
});
|
||||
const executor = new WorkflowGraphExecutor({ handlers: { prompt } });
|
||||
|
||||
const result = await executor.run(codingTask([GROUP_ID]), settingsOn(), BUILTIN_CODING_WORKFLOW_IR);
|
||||
|
||||
expect(result.context[`node:${GROUP_ID}:outcome`]).toBe("failure");
|
||||
expect(result.visitedNodeIds).toContain(INNER_STEP_VISITED_ID);
|
||||
// The group's only two outgoing edges are `success → review` and
|
||||
// `failure → end`; the inner-step failure routes the failure edge, so review
|
||||
// is skipped. (`end` is a terminal node the executor does not record in
|
||||
// visitedNodeIds, so the routing is asserted via the group's failure outcome
|
||||
// above + review being unreachable here.)
|
||||
expect(result.visitedNodeIds).not.toContain("review");
|
||||
});
|
||||
});
|
||||
@@ -131,7 +131,11 @@ describe("fast mode workflow/runtime invariants", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("graph executor with builtin:coding selection skips the workflow-step seam in fast mode", async () => {
|
||||
// U6: the coding built-in's pre-merge browser-verification optional-group is
|
||||
// default-OFF (the task sets no enabledWorkflowSteps), so it is bypassed — its
|
||||
// group node is visited but its body never runs and runWorkflowSteps is not
|
||||
// called. Fast mode is irrelevant to a bypassed group; the seam is simply gone.
|
||||
it("graph executor with builtin:coding selection bypasses the disabled browser-verification group", async () => {
|
||||
const { executor } = makeExecutorForTask(task({ executionMode: "fast", worktree: "/tmp/wt" }));
|
||||
const runWorkflowSteps = vi.spyOn(executor as any, "runWorkflowSteps").mockResolvedValue(workflowResult());
|
||||
const seams = {
|
||||
@@ -154,7 +158,9 @@ describe("fast mode workflow/runtime invariants", () => {
|
||||
const result = await runner.run(task({ id: "FN-6226", executionMode: "fast" }), { experimentalFeatures: { workflowGraphExecutor: true } });
|
||||
|
||||
expect(result.disposition).toBe("completed");
|
||||
expect(result.visitedNodeIds).toContain("workflow-step");
|
||||
expect(result.visitedNodeIds).toContain("browser-verification");
|
||||
expect(result.visitedNodeIds).not.toContain("browser-verification::browser-verification-step");
|
||||
expect(result.visitedNodeIds).not.toContain("workflow-step");
|
||||
expect(runWorkflowSteps).not.toHaveBeenCalled();
|
||||
expect(seams.review).toHaveBeenCalledTimes(1);
|
||||
expect(seams.merge).toHaveBeenCalledTimes(1);
|
||||
|
||||
@@ -30,7 +30,7 @@ import {
|
||||
type WorkflowIr,
|
||||
} from "@fusion/core";
|
||||
|
||||
import { WorkflowGraphExecutor } from "../workflow-graph-executor.js";
|
||||
import { WorkflowGraphExecutor, type WorkflowNodeResult } from "../workflow-graph-executor.js";
|
||||
import {
|
||||
FOREACH_ACTIVE_CONTEXT_KEY,
|
||||
type ForeachActiveContext,
|
||||
@@ -81,13 +81,17 @@ function makeFakeStore(steps: TaskStep[]) {
|
||||
};
|
||||
}
|
||||
|
||||
/** Build a TaskDetail with N pending steps. */
|
||||
function taskWithSteps(n: number): TaskDetail {
|
||||
/** Build a TaskDetail with N pending steps and an optional enabled-group set. */
|
||||
function taskWithSteps(n: number, enabledWorkflowSteps?: string[]): TaskDetail {
|
||||
const steps: TaskStep[] = Array.from({ length: n }, (_, i) => ({
|
||||
name: `Step ${i + 1}`,
|
||||
status: "pending" as const,
|
||||
}));
|
||||
return { id: "FN-STEPWISE", steps } as unknown as TaskDetail;
|
||||
return {
|
||||
id: "FN-STEPWISE",
|
||||
steps,
|
||||
...(enabledWorkflowSteps ? { enabledWorkflowSteps } : {}),
|
||||
} as unknown as TaskDetail;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -157,9 +161,15 @@ async function runStepwiseGraph(
|
||||
onReset?: (active: ForeachActiveContext) => void;
|
||||
captureResetResult?: (ok: boolean, reason?: string) => void;
|
||||
workflowStep?: WorkflowLegacySeams["workflowStep"];
|
||||
// U6: ids of optional-group nodes enabled for this task (e.g.
|
||||
// "browser-verification"); seeds task.enabledWorkflowSteps.
|
||||
enabledWorkflowSteps?: string[];
|
||||
// U6: handler for non-seam custom nodes — the browser-verification
|
||||
// optional-group's inner prompt node runs through this.
|
||||
runCustomNode?: (nodeId: string) => Promise<WorkflowNodeResult>;
|
||||
} = {},
|
||||
): Promise<{ trajectory: TrajectoryEntry[]; outcome: string; result: Awaited<ReturnType<WorkflowGraphExecutor["run"]>> }> {
|
||||
const task = taskWithSteps(stepCount);
|
||||
const task = taskWithSteps(stepCount, opts.enabledWorkflowSteps);
|
||||
const fake = makeFakeStore(task.steps as TaskStep[]);
|
||||
const reviewCursor = new Map<number, number>();
|
||||
|
||||
@@ -211,6 +221,10 @@ async function runStepwiseGraph(
|
||||
const executor = new WorkflowGraphExecutor({
|
||||
seams,
|
||||
signal: opts.signal,
|
||||
// U6: non-seam custom nodes (the browser-verification optional-group's inner
|
||||
// prompt node) route here. Default: success no-op.
|
||||
runCustomNode: async (node) =>
|
||||
opts.runCustomNode ? opts.runCustomNode(node.id) : { outcome: "success" },
|
||||
getTaskSteps: () => task.steps as TaskStep[],
|
||||
// parse-steps reads PROMPT.md; produce headings matching the step count so the
|
||||
// real builtin chain runs end-to-end. writeSteps is a no-op (steps pre-set).
|
||||
@@ -578,44 +592,57 @@ describe("stepwise workflow parity (U7 / KTD-9)", () => {
|
||||
expect(result.visitedNodeIds).toContain("merge");
|
||||
});
|
||||
|
||||
// ── Pre-merge workflow-step seam (optional-step execution, R1) ─────────────
|
||||
// ── Pre-merge browser-verification optional-group (U6, R-3 run-once) ────────
|
||||
|
||||
it("runs the pre-merge workflow-step seam exactly once after the foreach (enabled steps execute)", async () => {
|
||||
// This is the dead-toggle guard: without a workflow-step seam node on the
|
||||
// success path, a stepwise task's enabledWorkflowSteps (e.g. browser
|
||||
// verification) would never run. Wire a workflowStep spy and assert the graph
|
||||
// invokes it once, between the foreach and review.
|
||||
let workflowStepCalls = 0;
|
||||
const BROWSER_VERIFICATION_STEP_VISITED_ID = "browser-verification::browser-verification-step";
|
||||
|
||||
it("runs the pre-merge browser-verification optional-group EXACTLY ONCE after the foreach when enabled", async () => {
|
||||
// R-3 run-once guarantee + dead-toggle guard: the optional-group sits on the
|
||||
// post-foreach success path. A stepwise task whose enabledWorkflowSteps
|
||||
// includes the group id runs the inner browser-verification prompt node ONCE
|
||||
// after all step instances complete — never per step-instance.
|
||||
let browserVerificationCalls = 0;
|
||||
const { outcome, result } = await runStepwiseGraph(
|
||||
3,
|
||||
[["APPROVE"], ["APPROVE"], ["APPROVE"]],
|
||||
{
|
||||
workflowStep: async () => {
|
||||
workflowStepCalls++;
|
||||
enabledWorkflowSteps: ["browser-verification"],
|
||||
runCustomNode: async (nodeId) => {
|
||||
if (nodeId === "browser-verification-step") browserVerificationCalls++;
|
||||
return { outcome: "success" };
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(outcome).toBe("success");
|
||||
// The seam ran ONCE post-foreach — not per step-instance (3 steps here).
|
||||
expect(workflowStepCalls).toBe(1);
|
||||
expect(result.visitedNodeIds).toContain("workflow-step");
|
||||
// Ordering: all step instances complete before the workflow-step seam, which
|
||||
// ONCE post-foreach — not per step-instance (3 steps here).
|
||||
expect(browserVerificationCalls).toBe(1);
|
||||
expect(result.visitedNodeIds).toContain("browser-verification");
|
||||
expect(result.visitedNodeIds).toContain(BROWSER_VERIFICATION_STEP_VISITED_ID);
|
||||
// Ordering: all step instances complete before the group's inner step, which
|
||||
// precedes review.
|
||||
const seamIdx = result.visitedNodeIds.indexOf("workflow-step");
|
||||
const groupStepIdx = result.visitedNodeIds.indexOf(BROWSER_VERIFICATION_STEP_VISITED_ID);
|
||||
const reviewIdx = result.visitedNodeIds.indexOf("review");
|
||||
const lastStepIdx = result.visitedNodeIds.map((id) => id.startsWith("steps#")).lastIndexOf(true);
|
||||
expect(lastStepIdx).toBeLessThan(seamIdx);
|
||||
expect(seamIdx).toBeLessThan(reviewIdx);
|
||||
expect(lastStepIdx).toBeLessThan(groupStepIdx);
|
||||
expect(groupStepIdx).toBeLessThan(reviewIdx);
|
||||
});
|
||||
|
||||
it("treats the workflow-step seam as a no-op pass-through when no steps are enabled", async () => {
|
||||
// No workflowStep seam wired → the handler skips to success and routes to
|
||||
// review, leaving the trajectory identical to the pre-seam behavior.
|
||||
const { outcome, result } = await runStepwiseGraph(2, [["APPROVE"], ["APPROVE"]]);
|
||||
it("bypasses the browser-verification optional-group (inert) when it is not enabled", async () => {
|
||||
// Disabled (no enabledWorkflowSteps): the group node is traversed but its
|
||||
// template body never runs — the inner prompt node is not visited and the
|
||||
// custom-node runner is never invoked for it. Routes straight to review.
|
||||
let browserVerificationCalls = 0;
|
||||
const { outcome, result } = await runStepwiseGraph(2, [["APPROVE"], ["APPROVE"]], {
|
||||
runCustomNode: async (nodeId) => {
|
||||
if (nodeId === "browser-verification-step") browserVerificationCalls++;
|
||||
return { outcome: "success" };
|
||||
},
|
||||
});
|
||||
expect(outcome).toBe("success");
|
||||
expect(result.visitedNodeIds).toContain("workflow-step");
|
||||
expect(browserVerificationCalls).toBe(0);
|
||||
expect(result.visitedNodeIds).toContain("browser-verification");
|
||||
expect(result.visitedNodeIds).not.toContain(BROWSER_VERIFICATION_STEP_VISITED_ID);
|
||||
expect(result.visitedNodeIds).toContain("review");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// PARITY SUBJECT (test-file ownership, U7 / KTD-9):
|
||||
// This suite owns DEFAULT-WORKFLOW BYTE-IDENTITY parity — it proves the graph
|
||||
// executor reproduces the workflow-native planning → execute → workflow-step
|
||||
// → review → merge seam
|
||||
// sequence exactly (the parity ORACLE per KTD-1). It deliberately does NOT
|
||||
// executor reproduces the workflow-native planning → execute → review → merge
|
||||
// seam sequence exactly (the parity ORACLE per KTD-1). It deliberately does NOT
|
||||
// cover per-step / updateStep-trajectory parity.
|
||||
//
|
||||
// FNXC:WorkflowOptionalGroup 2026-06-21-15:10 (U6): the legacy `workflow-step`
|
||||
// seam was retired from the coding built-in; pre-merge browser-verification is
|
||||
// now a default-OFF `optional-group`. With no `enabledWorkflowSteps` on the task
|
||||
// the group is BYPASSED, so the lifecycle seam sequence is planning → execute →
|
||||
// review → merge (no workflow-step seam).
|
||||
//
|
||||
// The stepwise per-step trajectory + merge-blocker-window parity (legacy
|
||||
// step-session path vs the stepwise foreach graph) is owned by the sibling
|
||||
// suite `stepwise-workflow-parity.test.ts`. Keep the two concerns separate.
|
||||
@@ -41,9 +46,7 @@ function runLegacy(seams: WorkflowLegacySeams) {
|
||||
const execute = await seams.execute(task, {});
|
||||
events.push(`execute:${execute.outcome}`);
|
||||
if (execute.outcome !== "success") return events;
|
||||
const workflowStep = await seams.workflowStep?.(task, {}) ?? { outcome: "success" as const };
|
||||
events.push(`workflow-step:${workflowStep.outcome}`);
|
||||
if (workflowStep.outcome !== "success") return events;
|
||||
// U6: no workflow-step seam — browser-verification is a bypassed optional-group.
|
||||
const review = await seams.review(task, {});
|
||||
events.push(`review:${review.outcome}`);
|
||||
if (review.outcome !== "success") return events;
|
||||
@@ -98,7 +101,7 @@ describe("WorkflowGraphExecutor interpreter-parity", () => {
|
||||
const executor = new WorkflowGraphExecutor({ seams });
|
||||
const result = await executor.run(task, { experimentalFeatures: { workflowGraphExecutor: true } });
|
||||
expect(result.outcome).toBe("failure");
|
||||
expect(legacyEvents).toEqual(["planning:success", "execute:success", "workflow-step:success", "review:success", "merge:failure"]);
|
||||
expect(legacyEvents).toEqual(["planning:success", "execute:success", "review:success", "merge:failure"]);
|
||||
});
|
||||
|
||||
it("preserves autoMerge:false terminal in-review semantics via review failure", async () => {
|
||||
@@ -198,11 +201,11 @@ describe("column-agent feature is invisible when unbound (U7 / R9)", () => {
|
||||
// Bind the invariant to actual executor behavior (PR #1432 review): the
|
||||
// observation below derives from the run-captured seam sequence, so seam
|
||||
// drift fails here instead of being masked by a hard-coded literal.
|
||||
expect(stages).toEqual(["planning", "execute", "workflow-step", "review", "merge"]);
|
||||
expect(stages).toEqual(["planning", "execute", "review", "merge"]);
|
||||
|
||||
// Legacy authoritative observation: a clean run that lands in `done`/merged.
|
||||
const legacyObs = buildWorkflowObservation({
|
||||
stageTransitions: ["triage", "planning", "execute", "workflow-step", "review", "merge"],
|
||||
stageTransitions: ["triage", "planning", "execute", "review", "merge"],
|
||||
terminalColumn: "done",
|
||||
terminalStatus: "done",
|
||||
reviewVerdict: "approve",
|
||||
|
||||
@@ -27,9 +27,14 @@ describe("WorkflowGraphExecutor built-in coding workflow retries", () => {
|
||||
expect(result.outcome).toBe("success");
|
||||
expect(executeCalls).toBe(2);
|
||||
expect(result.context["node:execute:outcome"]).toBe("success");
|
||||
// U6: the legacy `workflow-step` seam is gone; the pre-merge browser-verification
|
||||
// optional-group is bypassed here (task has no enabledWorkflowSteps), so its
|
||||
// group node is visited but its template body is not.
|
||||
expect(result.visitedNodeIds).toEqual(
|
||||
expect.arrayContaining(["execute", "workflow-step", "review", "merge"]),
|
||||
expect.arrayContaining(["execute", "browser-verification", "review", "merge"]),
|
||||
);
|
||||
expect(result.visitedNodeIds).not.toContain("workflow-step");
|
||||
expect(result.visitedNodeIds).not.toContain("browser-verification::browser-verification-step");
|
||||
});
|
||||
|
||||
it("exhausts execute node retries and routes failure to end", async () => {
|
||||
@@ -52,7 +57,7 @@ describe("WorkflowGraphExecutor built-in coding workflow retries", () => {
|
||||
expect(result.outcome).toBe("failure");
|
||||
expect(BUILTIN_CODING_WORKFLOW_IR.edges).toContainEqual({ from: "execute", to: "end", condition: "failure" });
|
||||
expect(result.visitedNodeIds).toEqual(["start", "planning", "execute"]);
|
||||
expect(result.visitedNodeIds).not.toContain("workflow-step");
|
||||
expect(result.visitedNodeIds).not.toContain("browser-verification");
|
||||
});
|
||||
|
||||
it("does not retry when the execute node returns a clean failure outcome", async () => {
|
||||
@@ -95,7 +100,9 @@ describe("WorkflowGraphExecutor built-in coding workflow retries", () => {
|
||||
expect(result.context["node:review:value"]).toBe("exception");
|
||||
expect(result.context["node:review:error"]).toBe("review seam failed");
|
||||
expect(result.outcome).toBe("failure");
|
||||
expect(result.visitedNodeIds).toEqual(["start", "planning", "execute", "workflow-step", "review"]);
|
||||
// U6: with browser-verification disabled (bypassed), the group node sits
|
||||
// between execute and review where the workflow-step seam used to.
|
||||
expect(result.visitedNodeIds).toEqual(["start", "planning", "execute", "browser-verification", "review"]);
|
||||
});
|
||||
|
||||
it("respects a per-node maxRetries override", async () => {
|
||||
|
||||
@@ -19,6 +19,10 @@ const mergeRegionEntries: Array<{ id: string; kind: WorkflowIrNodeKind }> = [
|
||||
];
|
||||
const rawMergeRegionNodeIds = mergeRegionEntries.map((entry) => entry.id);
|
||||
|
||||
// U6: the task carries no `enabledWorkflowSteps`, so the pre-merge
|
||||
// browser-verification optional-group is BYPASSED — its node is visited but its
|
||||
// body never runs. The legacy `workflowStep` seam is retained here only for shape
|
||||
// (it is no longer reached by the migrated coding IR).
|
||||
function createSeams(overrides: Partial<WorkflowLegacySeams> = {}): WorkflowLegacySeams {
|
||||
return {
|
||||
planning: async () => ({ outcome: "success" }),
|
||||
@@ -62,7 +66,7 @@ describe("WorkflowGraphExecutor merge-region collapse", () => {
|
||||
expect(result.outcome).toBe("success");
|
||||
expect(merge).toHaveBeenCalledOnce();
|
||||
expect(calls).toEqual(["merge"]);
|
||||
expect(result.visitedNodeIds).toEqual(["start", "planning", "execute", "workflow-step", "review", "merge"]);
|
||||
expect(result.visitedNodeIds).toEqual(["start", "planning", "execute", "browser-verification", "review", "merge"]);
|
||||
expect(result.context["node:merge:outcome"]).toBe("success");
|
||||
expectNoRawMergeRegionVisits(result.visitedNodeIds);
|
||||
});
|
||||
@@ -75,7 +79,7 @@ describe("WorkflowGraphExecutor merge-region collapse", () => {
|
||||
|
||||
expect(result.outcome).toBe("failure");
|
||||
expect(merge).toHaveBeenCalledOnce();
|
||||
expect(result.visitedNodeIds).toEqual(["start", "planning", "execute", "workflow-step", "review", "merge"]);
|
||||
expect(result.visitedNodeIds).toEqual(["start", "planning", "execute", "browser-verification", "review", "merge"]);
|
||||
expect(result.context["node:merge:outcome"]).toBe("failure");
|
||||
expect(result.context["node:merge:value"]).toBe("FileScopeViolationError");
|
||||
expectNoRawMergeRegionVisits(result.visitedNodeIds);
|
||||
@@ -94,7 +98,7 @@ describe("WorkflowGraphExecutor merge-region collapse", () => {
|
||||
|
||||
expect(result.outcome).toBe("failure");
|
||||
expect(merge).not.toHaveBeenCalled();
|
||||
expect(result.visitedNodeIds).toEqual(["start", "planning", "execute", "workflow-step", "review"]);
|
||||
expect(result.visitedNodeIds).toEqual(["start", "planning", "execute", "browser-verification", "review"]);
|
||||
expect(result.visitedNodeIds).not.toContain("merge");
|
||||
expectNoRawMergeRegionVisits(result.visitedNodeIds);
|
||||
});
|
||||
@@ -109,7 +113,7 @@ describe("WorkflowGraphExecutor merge-region collapse", () => {
|
||||
|
||||
expect(result.outcome).toBe("success");
|
||||
expect(merge).toHaveBeenCalledOnce();
|
||||
expect(result.visitedNodeIds).toEqual(["start", "planning", "execute", "workflow-step", "review", "merge"]);
|
||||
expect(result.visitedNodeIds).toEqual(["start", "planning", "execute", "browser-verification", "review", "merge"]);
|
||||
expectNoRawMergeRegionVisits(result.visitedNodeIds);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { TaskDetail, WorkflowIr } from "@fusion/core";
|
||||
|
||||
import { WorkflowGraphExecutor, type WorkflowNodeHandler } from "../workflow-graph-executor.js";
|
||||
|
||||
/*
|
||||
FNXC:WorkflowOptionalGroup 2026-06-21-14:05:
|
||||
Execution-level coverage for the run-once/bypass dispatch (U2). The contract that
|
||||
guards the dead-toggle failure mode is the TWO-TASK DIVERGENCE test: two tasks
|
||||
identical except `enabledWorkflowSteps` must diverge — the enabled one runs the
|
||||
template's nodes, the disabled one runs NONE and still reaches the same downstream
|
||||
node. These are real executor runs (not traversal-only) so a mock-masked dead path
|
||||
cannot pass.
|
||||
*/
|
||||
|
||||
const settingsOn = () => ({ experimentalFeatures: { workflowGraphExecutor: true } });
|
||||
|
||||
/** A graph with one `optional-group` between `before` and `after`. The group's
|
||||
* template runs a single `optstep` prompt when the group is enabled. */
|
||||
function optionalGroupIr(): WorkflowIr {
|
||||
return {
|
||||
version: "v2",
|
||||
name: "optional-group-test",
|
||||
columns: [{ id: "work", name: "Work", traits: [] }],
|
||||
nodes: [
|
||||
{ id: "start", kind: "start" },
|
||||
{ id: "before", kind: "prompt", config: { prompt: "before" } },
|
||||
{
|
||||
id: "group",
|
||||
kind: "optional-group",
|
||||
config: {
|
||||
name: "Browser verification",
|
||||
defaultOn: false,
|
||||
template: {
|
||||
nodes: [{ id: "optstep", kind: "prompt", config: { prompt: "verify" } }],
|
||||
edges: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
{ id: "after", kind: "prompt", config: { prompt: "after" } },
|
||||
{ id: "end", kind: "end" },
|
||||
],
|
||||
edges: [
|
||||
{ from: "start", to: "before" },
|
||||
{ from: "before", to: "group" },
|
||||
{ from: "group", to: "after", condition: "success" },
|
||||
{ from: "after", to: "end" },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/** A graph with a two-node template so we can prove a single pass walks all
|
||||
* template nodes once (not per-step, not looped). */
|
||||
function multiNodeGroupIr(): WorkflowIr {
|
||||
return {
|
||||
version: "v2",
|
||||
name: "optional-group-multi",
|
||||
columns: [{ id: "work", name: "Work", traits: [] }],
|
||||
nodes: [
|
||||
{ id: "start", kind: "start" },
|
||||
{
|
||||
id: "group",
|
||||
kind: "optional-group",
|
||||
config: {
|
||||
defaultOn: false,
|
||||
template: {
|
||||
nodes: [
|
||||
{ id: "a", kind: "prompt", config: { prompt: "a" } },
|
||||
{ id: "b", kind: "gate", config: { prompt: "b" } },
|
||||
],
|
||||
edges: [{ from: "a", to: "b" }],
|
||||
},
|
||||
},
|
||||
},
|
||||
{ id: "after", kind: "prompt", config: { prompt: "after" } },
|
||||
{ id: "end", kind: "end" },
|
||||
],
|
||||
edges: [
|
||||
{ from: "start", to: "group" },
|
||||
{ from: "group", to: "after", condition: "success" },
|
||||
{ from: "after", to: "end" },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function taskWith(enabled: string[] | undefined): TaskDetail {
|
||||
return { id: "FN-OG", enabledWorkflowSteps: enabled } as TaskDetail;
|
||||
}
|
||||
|
||||
describe("WorkflowGraphExecutor optional-group", () => {
|
||||
it("two-task divergence: only the task whose enabledWorkflowSteps includes the group id runs the template; the sibling runs none and both reach downstream", async () => {
|
||||
const ir = optionalGroupIr();
|
||||
|
||||
const enabledCalls: string[] = [];
|
||||
const enabledExecutor = new WorkflowGraphExecutor({
|
||||
handlers: {
|
||||
prompt: async (node) => {
|
||||
enabledCalls.push(node.id);
|
||||
return { outcome: "success" };
|
||||
},
|
||||
},
|
||||
});
|
||||
const enabledResult = await enabledExecutor.run(taskWith(["group"]), settingsOn(), ir);
|
||||
|
||||
const disabledCalls: string[] = [];
|
||||
const disabledExecutor = new WorkflowGraphExecutor({
|
||||
handlers: {
|
||||
prompt: async (node) => {
|
||||
disabledCalls.push(node.id);
|
||||
return { outcome: "success" };
|
||||
},
|
||||
},
|
||||
});
|
||||
const disabledResult = await disabledExecutor.run(taskWith([]), settingsOn(), ir);
|
||||
|
||||
// Enabled task executed the template node; disabled did not.
|
||||
expect(enabledCalls).toContain("optstep");
|
||||
expect(disabledCalls).not.toContain("optstep");
|
||||
|
||||
// The materialized template id is recorded only for the enabled run.
|
||||
expect(enabledResult.visitedNodeIds).toContain("group::optstep");
|
||||
expect(disabledResult.visitedNodeIds).not.toContain("group::optstep");
|
||||
|
||||
// Both still reach the same downstream node.
|
||||
expect(enabledCalls).toContain("after");
|
||||
expect(disabledCalls).toContain("after");
|
||||
expect(enabledResult.visitedNodeIds).toContain("after");
|
||||
expect(disabledResult.visitedNodeIds).toContain("after");
|
||||
|
||||
expect(enabledResult.outcome).toBe("success");
|
||||
expect(disabledResult.outcome).toBe("success");
|
||||
});
|
||||
|
||||
it("runs an enabled group's template exactly once (single pass, not per-step/looped)", async () => {
|
||||
const runTemplate = vi.fn<WorkflowNodeHandler>(async () => ({ outcome: "success" }));
|
||||
const executor = new WorkflowGraphExecutor({
|
||||
handlers: { prompt: runTemplate, gate: runTemplate },
|
||||
});
|
||||
|
||||
const result = await executor.run(taskWith(["group"]), settingsOn(), multiNodeGroupIr());
|
||||
|
||||
// Each template node ran exactly once; plus the downstream `after`.
|
||||
const templateRuns = runTemplate.mock.calls
|
||||
.map(([node]) => node.id)
|
||||
.filter((id) => id === "a" || id === "b");
|
||||
expect(templateRuns).toEqual(["a", "b"]);
|
||||
|
||||
expect(result.visitedNodeIds.filter((id) => id === "group::a")).toHaveLength(1);
|
||||
expect(result.visitedNodeIds.filter((id) => id === "group::b")).toHaveLength(1);
|
||||
expect(result.context["node:group:outcome"]).toBe("success");
|
||||
expect(result.outcome).toBe("success");
|
||||
});
|
||||
|
||||
it("disabled group is inert: downstream outcome/context identical to the group not being there", async () => {
|
||||
const ir = optionalGroupIr();
|
||||
const handler: WorkflowNodeHandler = async () => ({ outcome: "success" });
|
||||
|
||||
// Run with the group disabled.
|
||||
const withGroup = new WorkflowGraphExecutor({ handlers: { prompt: handler } });
|
||||
const disabledResult = await withGroup.run(taskWith([]), settingsOn(), ir);
|
||||
|
||||
// Reference graph: identical but with the group node removed (before → after).
|
||||
const refIr: WorkflowIr = {
|
||||
...ir,
|
||||
nodes: ir.nodes.filter((n) => n.id !== "group"),
|
||||
edges: [
|
||||
{ from: "start", to: "before" },
|
||||
{ from: "before", to: "after" },
|
||||
{ from: "after", to: "end" },
|
||||
],
|
||||
};
|
||||
const refExecutor = new WorkflowGraphExecutor({ handlers: { prompt: handler } });
|
||||
const refResult = await refExecutor.run(taskWith([]), settingsOn(), refIr);
|
||||
|
||||
expect(disabledResult.outcome).toBe(refResult.outcome);
|
||||
// Downstream node outcome is identical in both graphs.
|
||||
expect(disabledResult.context["node:after:outcome"]).toBe(refResult.context["node:after:outcome"]);
|
||||
expect(disabledResult.context["node:before:outcome"]).toBe(refResult.context["node:before:outcome"]);
|
||||
// No template node executed.
|
||||
expect(disabledResult.visitedNodeIds).not.toContain("group::optstep");
|
||||
});
|
||||
|
||||
it("a template-node failure inside an enabled group surfaces as the group's outcome and routes its outcome: edge", async () => {
|
||||
const ir = optionalGroupIr();
|
||||
// Route the group's failure value to a dedicated recovery node.
|
||||
ir.nodes.push({ id: "recover", kind: "prompt", config: { prompt: "recover" } });
|
||||
ir.edges.push({ from: "group", to: "recover", condition: "outcome:boom" });
|
||||
|
||||
const calls: string[] = [];
|
||||
const handler: WorkflowNodeHandler = async (node) => {
|
||||
calls.push(node.id);
|
||||
if (node.id === "optstep") return { outcome: "failure", value: "boom" };
|
||||
return { outcome: "success" };
|
||||
};
|
||||
const executor = new WorkflowGraphExecutor({ handlers: { prompt: handler } });
|
||||
|
||||
const result = await executor.run(taskWith(["group"]), settingsOn(), ir);
|
||||
|
||||
// The group's outcome reflects the template failure.
|
||||
expect(result.context["node:group:outcome"]).toBe("failure");
|
||||
expect(result.context["node:group:value"]).toBe("boom");
|
||||
// The outcome: edge routed to recover, NOT the success edge to `after`.
|
||||
expect(calls).toContain("recover");
|
||||
expect(calls).not.toContain("after");
|
||||
});
|
||||
|
||||
it("treats a stale/unknown enabled id as not-enabled (group bypassed, no crash)", async () => {
|
||||
const ir = optionalGroupIr();
|
||||
const calls: string[] = [];
|
||||
const handler: WorkflowNodeHandler = async (node) => {
|
||||
calls.push(node.id);
|
||||
return { outcome: "success" };
|
||||
};
|
||||
const executor = new WorkflowGraphExecutor({ handlers: { prompt: handler } });
|
||||
|
||||
// enabledWorkflowSteps references a since-removed group id, not "group".
|
||||
const result = await executor.run(taskWith(["stale-group-id"]), settingsOn(), ir);
|
||||
|
||||
expect(calls).not.toContain("optstep");
|
||||
expect(calls).toContain("after");
|
||||
expect(result.outcome).toBe("success");
|
||||
});
|
||||
});
|
||||
@@ -245,7 +245,9 @@ describe("WorkflowGraphTaskRunner (CU-U2)", () => {
|
||||
const result = await runner.run(task, flagOn);
|
||||
|
||||
expect(result.disposition).toBe("completed");
|
||||
expect(calls).toEqual(["planning", "execute", "workflow-step", "review", "merge"]);
|
||||
// U6: the coding built-in no longer carries a `workflow-step` seam; its
|
||||
// pre-merge browser-verification optional-group is default-OFF and bypassed.
|
||||
expect(calls).toEqual(["planning", "execute", "review", "merge"]);
|
||||
expect(result.reason).toBeUndefined();
|
||||
expect(getWorkflowDefinition).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -229,7 +229,9 @@ describe("WorkflowTaskRuntime", () => {
|
||||
const result = await runtime.run(attachmentTask, flagOff);
|
||||
|
||||
expect(result.disposition).toBe("completed");
|
||||
expect(calls).toEqual(["planning", "prepare-worktree", "execute", "workflow-step", "review", "merge"]);
|
||||
// U6: the coding built-in no longer runs a `workflow-step` seam; the pre-merge
|
||||
// browser-verification optional-group is default-OFF and bypassed (no call).
|
||||
expect(calls).toEqual(["planning", "prepare-worktree", "execute", "review", "merge"]);
|
||||
expect(observed.executedTasks).toHaveLength(1);
|
||||
expect(observed.executedTasks[0]?.attachments).toEqual(attachments);
|
||||
});
|
||||
@@ -293,31 +295,52 @@ describe("WorkflowTaskRuntime", () => {
|
||||
const result = await runtime.run(task, flagOff);
|
||||
|
||||
expect(result.disposition).toBe("completed");
|
||||
expect(calls).toEqual(["planning", "prepare-worktree", "execute", "workflow-step", "review", "merge"]);
|
||||
expect(result.visitedNodeIds).toEqual(["start", "planning", "execute", "workflow-step", "review", "merge"]);
|
||||
// U6: no `workflow-step` seam; the bypassed browser-verification group node
|
||||
// sits between execute and review in the visited sequence.
|
||||
expect(calls).toEqual(["planning", "prepare-worktree", "execute", "review", "merge"]);
|
||||
expect(result.visitedNodeIds).toEqual(["start", "planning", "execute", "browser-verification", "review", "merge"]);
|
||||
});
|
||||
|
||||
it("stops the built-in workflow before review when workflow-step remediation is scheduled", async () => {
|
||||
it("runs the pre-merge browser-verification optional-group once when enabled, before review", async () => {
|
||||
// U6: replaces the prior workflow-step-remediation test. With the group ENABLED
|
||||
// (task.enabledWorkflowSteps includes "browser-verification"), the inner
|
||||
// browser-verification-step prompt node runs once pre-merge, recorded as a
|
||||
// custom-node call; the group then routes success → review → merge.
|
||||
const calls: string[] = [];
|
||||
const runtime = new WorkflowTaskRuntime({
|
||||
store: {
|
||||
getTaskWorkflowSelection: () => undefined,
|
||||
getWorkflowDefinition: async () => undefined,
|
||||
},
|
||||
primitives: recordingPrimitives(calls, {
|
||||
workflowStep: { outcome: "success", value: "remediation-scheduled" },
|
||||
}),
|
||||
primitives: recordingPrimitives(calls),
|
||||
runCustomNode: async (node) => {
|
||||
calls.push(`custom:${node.id}`);
|
||||
return { outcome: "success" };
|
||||
},
|
||||
});
|
||||
|
||||
const result = await runtime.run(task, flagOff);
|
||||
const enabledTask = { ...task, enabledWorkflowSteps: ["browser-verification"] } as TaskDetail;
|
||||
const result = await runtime.run(enabledTask, flagOff);
|
||||
|
||||
expect(result.disposition).toBe("completed");
|
||||
expect(calls).toEqual(["planning", "prepare-worktree", "execute", "workflow-step"]);
|
||||
expect(result.visitedNodeIds).toEqual(["start", "planning", "execute", "workflow-step"]);
|
||||
expect(calls).toEqual([
|
||||
"planning",
|
||||
"prepare-worktree",
|
||||
"execute",
|
||||
"custom:browser-verification-step",
|
||||
"review",
|
||||
"merge",
|
||||
]);
|
||||
expect(result.visitedNodeIds).toEqual([
|
||||
"start",
|
||||
"planning",
|
||||
"execute",
|
||||
// The group container node, then its inner template step (run once).
|
||||
"browser-verification",
|
||||
"browser-verification::browser-verification-step",
|
||||
"review",
|
||||
"merge",
|
||||
]);
|
||||
});
|
||||
|
||||
it("fails selected workflow lookup misses instead of running the built-in workflow", async () => {
|
||||
|
||||
@@ -38,7 +38,7 @@ import {
|
||||
type ForeachEnvironment,
|
||||
type WorkflowStepInstancePersistence,
|
||||
} from "./workflow-graph-foreach.js";
|
||||
import { runLoop } from "./workflow-graph-loop.js";
|
||||
import { runLoop, runOptionalGroup } from "./workflow-graph-loop.js";
|
||||
|
||||
export type WorkflowNodeOutcome = "success" | "failure";
|
||||
|
||||
@@ -473,6 +473,52 @@ export class WorkflowGraphExecutor {
|
||||
return await traverseChildren(node, result);
|
||||
}
|
||||
|
||||
if (node.kind === "optional-group") {
|
||||
/*
|
||||
* FNXC:WorkflowOptionalGroup 2026-06-21-14:05:
|
||||
* Run-once-or-bypass dispatch. The enable decision is read from the
|
||||
* per-task `enabledWorkflowSteps` facet, keyed by THIS group node's id
|
||||
* (KTD-2). Enabled → walk the template subgraph EXACTLY ONCE via
|
||||
* `runOptionalGroup` (single pass, no iteration/rework). Disabled →
|
||||
* pass through: traverse the group's children with a synthetic
|
||||
* success result WITHOUT executing any template node, so a disabled
|
||||
* group is byte-inert vs the group not being there. Two tasks
|
||||
* identical except `enabledWorkflowSteps` therefore diverge here:
|
||||
* the enabled one runs the body, the disabled one runs none and
|
||||
* still reaches the same downstream node.
|
||||
*/
|
||||
const enabled = task.enabledWorkflowSteps?.includes(node.id) ?? false;
|
||||
if (!enabled) {
|
||||
// FNXC:WorkflowOptionalGroup 2026-06-21-16:30: record the group's own
|
||||
// outcome on bypass too (mirrors the enabled path + every other node
|
||||
// kind), so a downstream node reading `node:<id>:outcome` from context
|
||||
// sees "success" rather than undefined — disabled is fully inert, not
|
||||
// just edge-routing-inert.
|
||||
context[`node:${node.id}:outcome`] = "success";
|
||||
// FNXC:WorkflowOptionalGroup 2026-06-22-09:00: route a disabled group
|
||||
// as a plain success with NO distinguishing value — a non-empty value
|
||||
// could let an `outcome:*` edge preempt the success edge in
|
||||
// traverseChildren, breaking the "disabled == node absent" inertness
|
||||
// invariant. (Code review: CodeRabbit.)
|
||||
return await traverseChildren(node, { outcome: "success" });
|
||||
}
|
||||
const groupResult = await runOptionalGroup(node, {
|
||||
context,
|
||||
runTemplateNode: (tNode, sig, contextOverride) =>
|
||||
this.executeNodeWithRetries(tNode, task, settings, contextOverride ?? context, ir, sig),
|
||||
shouldTraverseEdge: (edge, src) => this.shouldTraverseEdge(edge, src),
|
||||
signal: this.deps.signal,
|
||||
});
|
||||
visitedNodeIds.push(...groupResult.visitedNodeIds);
|
||||
const result: WorkflowNodeResult = {
|
||||
outcome: groupResult.outcome,
|
||||
value: groupResult.value,
|
||||
};
|
||||
context[`node:${node.id}:outcome`] = result.outcome;
|
||||
if (result.value !== undefined) context[`node:${node.id}:value`] = result.value;
|
||||
return await traverseChildren(node, result);
|
||||
}
|
||||
|
||||
const result = await this.executeNodeWithRetries(node, task, settings, context, ir);
|
||||
if (result.contextPatch) Object.assign(context, result.contextPatch);
|
||||
context[`node:${node.id}:outcome`] = result.outcome;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { WorkflowIrEdge, WorkflowIrNode, WorkflowLoopConfig } from "@fusion/core";
|
||||
import type { WorkflowIrEdge, WorkflowIrNode, WorkflowLoopConfig, WorkflowOptionalGroupConfig } from "@fusion/core";
|
||||
import { WorkflowIrError } from "@fusion/core";
|
||||
|
||||
import type { WorkflowNodeOutcome, WorkflowNodeResult } from "./workflow-graph-executor.js";
|
||||
@@ -208,3 +208,87 @@ export async function runLoop(
|
||||
};
|
||||
return { outcome: "failure", value: "loop-iteration-exhausted", visitedNodeIds };
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:WorkflowOptionalGroup 2026-06-21-14:05:
|
||||
An enabled `optional-group` runs its `template` subgraph EXACTLY ONCE (single pass — no iteration, no rework budget; rework edges are validation-forbidden inside the template). This reuses the loop's template-walk primitives (`buildOutgoing`, `findTemplateEntry`, `shouldTraverseEdge`) but caps the walk at one pass. The disabled/bypass decision lives in the executor branch (read from per-task `enabledWorkflowSteps`); this helper only runs the body when enabled.
|
||||
A template-node failure surfaces as the group's outcome so the group's `failure`/`outcome:` edges route, mirroring `runLoop`'s node-failure short-circuit.
|
||||
*/
|
||||
export interface OptionalGroupEnvironment {
|
||||
context: Record<string, unknown>;
|
||||
runTemplateNode: (
|
||||
node: WorkflowIrNode,
|
||||
signal?: AbortSignal,
|
||||
contextOverride?: Record<string, unknown>,
|
||||
) => Promise<WorkflowNodeResult>;
|
||||
shouldTraverseEdge: (edge: WorkflowIrEdge, source: WorkflowNodeResult) => boolean;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
export interface OptionalGroupRunResult {
|
||||
outcome: WorkflowNodeOutcome;
|
||||
value?: string;
|
||||
visitedNodeIds: string[];
|
||||
}
|
||||
|
||||
function resolveOptionalGroupTemplate(
|
||||
node: WorkflowIrNode,
|
||||
): { nodes: WorkflowIrNode[]; edges: WorkflowIrEdge[] } {
|
||||
const cfg = (node.config ?? {}) as Partial<WorkflowOptionalGroupConfig>;
|
||||
if (!cfg.template || !Array.isArray(cfg.template.nodes) || !Array.isArray(cfg.template.edges)) {
|
||||
throw new WorkflowIrError(`optional-group node '${node.id}' has no template subgraph`);
|
||||
}
|
||||
return cfg.template;
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk an enabled optional-group's template subgraph once. Mirrors a single
|
||||
* loop iteration: entry → follow matching edges → stop at the template exit (no
|
||||
* outgoing matching edge). Materialized visited ids use a `<groupId>::<templateNodeId>`
|
||||
* scheme so they are distinguishable from top-level ids and parseable back to the
|
||||
* template node. The group's own outcome is the last template node's outcome.
|
||||
*/
|
||||
export async function runOptionalGroup(
|
||||
groupNode: WorkflowIrNode,
|
||||
env: OptionalGroupEnvironment,
|
||||
): Promise<OptionalGroupRunResult> {
|
||||
const template = resolveOptionalGroupTemplate(groupNode);
|
||||
const templateById = new Map(template.nodes.map((n) => [n.id, n]));
|
||||
const outgoing = buildOutgoing(template.edges);
|
||||
const entry = findTemplateEntry(template.nodes, template.edges, groupNode.id);
|
||||
const visitedNodeIds: string[] = [];
|
||||
|
||||
const groupContext: Record<string, unknown> = { ...env.context };
|
||||
let current: WorkflowIrNode | undefined = entry;
|
||||
let lastResult: WorkflowNodeResult = { outcome: "success" };
|
||||
|
||||
while (current) {
|
||||
if (env.signal?.aborted) {
|
||||
return { outcome: "failure", value: "aborted", visitedNodeIds };
|
||||
}
|
||||
|
||||
const materializedId = `${groupNode.id}::${current.id}`;
|
||||
visitedNodeIds.push(materializedId);
|
||||
lastResult = await env.runTemplateNode(current, env.signal, groupContext);
|
||||
if (lastResult.contextPatch) Object.assign(groupContext, lastResult.contextPatch);
|
||||
groupContext[`node:${current.id}:outcome`] = lastResult.outcome;
|
||||
if (lastResult.value !== undefined) groupContext[`node:${current.id}:value`] = lastResult.value;
|
||||
|
||||
if (lastResult.outcome === "failure") {
|
||||
// Publish accumulated template context, then surface the failure as the
|
||||
// group's outcome so its failure/outcome: edges route.
|
||||
Object.assign(env.context, groupContext);
|
||||
return { outcome: "failure", value: lastResult.value, visitedNodeIds };
|
||||
}
|
||||
|
||||
const edges: WorkflowIrEdge[] = outgoing.get(current.id) ?? [];
|
||||
const matching: WorkflowIrEdge[] = edges.filter((edge: WorkflowIrEdge) =>
|
||||
env.shouldTraverseEdge(edge, lastResult),
|
||||
);
|
||||
current = matching.length > 0 ? templateById.get(matching[0].to) : undefined;
|
||||
}
|
||||
|
||||
// Single pass complete: publish the template's context onto the shared context.
|
||||
Object.assign(env.context, groupContext);
|
||||
return { outcome: lastResult.outcome, value: lastResult.value, visitedNodeIds };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user