Merge remote-tracking branch 'origin/main' into latest-1714
# Conflicts: # packages/engine/src/executor.ts
This commit is contained in:
5
.changeset/fn-6939-dev-server-narrow-preview-modal.md
Normal file
5
.changeset/fn-6939-dev-server-narrow-preview-modal.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
Fix narrow right-sidebar Dev Server preview overlap by replacing the inline preview with an accessible modal launcher when the dock is very narrow, while keeping inline preview for full-page, mobile viewport, and expanded pop-out hosts.
|
||||
5
.changeset/fn-6953-ntfy-test-unsaved-config.md
Normal file
5
.changeset/fn-6953-ntfy-test-unsaved-config.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
Fix ntfy test notifications to honor unsaved Settings form config so users can enable ntfy, enter a valid topic/server/token, and send a test notification before saving.
|
||||
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.)
|
||||
@@ -264,6 +264,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
|
||||
|
||||
@@ -866,6 +866,9 @@ Features:
|
||||
- Start, stop, and restart the current server session
|
||||
- Manage preview URLs with embedded preview and **Open in new tab** fallback
|
||||
- Tail live logs, load older history, and refresh session status
|
||||
- When Dev Server is hosted in a very narrow right sidebar, open the preview from the compact **Open preview** launcher; the modal keeps preview actions available while configuration and logs stay usable in the sidebar.
|
||||
|
||||
<!-- FNXC:DevServerDocs 2026-06-23-00:00: The narrow right-sidebar Dev Server host must describe the preview modal launcher so users do not expect the preview iframe to remain inline when the dock is too constrained for logs and preview together. -->
|
||||
|
||||
For module-level behavior and API surfaces, see [Dev Server modules](./dev-server-modules.md).
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -67,8 +67,8 @@
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow);
|
||||
/* Must sit above floating dashboard panels. */
|
||||
z-index: 1200;
|
||||
/* Must sit above floating dashboard panels and the shared floating-window stack (10100+). */
|
||||
z-index: 11000;
|
||||
max-height: 320px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -582,6 +582,65 @@ exactly when the surrounding chrome is gone.
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.devserver-preview-modal-launcher {
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.devserver-preview-modal-launcher__copy {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.devserver-preview-modal-launcher__copy .devserver-preview-url-badge {
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.devserver-preview-modal-launcher__description {
|
||||
margin: 0;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.devserver-preview-modal-overlay {
|
||||
align-items: center;
|
||||
padding: var(--space-xl);
|
||||
}
|
||||
|
||||
.devserver-preview-modal {
|
||||
width: min(calc(var(--space-2xl) * 28), calc(100vw - var(--space-xl) * 2));
|
||||
max-height: calc(100vh - var(--space-xl) * 2);
|
||||
}
|
||||
|
||||
.devserver-preview-modal__titlebar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-md);
|
||||
padding: var(--space-md);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.devserver-preview-modal__titlebar h2 {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.devserver-preview-modal__body {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.devserver-preview-modal__body .devserver-preview-container {
|
||||
flex: 1;
|
||||
min-height: min(60vh, calc(var(--space-2xl) * 14));
|
||||
max-height: none;
|
||||
}
|
||||
|
||||
/* Legacy selector compatibility for static CSS tests */
|
||||
.dev-server-preview-fallback {
|
||||
border: 1px solid color-mix(in srgb, var(--color-warning) 40%, transparent);
|
||||
@@ -655,7 +714,8 @@ exactly when the surrounding chrome is gone.
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.devserver-preview-header {
|
||||
.devserver-preview-header,
|
||||
.devserver-preview-modal-launcher__copy {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
@@ -703,6 +763,20 @@ exactly when the surrounding chrome is gone.
|
||||
max-height: calc(var(--space-2xl) * 3);
|
||||
}
|
||||
|
||||
.devserver-preview-modal-overlay {
|
||||
align-items: stretch;
|
||||
padding: var(--space-md);
|
||||
}
|
||||
|
||||
.devserver-preview-modal {
|
||||
width: 100%;
|
||||
max-height: calc(100vh - var(--space-md) * 2);
|
||||
}
|
||||
|
||||
.devserver-preview-modal__body .devserver-preview-container {
|
||||
min-height: calc(var(--space-2xl) * 7);
|
||||
}
|
||||
|
||||
.dev-server-config {
|
||||
max-height: min(48vh, calc(var(--space-2xl) * 13));
|
||||
}
|
||||
@@ -852,7 +926,8 @@ vertically (.dev-server-view overflow-y:auto), so each panel just needs to be fu
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.devserver-preview-panel {
|
||||
.devserver-preview-panel,
|
||||
.devserver-preview-modal-launcher {
|
||||
grid-column: auto;
|
||||
grid-row: auto;
|
||||
}
|
||||
@@ -862,10 +937,25 @@ vertically (.dev-server-view overflow-y:auto), so each panel just needs to be fu
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.devserver-preview-header {
|
||||
.devserver-preview-header,
|
||||
.devserver-preview-modal-launcher__copy {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.devserver-preview-modal-overlay {
|
||||
align-items: stretch;
|
||||
padding: var(--space-md);
|
||||
}
|
||||
|
||||
.devserver-preview-modal {
|
||||
width: min(calc(var(--space-2xl) * 20), calc(100vw - var(--space-md) * 2));
|
||||
max-height: calc(100vh - var(--space-md) * 2);
|
||||
}
|
||||
|
||||
.devserver-preview-modal__body .devserver-preview-container {
|
||||
min-height: calc(var(--space-2xl) * 7);
|
||||
}
|
||||
|
||||
.devserver-preview-url-badge {
|
||||
order: 2;
|
||||
flex: 1 1 100%;
|
||||
@@ -915,8 +1005,8 @@ vertically (.dev-server-view overflow-y:auto), so each panel just needs to be fu
|
||||
}
|
||||
|
||||
.dev-server-logs,
|
||||
.devserver-preview-container,
|
||||
.devserver-preview-iframe {
|
||||
.devserver-preview-panel .devserver-preview-container,
|
||||
.devserver-preview-panel .devserver-preview-iframe {
|
||||
min-height: calc(var(--space-2xl) * 4 + var(--space-md));
|
||||
max-height: none;
|
||||
}
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { RefObject } from "react";
|
||||
import type { TFunction } from "i18next";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AlertTriangle, ExternalLink, Eye, Loader2, Monitor, Play, RefreshCw, RotateCw, ShieldAlert, Square } from "lucide-react";
|
||||
import { AlertTriangle, ExternalLink, Eye, Loader2, Monitor, Play, RefreshCw, RotateCw, ShieldAlert, Square, X } from "lucide-react";
|
||||
import type { Task, TaskDetail } from "@fusion/core";
|
||||
import "./DevServerView.css";
|
||||
import type { DetectedDevServerCommand } from "../api";
|
||||
import { useDevServer } from "../hooks/useDevServer";
|
||||
import { useDevServerLogs } from "../hooks/useDevServerLogs";
|
||||
import { usePreviewEmbed } from "../hooks/usePreviewEmbed";
|
||||
import { useOverlayDismiss } from "../hooks/useOverlayDismiss";
|
||||
import type { ToastType } from "../hooks/useToast";
|
||||
import { DevServerLogViewer } from "./DevServerLogViewer";
|
||||
import { PreviewIframe } from "./PreviewIframe";
|
||||
@@ -37,6 +39,85 @@ function getStatusBadgeConfig(t: TFunction<"app">): Record<"stopped" | "starting
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
const NARROW_RIGHT_DOCK_PREVIEW_THRESHOLD = 480;
|
||||
|
||||
function isTrueMobileViewport(): boolean {
|
||||
if (typeof window === "undefined" || typeof window.matchMedia !== "function") {
|
||||
return false;
|
||||
}
|
||||
|
||||
return window.matchMedia("(max-width: 768px)").matches;
|
||||
}
|
||||
|
||||
function getDirectRightDockBodyHost(element: HTMLElement): HTMLElement | null {
|
||||
if (element.closest(".right-dock-expand-modal__body")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parent = element.parentElement;
|
||||
if (!parent?.classList.contains("right-dock__body")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return parent;
|
||||
}
|
||||
|
||||
function readHostInlineSize(host: HTMLElement): number {
|
||||
if (host.clientWidth > 0) {
|
||||
return host.clientWidth;
|
||||
}
|
||||
|
||||
const rect = host.getBoundingClientRect();
|
||||
return rect.width;
|
||||
}
|
||||
|
||||
function shouldUseNarrowRightDockPreviewMode(root: HTMLElement | null): boolean {
|
||||
if (!root || isTrueMobileViewport()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const host = getDirectRightDockBodyHost(root);
|
||||
if (!host) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return readHostInlineSize(host) <= NARROW_RIGHT_DOCK_PREVIEW_THRESHOLD;
|
||||
}
|
||||
|
||||
function useNarrowRightDockPreviewMode(rootRef: RefObject<HTMLDivElement | null>): boolean {
|
||||
const [isNarrowRightDockPreviewMode, setIsNarrowRightDockPreviewMode] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const root = rootRef.current;
|
||||
if (!root) {
|
||||
setIsNarrowRightDockPreviewMode(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const host = getDirectRightDockBodyHost(root);
|
||||
const updateMode = () => setIsNarrowRightDockPreviewMode(shouldUseNarrowRightDockPreviewMode(root));
|
||||
|
||||
updateMode();
|
||||
|
||||
if (!host || typeof ResizeObserver === "undefined") {
|
||||
window.addEventListener("resize", updateMode);
|
||||
return () => window.removeEventListener("resize", updateMode);
|
||||
}
|
||||
|
||||
const observer = new ResizeObserver(updateMode);
|
||||
observer.observe(host);
|
||||
window.addEventListener("resize", updateMode);
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
window.removeEventListener("resize", updateMode);
|
||||
};
|
||||
}, [rootRef]);
|
||||
|
||||
return isNarrowRightDockPreviewMode;
|
||||
}
|
||||
|
||||
let devServerViewWasPreviouslyInactive = false;
|
||||
|
||||
function normalizeError(error: unknown): string {
|
||||
@@ -142,6 +223,14 @@ export function DevServerView({ addToast, projectId, tasks }: DevServerViewProps
|
||||
const effectivePreviewUrl = previewUrl;
|
||||
const selectedSource = session?.config?.cwd ?? null;
|
||||
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const isNarrowRightDockPreviewMode = useNarrowRightDockPreviewMode(rootRef);
|
||||
|
||||
/*
|
||||
FNXC:DevServer 2026-06-23-00:00:
|
||||
The Dev Server preview must escape into a modal when the direct right-dock host is very narrow so preview chrome does not crowd logs and configuration in the same dock column.
|
||||
The 480px threshold catches the dock's compact range before preview chrome becomes unusable while preserving full-page, true mobile viewport, and expanded pop-out inline previews.
|
||||
*/
|
||||
const [showCandidates, setShowCandidates] = useState(true);
|
||||
const [commandInput, setCommandInput] = useState("");
|
||||
const [previewInput, setPreviewInput] = useState("");
|
||||
@@ -170,6 +259,9 @@ export function DevServerView({ addToast, projectId, tasks }: DevServerViewProps
|
||||
}, [executingTasks, selectedTaskId]);
|
||||
|
||||
const [previewMode, setPreviewMode] = useState<PreviewMode>("embedded");
|
||||
const [isPreviewModalOpen, setIsPreviewModalOpen] = useState(false);
|
||||
const previewModalLauncherRef = useRef<HTMLButtonElement>(null);
|
||||
const previewModalRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const previewEmbedUrl = previewMode === "embedded" ? effectivePreviewUrl : null;
|
||||
const {
|
||||
@@ -271,6 +363,60 @@ export function DevServerView({ addToast, projectId, tasks }: DevServerViewProps
|
||||
setPreviewInput(effectivePreviewUrl ?? "");
|
||||
}, [effectivePreviewUrl]);
|
||||
|
||||
const closePreviewModal = useCallback(() => {
|
||||
setIsPreviewModalOpen(false);
|
||||
window.requestAnimationFrame(() => previewModalLauncherRef.current?.focus());
|
||||
}, []);
|
||||
const previewModalOverlayDismissProps = useOverlayDismiss(closePreviewModal);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPreviewModalOpen) {
|
||||
return;
|
||||
}
|
||||
|
||||
previewModalRef.current?.focus();
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") {
|
||||
closePreviewModal();
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key !== "Tab") {
|
||||
return;
|
||||
}
|
||||
|
||||
const focusableElements = Array.from(
|
||||
previewModalRef.current?.querySelectorAll<HTMLElement>(
|
||||
'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])',
|
||||
) ?? [],
|
||||
).filter((element) => !element.hasAttribute("disabled") && element.getAttribute("aria-hidden") !== "true");
|
||||
|
||||
const firstElement = focusableElements[0];
|
||||
const lastElement = focusableElements.at(-1);
|
||||
if (!firstElement || !lastElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.shiftKey && document.activeElement === firstElement) {
|
||||
event.preventDefault();
|
||||
lastElement.focus();
|
||||
} else if (!event.shiftKey && document.activeElement === lastElement) {
|
||||
event.preventDefault();
|
||||
firstElement.focus();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, [closePreviewModal, isPreviewModalOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isNarrowRightDockPreviewMode && isPreviewModalOpen) {
|
||||
setIsPreviewModalOpen(false);
|
||||
}
|
||||
}, [isNarrowRightDockPreviewMode, isPreviewModalOpen]);
|
||||
|
||||
const handleOpenInNewTab = useCallback(() => {
|
||||
if (!effectivePreviewUrl) {
|
||||
return;
|
||||
@@ -399,8 +545,136 @@ export function DevServerView({ addToast, projectId, tasks }: DevServerViewProps
|
||||
const stopDisabled = status === "stopped" || actionInFlight !== null;
|
||||
const restartDisabled = status === "stopped" || status === "starting" || actionInFlight !== null;
|
||||
|
||||
const renderPreviewContent = () => (
|
||||
<>
|
||||
<div className="devserver-preview-header">
|
||||
<div className="devserver-preview-title">
|
||||
<Eye size={14} />
|
||||
<span>{t("devserver.preview", "Preview")}</span>
|
||||
</div>
|
||||
<span
|
||||
className={`devserver-preview-url-badge ${isManualPreviewOverride ? "devserver-preview-url-badge--manual" : "devserver-preview-url-badge--auto"}`}
|
||||
title={effectivePreviewUrl ?? t("devserver.noPreviewUrl", "No preview URL")}
|
||||
data-testid="devserver-preview-url-badge"
|
||||
>
|
||||
{isManualPreviewOverride ? t("devserver.manual", "Manual") : t("devserver.auto", "Auto")}
|
||||
{effectivePreviewUrl ? ` · ${effectivePreviewUrl}` : t("devserver.notAvailable", " · Not available")}
|
||||
</span>
|
||||
<div className="devserver-preview-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm"
|
||||
onClick={() => setPreviewMode((current) => (current === "embedded" ? "external" : "embedded"))}
|
||||
data-testid="devserver-preview-mode-toggle"
|
||||
>
|
||||
{previewMode === "embedded" ? t("devserver.externalOnly", "External only") : t("devserver.embedded", "Embedded")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm btn-icon"
|
||||
title={t("devserver.openInNewTab", "Open in new tab")}
|
||||
onClick={handleOpenInNewTab}
|
||||
disabled={!effectivePreviewUrl}
|
||||
data-testid="devserver-preview-open-tab"
|
||||
>
|
||||
<ExternalLink />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm btn-icon"
|
||||
title={t("devserver.refreshPreview", "Refresh preview")}
|
||||
onClick={handleRefreshPreview}
|
||||
disabled={!effectivePreviewUrl}
|
||||
data-testid="devserver-preview-refresh"
|
||||
>
|
||||
<RefreshCw />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="devserver-preview-container" data-embed-status={embedStatus} data-embedded={isEmbedded ? "true" : "false"}>
|
||||
{!effectivePreviewUrl && !isRunning && (
|
||||
<p className="devserver-preview-empty">{t("devserver.startDevServer", "Start a dev server to see a live preview here.")}</p>
|
||||
)}
|
||||
|
||||
{!effectivePreviewUrl && isRunning && (
|
||||
<p className="devserver-preview-empty">{t("devserver.noPreviewDetected", "No preview URL detected. Start the dev server or set a manual URL to preview your app.")}</p>
|
||||
)}
|
||||
|
||||
{effectivePreviewUrl && previewMode === "external" && (
|
||||
<div className="devserver-preview-external-only" data-testid="devserver-preview-external-only">
|
||||
<p>{t("devserver.embeddedPreviewDisabled", "Embedded preview is disabled. Open your app in a separate browser tab.")}</p>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary btn-sm touch-target"
|
||||
onClick={handleOpenInNewTab}
|
||||
data-testid="devserver-preview-external-open-tab"
|
||||
>
|
||||
{t("devserver.openInNewTab", "Open in new tab")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{effectivePreviewUrl && previewMode === "embedded" && showFallback && isBlocked && (
|
||||
<div
|
||||
className={embedStatus === "error" ? "devserver-preview-error-panel" : "devserver-preview-blocked-panel"}
|
||||
data-testid="devserver-preview-fallback"
|
||||
role="alert"
|
||||
>
|
||||
{embedStatus === "error"
|
||||
? <AlertTriangle className="devserver-preview-blocked-icon" aria-hidden="true" />
|
||||
: <ShieldAlert className="devserver-preview-blocked-icon" aria-hidden="true" />}
|
||||
<div>
|
||||
<p className="devserver-preview-blocked-title">
|
||||
{embedStatus === "error" ? t("devserver.previewFailed", "Preview failed") : t("devserver.previewBlocked", "Preview blocked")}
|
||||
</p>
|
||||
{blockReason && <p className="devserver-preview-blocked-context">{blockReason}</p>}
|
||||
</div>
|
||||
<p className="devserver-preview-blocked-description">
|
||||
{t("devserver.openPreviewOrRetry", "Open the preview in a new tab, or retry embedded mode after checking your server settings.")}
|
||||
</p>
|
||||
<div className="devserver-preview-blocked-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
onClick={handleOpenInNewTab}
|
||||
data-testid="devserver-preview-fallback-open-tab"
|
||||
>
|
||||
{t("devserver.openPreviewInNewTab", "Open preview in new tab")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm"
|
||||
onClick={handleRetryEmbeddedPreview}
|
||||
data-testid="devserver-preview-fallback-retry"
|
||||
>
|
||||
{t("devserver.retryEmbeddedPreview", "Retry embedded preview")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{effectivePreviewUrl && previewMode === "embedded" && !showFallback && (
|
||||
<PreviewIframe
|
||||
url={effectivePreviewUrl}
|
||||
embedStatus={embedStatus}
|
||||
onEmbedStatusChange={setEmbedStatus}
|
||||
iframeRef={iframeRef}
|
||||
blockReason={blockReason}
|
||||
onRetry={handleRetryEmbeddedPreview}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="dev-server-view" data-testid="dev-server-view">
|
||||
<div
|
||||
ref={rootRef}
|
||||
className="dev-server-view"
|
||||
data-testid="dev-server-view"
|
||||
data-narrow-right-dock-preview={isNarrowRightDockPreviewMode ? "true" : "false"}
|
||||
>
|
||||
{/*
|
||||
FNXC:DevServer 2026-06-22-01:00:
|
||||
Migrated to the shared ViewHeader for cross-view consistency. The status badge sits next to the title inside the actions slot (wrapped in .dev-server-header-title so the existing mobile flex-wrap rule still applies), and the Start/Stop/Restart controls follow in .dev-server-header-actions. ViewHeader supplies the standard view padding; the view body must not repeat the top padding.
|
||||
@@ -641,126 +915,75 @@ export function DevServerView({ addToast, projectId, tasks }: DevServerViewProps
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section className="dev-server-panel devserver-preview-panel" data-testid="devserver-preview-panel" aria-label={t("devserver.previewLabel", "Dev server preview")}>
|
||||
<div className="devserver-preview-header">
|
||||
<div className="devserver-preview-title">
|
||||
<Eye size={14} />
|
||||
<span>{t("devserver.preview", "Preview")}</span>
|
||||
{isNarrowRightDockPreviewMode ? (
|
||||
<section
|
||||
className="dev-server-panel devserver-preview-modal-launcher"
|
||||
data-testid="devserver-preview-modal-launcher"
|
||||
aria-label={t("devserver.previewLabel", "Dev server preview")}
|
||||
>
|
||||
<div className="devserver-preview-modal-launcher__copy">
|
||||
<div className="devserver-preview-title">
|
||||
<Eye size={14} />
|
||||
<span>{t("devserver.preview", "Preview")}</span>
|
||||
</div>
|
||||
<span
|
||||
className={`devserver-preview-url-badge ${isManualPreviewOverride ? "devserver-preview-url-badge--manual" : "devserver-preview-url-badge--auto"}`}
|
||||
title={effectivePreviewUrl ?? t("devserver.noPreviewUrl", "No preview URL")}
|
||||
data-testid="devserver-preview-url-badge"
|
||||
>
|
||||
{effectivePreviewUrl ? effectivePreviewUrl : t("devserver.notAvailable", "Not available")}
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
className={`devserver-preview-url-badge ${isManualPreviewOverride ? "devserver-preview-url-badge--manual" : "devserver-preview-url-badge--auto"}`}
|
||||
title={effectivePreviewUrl ?? t("devserver.noPreviewUrl", "No preview URL")}
|
||||
data-testid="devserver-preview-url-badge"
|
||||
<p className="devserver-preview-modal-launcher__description">
|
||||
{effectivePreviewUrl
|
||||
? t("devserver.previewModalLauncherDescription", "Open the live preview in a modal so logs and configuration stay usable in this narrow dock.")
|
||||
: t("devserver.previewModalLauncherUnavailable", "Start the dev server or set a preview URL to open the preview modal.")}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary btn-sm"
|
||||
ref={previewModalLauncherRef}
|
||||
onClick={() => setIsPreviewModalOpen(true)}
|
||||
data-testid="devserver-preview-modal-open"
|
||||
>
|
||||
{isManualPreviewOverride ? t("devserver.manual", "Manual") : t("devserver.auto", "Auto")}
|
||||
{effectivePreviewUrl ? ` · ${effectivePreviewUrl}` : t("devserver.notAvailable", " · Not available")}
|
||||
</span>
|
||||
<div className="devserver-preview-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm"
|
||||
onClick={() => setPreviewMode((current) => (current === "embedded" ? "external" : "embedded"))}
|
||||
data-testid="devserver-preview-mode-toggle"
|
||||
>
|
||||
{previewMode === "embedded" ? t("devserver.externalOnly", "External only") : t("devserver.embedded", "Embedded")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm btn-icon"
|
||||
title={t("devserver.openInNewTab", "Open in new tab")}
|
||||
onClick={handleOpenInNewTab}
|
||||
disabled={!effectivePreviewUrl}
|
||||
data-testid="devserver-preview-open-tab"
|
||||
>
|
||||
<ExternalLink />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm btn-icon"
|
||||
title={t("devserver.refreshPreview", "Refresh preview")}
|
||||
onClick={handleRefreshPreview}
|
||||
disabled={!effectivePreviewUrl}
|
||||
data-testid="devserver-preview-refresh"
|
||||
>
|
||||
<RefreshCw />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{t("devserver.openPreview", "Open preview")}
|
||||
</button>
|
||||
</section>
|
||||
) : (
|
||||
<section className="dev-server-panel devserver-preview-panel" data-testid="devserver-preview-panel" aria-label={t("devserver.previewLabel", "Dev server preview")}>
|
||||
{renderPreviewContent()}
|
||||
</section>
|
||||
)}
|
||||
|
||||
<div className="devserver-preview-container" data-embed-status={embedStatus} data-embedded={isEmbedded ? "true" : "false"}>
|
||||
{!effectivePreviewUrl && !isRunning && (
|
||||
<p className="devserver-preview-empty">{t("devserver.startDevServer", "Start a dev server to see a live preview here.")}</p>
|
||||
)}
|
||||
|
||||
{!effectivePreviewUrl && isRunning && (
|
||||
<p className="devserver-preview-empty">{t("devserver.noPreviewDetected", "No preview URL detected. Start the dev server or set a manual URL to preview your app.")}</p>
|
||||
)}
|
||||
|
||||
{effectivePreviewUrl && previewMode === "external" && (
|
||||
<div className="devserver-preview-external-only" data-testid="devserver-preview-external-only">
|
||||
<p>{t("devserver.embeddedPreviewDisabled", "Embedded preview is disabled. Open your app in a separate browser tab.")}</p>
|
||||
{isNarrowRightDockPreviewMode && isPreviewModalOpen && (
|
||||
<div className="modal-overlay open devserver-preview-modal-overlay" {...previewModalOverlayDismissProps}>
|
||||
<div
|
||||
className="modal devserver-preview-modal"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="devserver-preview-modal-title"
|
||||
tabIndex={-1}
|
||||
ref={previewModalRef}
|
||||
data-testid="devserver-preview-modal"
|
||||
>
|
||||
<div className="devserver-preview-modal__titlebar">
|
||||
<h2 id="devserver-preview-modal-title">{t("devserver.preview", "Preview")}</h2>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary btn-sm touch-target"
|
||||
onClick={handleOpenInNewTab}
|
||||
data-testid="devserver-preview-external-open-tab"
|
||||
className="btn btn-sm btn-icon"
|
||||
onClick={closePreviewModal}
|
||||
aria-label={t("devserver.closePreviewModal", "Close preview modal")}
|
||||
data-testid="devserver-preview-modal-close"
|
||||
>
|
||||
{t("devserver.openInNewTab", "Open in new tab")}
|
||||
<X />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{effectivePreviewUrl && previewMode === "embedded" && showFallback && isBlocked && (
|
||||
<div
|
||||
className={embedStatus === "error" ? "devserver-preview-error-panel" : "devserver-preview-blocked-panel"}
|
||||
data-testid="devserver-preview-fallback"
|
||||
role="alert"
|
||||
>
|
||||
{embedStatus === "error"
|
||||
? <AlertTriangle className="devserver-preview-blocked-icon" aria-hidden="true" />
|
||||
: <ShieldAlert className="devserver-preview-blocked-icon" aria-hidden="true" />}
|
||||
<div>
|
||||
<p className="devserver-preview-blocked-title">
|
||||
{embedStatus === "error" ? t("devserver.previewFailed", "Preview failed") : t("devserver.previewBlocked", "Preview blocked")}
|
||||
</p>
|
||||
{blockReason && <p className="devserver-preview-blocked-context">{blockReason}</p>}
|
||||
</div>
|
||||
<p className="devserver-preview-blocked-description">
|
||||
{t("devserver.openPreviewOrRetry", "Open the preview in a new tab, or retry embedded mode after checking your server settings.")}
|
||||
</p>
|
||||
<div className="devserver-preview-blocked-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
onClick={handleOpenInNewTab}
|
||||
data-testid="devserver-preview-fallback-open-tab"
|
||||
>
|
||||
{t("devserver.openPreviewInNewTab", "Open preview in new tab")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm"
|
||||
onClick={handleRetryEmbeddedPreview}
|
||||
data-testid="devserver-preview-fallback-retry"
|
||||
>
|
||||
{t("devserver.retryEmbeddedPreview", "Retry embedded preview")}
|
||||
</button>
|
||||
</div>
|
||||
<div className="devserver-preview-modal__body">
|
||||
{renderPreviewContent()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{effectivePreviewUrl && previewMode === "embedded" && !showFallback && (
|
||||
<PreviewIframe
|
||||
url={effectivePreviewUrl}
|
||||
embedStatus={embedStatus}
|
||||
onEmbedStatusChange={setEmbedStatus}
|
||||
iframeRef={iframeRef}
|
||||
blockReason={blockReason}
|
||||
onRetry={handleRetryEmbeddedPreview}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -696,13 +696,16 @@ The embedded title reads like other embedded-view titles (Planning modal-header-
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:SettingsMobile 2026-06-23-09:02:
|
||||
Settings section headings should preserve hierarchy through spacing and type only. Avoid per-heading divider borders so mobile and desktop shared Settings sections keep the lighter scrollbar-focused chrome contract.
|
||||
*/
|
||||
.settings-section-heading {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
padding: var(--space-lg) 0 var(--space-md);
|
||||
margin: 0 0 var(--space-md);
|
||||
color: var(--text);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
/* First heading inside the section drops top padding to remove a redundant
|
||||
|
||||
@@ -1851,17 +1851,22 @@ export function SettingsModal({
|
||||
return next;
|
||||
});
|
||||
try {
|
||||
/*
|
||||
FNXC:Notifications 2026-06-23-08:49:
|
||||
Settings notification tests must send the current unsaved ntfy form values for every ntfy test affordance. Users validate the exact topic/server/token they just typed before saving, so message/room test requests carry the same request-scoped config as the general ntfy test.
|
||||
*/
|
||||
const currentNtfyConfig = {
|
||||
ntfyEnabled: form.ntfyEnabled,
|
||||
ntfyTopic: form.ntfyTopic,
|
||||
...(form.ntfyBaseUrl?.trim() ? { ntfyBaseUrl: form.ntfyBaseUrl.trim() } : {}),
|
||||
...(form.ntfyAccessToken?.trim() ? { ntfyAccessToken: form.ntfyAccessToken.trim() } : {}),
|
||||
};
|
||||
const config = providerId === "ntfy"
|
||||
? {
|
||||
ntfyEnabled: form.ntfyEnabled,
|
||||
ntfyTopic: form.ntfyTopic,
|
||||
...(form.ntfyBaseUrl?.trim() ? { ntfyBaseUrl: form.ntfyBaseUrl.trim() } : {}),
|
||||
...(form.ntfyAccessToken?.trim() ? { ntfyAccessToken: form.ntfyAccessToken.trim() } : {}),
|
||||
}
|
||||
? currentNtfyConfig
|
||||
: providerId === "ntfy-message"
|
||||
? { messageEventType: "message:agent-to-user" }
|
||||
? { ...currentNtfyConfig, messageEventType: "message:agent-to-user" }
|
||||
: providerId === "ntfy-room"
|
||||
? { messageEventType: "message:room" }
|
||||
? { ...currentNtfyConfig, messageEventType: "message:room" }
|
||||
: {
|
||||
webhookUrl: form.webhookUrl,
|
||||
webhookFormat: form.webhookFormat || "generic",
|
||||
|
||||
@@ -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;
|
||||
@@ -51,12 +51,27 @@ describe("DevServerView mobile CSS/structure", () => {
|
||||
const mobileBlockMatch = css.match(/@media[^{]*\(max-width: 768px\)[^{]*\{([\s\S]*?)\n\}/g) ?? [];
|
||||
const mobileCss = mobileBlockMatch.join("\n");
|
||||
|
||||
const headerRuleCount = (mobileCss.match(/\.devserver-preview-header\s*\{/g) ?? []).length;
|
||||
const headerRuleCount = (mobileCss.match(/\.devserver-preview-header,\s*\.devserver-preview-modal-launcher__copy\s*\{/g) ?? []).length;
|
||||
expect(headerRuleCount).toBe(1);
|
||||
expect(mobileCss).toMatch(/\.devserver-preview-url-badge\s*\{[\s\S]*max-width:\s*100%/);
|
||||
expect(mobileCss).toMatch(/\.dev-server-header-title\s*\{[\s\S]*flex-wrap:\s*wrap/);
|
||||
});
|
||||
|
||||
it("defines narrow right-dock launcher and modal rules without duplicating mobile media rules", () => {
|
||||
const css = loadAllAppCss();
|
||||
const containerStart = css.indexOf("@container right-dock-body (max-width: 768px)");
|
||||
expect(containerStart).toBeGreaterThan(-1);
|
||||
const containerCss = css.slice(containerStart);
|
||||
|
||||
expect(containerCss).toMatch(/\.devserver-preview-panel,\s*\.devserver-preview-modal-launcher\s*\{[\s\S]*grid-column:\s*auto/);
|
||||
expect(containerCss).toMatch(/\.devserver-preview-modal\s*\{[\s\S]*width:\s*min\(calc\(var\(--space-2xl\) \* 20\), calc\(100vw - var\(--space-md\) \* 2\)\)/);
|
||||
expect(containerCss).toMatch(/\.devserver-preview-panel \.devserver-preview-container/);
|
||||
expect(containerCss).not.toMatch(/\.dev-server-logs,\s*\.devserver-preview-container,\s*\.devserver-preview-iframe/);
|
||||
|
||||
expect(css).toMatch(/@media[^{]*\(max-width: 768px\)/);
|
||||
expect(css).toMatch(/@container right-dock-body \(max-width: 768px\)/);
|
||||
});
|
||||
|
||||
it("renders preview header elements and keeps URL badge outside preview actions", () => {
|
||||
mockUseDevServer.mockReturnValue(createDevServerHookState());
|
||||
mockUseDevServerLogs.mockReturnValue({
|
||||
|
||||
@@ -44,6 +44,7 @@ vi.mock("lucide-react", () => ({
|
||||
Search: () => <span data-testid="icon-search" />,
|
||||
ShieldAlert: () => <span data-testid="icon-shield-alert" />,
|
||||
Square: () => <span data-testid="icon-square" />,
|
||||
X: () => <span data-testid="icon-x" />,
|
||||
}));
|
||||
|
||||
function createState(overrides: Partial<DevServerState> = {}): DevServerState {
|
||||
@@ -201,6 +202,153 @@ describe("DevServerView preview panel", () => {
|
||||
|
||||
afterEach(() => {
|
||||
window.open = originalWindowOpen;
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
function renderInRightDock(width: number) {
|
||||
const host = document.createElement("div");
|
||||
host.className = "right-dock__body";
|
||||
Object.defineProperty(host, "clientWidth", { configurable: true, value: width });
|
||||
document.body.appendChild(host);
|
||||
|
||||
return render(<DevServerView addToast={addToast} projectId="project-a" />, { container: host });
|
||||
}
|
||||
|
||||
it("activates narrow right-dock preview mode only below the dock threshold", async () => {
|
||||
mockUseDevServer.mockReturnValue(
|
||||
createDevServerHookState({ serverState: createState({ status: "running", previewUrl: "http://localhost:3000" }) }),
|
||||
);
|
||||
|
||||
const narrow = renderInRightDock(420);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("dev-server-view")).toHaveAttribute("data-narrow-right-dock-preview", "true");
|
||||
});
|
||||
|
||||
narrow.unmount();
|
||||
document.body.innerHTML = "";
|
||||
|
||||
renderInRightDock(640);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("dev-server-view")).toHaveAttribute("data-narrow-right-dock-preview", "false");
|
||||
});
|
||||
expect(screen.queryByTestId("devserver-preview-modal-launcher")).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId("devserver-preview-panel")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("replaces the narrow right-dock inline preview with an accessible modal launcher", async () => {
|
||||
mockUseDevServer.mockReturnValue(
|
||||
createDevServerHookState({ serverState: createState({ status: "running", previewUrl: "http://localhost:3000" }) }),
|
||||
);
|
||||
mockUseDevServerLogs.mockReturnValue(createDevServerLogsHookState({
|
||||
entries: [{ id: "log-1", timestamp: "2026-06-23T00:00:00.000Z", stream: "stdout", text: "ready" }],
|
||||
total: 1,
|
||||
}));
|
||||
previewEmbedState = createPreviewEmbedState({ embedStatus: "embedded", isEmbedded: true });
|
||||
|
||||
renderInRightDock(420);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("dev-server-view")).toHaveAttribute("data-narrow-right-dock-preview", "true");
|
||||
});
|
||||
|
||||
expect(screen.getByTestId("dev-server-logs-panel")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("devserver-preview-panel")).not.toBeInTheDocument();
|
||||
expect(screen.queryByTitle("Dev server preview")).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId("devserver-preview-modal-launcher")).toHaveTextContent("http://localhost:3000");
|
||||
expect(screen.getByTestId("devserver-preview-url-badge")).toHaveTextContent("http://localhost:3000");
|
||||
|
||||
fireEvent.click(screen.getByTestId("devserver-preview-modal-open"));
|
||||
|
||||
const modal = await screen.findByTestId("devserver-preview-modal");
|
||||
expect(modal).toHaveAttribute("role", "dialog");
|
||||
expect(modal).toHaveAttribute("aria-modal", "true");
|
||||
expect(screen.getByTitle("Dev server preview")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("devserver-preview-open-tab")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("devserver-preview-refresh")).toBeInTheDocument();
|
||||
|
||||
fireEvent.keyDown(document, { key: "Escape" });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId("devserver-preview-modal")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps preview modes and fallback actions inside the narrow dock modal", async () => {
|
||||
const retry = vi.fn();
|
||||
mockUseDevServer.mockReturnValue(
|
||||
createDevServerHookState({ serverState: createState({ status: "running", previewUrl: "http://localhost:3000" }) }),
|
||||
);
|
||||
previewEmbedState = createPreviewEmbedState({ embedStatus: "embedded", isEmbedded: true });
|
||||
|
||||
const { rerender } = renderInRightDock(420);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("dev-server-view")).toHaveAttribute("data-narrow-right-dock-preview", "true");
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByTestId("devserver-preview-modal-open"));
|
||||
|
||||
previewEmbedState = createPreviewEmbedState({
|
||||
embedStatus: "blocked",
|
||||
isBlocked: true,
|
||||
embedContext: "The server may block iframe embedding...",
|
||||
retry,
|
||||
});
|
||||
rerender(<DevServerView addToast={addToast} projectId="project-a" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("devserver-preview-fallback")).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText("Preview blocked")).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByTestId("devserver-preview-fallback-retry"));
|
||||
expect(retry).toHaveBeenCalledTimes(1);
|
||||
|
||||
previewEmbedState = createPreviewEmbedState({ embedStatus: "embedded", isEmbedded: true });
|
||||
rerender(<DevServerView addToast={addToast} projectId="project-a" />);
|
||||
fireEvent.click(screen.getByTestId("devserver-preview-mode-toggle"));
|
||||
|
||||
expect(screen.getByTestId("devserver-preview-external-only")).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByTestId("devserver-preview-external-open-tab"));
|
||||
expect(window.open).toHaveBeenCalledWith("http://localhost:3000", "_blank", "noopener,noreferrer");
|
||||
});
|
||||
|
||||
it("keeps inline preview mode for true mobile viewport and expanded right-dock hosts", async () => {
|
||||
vi.stubGlobal("matchMedia", vi.fn().mockImplementation((query: string) => ({
|
||||
matches: query === "(max-width: 768px)",
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
})));
|
||||
mockUseDevServer.mockReturnValue(
|
||||
createDevServerHookState({ serverState: createState({ status: "running", previewUrl: "http://localhost:3000" }) }),
|
||||
);
|
||||
|
||||
const mobile = renderInRightDock(420);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("dev-server-view")).toHaveAttribute("data-narrow-right-dock-preview", "false");
|
||||
});
|
||||
|
||||
mobile.unmount();
|
||||
document.body.innerHTML = "";
|
||||
vi.unstubAllGlobals();
|
||||
|
||||
const expandedHost = document.createElement("div");
|
||||
expandedHost.className = "right-dock-expand-modal__body";
|
||||
Object.defineProperty(expandedHost, "clientWidth", { configurable: true, value: 420 });
|
||||
document.body.appendChild(expandedHost);
|
||||
|
||||
render(<DevServerView addToast={addToast} projectId="project-a" />, { container: expandedHost });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("dev-server-view")).toHaveAttribute("data-narrow-right-dock-preview", "false");
|
||||
});
|
||||
});
|
||||
|
||||
it("shows start-empty state when server is not configured", () => {
|
||||
|
||||
@@ -4895,6 +4895,54 @@ describe("SettingsModal", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("sends unsaved ntfy form config before saving", async () => {
|
||||
mockFetchSettings.mockResolvedValueOnce({ ...defaultSettings, ntfyEnabled: false, ntfyTopic: undefined });
|
||||
renderModal();
|
||||
await waitForSettingsModalReady();
|
||||
await openNotificationsSection();
|
||||
|
||||
await user.click(screen.getByLabelText("Enable"));
|
||||
await user.type(screen.getByLabelText("ntfy Topic"), "fresh-topic");
|
||||
await user.click(screen.getByText("Advanced"));
|
||||
await user.type(screen.getByLabelText("Custom ntfy server URL (optional)"), "https://ntfy.override.example//");
|
||||
await user.type(screen.getByLabelText("Access token (optional)"), "override-token");
|
||||
await user.click(screen.getByRole("button", { name: /Test notification/ }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockTestNotification).toHaveBeenCalledWith(
|
||||
"ntfy",
|
||||
expect.objectContaining({
|
||||
ntfyEnabled: true,
|
||||
ntfyTopic: "fresh-topic",
|
||||
ntfyBaseUrl: "https://ntfy.override.example//",
|
||||
ntfyAccessToken: "override-token",
|
||||
}),
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
expect(mockUpdateSettings).not.toHaveBeenCalled();
|
||||
expect(mockUpdateGlobalSettings).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps ntfy test disabled until the current form has a valid topic", async () => {
|
||||
mockFetchSettings.mockResolvedValueOnce({ ...defaultSettings, ntfyEnabled: false, ntfyTopic: undefined });
|
||||
renderModal();
|
||||
await waitForSettingsModalReady();
|
||||
await openNotificationsSection();
|
||||
|
||||
await user.click(screen.getByLabelText("Enable"));
|
||||
const testButton = screen.getByRole("button", { name: /Test notification/ });
|
||||
expect(testButton).toBeDisabled();
|
||||
|
||||
await user.type(screen.getByLabelText("ntfy Topic"), "bad topic!");
|
||||
expect(testButton).toBeDisabled();
|
||||
expect(mockTestNotification).not.toHaveBeenCalled();
|
||||
|
||||
await user.clear(screen.getByLabelText("ntfy Topic"));
|
||||
await user.type(screen.getByLabelText("ntfy Topic"), "fresh-topic");
|
||||
expect(testButton).toBeEnabled();
|
||||
});
|
||||
|
||||
it("clears a saved ntfy access token via global null-as-delete semantics", async () => {
|
||||
mockFetchSettings.mockResolvedValueOnce({
|
||||
...defaultSettings,
|
||||
@@ -4929,7 +4977,11 @@ describe("SettingsModal", () => {
|
||||
await waitFor(() => {
|
||||
expect(mockTestNotification).toHaveBeenCalledWith(
|
||||
"ntfy",
|
||||
{ messageEventType: "message:agent-to-user" },
|
||||
expect.objectContaining({
|
||||
messageEventType: "message:agent-to-user",
|
||||
ntfyEnabled: true,
|
||||
ntfyTopic: "test-topic",
|
||||
}),
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
@@ -4953,7 +5005,11 @@ describe("SettingsModal", () => {
|
||||
await waitFor(() => {
|
||||
expect(mockTestNotification).toHaveBeenCalledWith(
|
||||
"ntfy",
|
||||
{ messageEventType: "message:room" },
|
||||
expect.objectContaining({
|
||||
messageEventType: "message:room",
|
||||
ntfyEnabled: true,
|
||||
ntfyTopic: "test-topic",
|
||||
}),
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1772,6 +1772,37 @@ describe("POST /settings/test-ntfy", () => {
|
||||
expect(url).toBe("https://ntfy.override.example/my-topic");
|
||||
});
|
||||
|
||||
it("uses unsaved request ntfy config when saved settings are disabled", async () => {
|
||||
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
ntfyEnabled: false,
|
||||
ntfyTopic: undefined,
|
||||
ntfyBaseUrl: "https://ntfy.saved.example",
|
||||
ntfyAccessToken: "saved-token",
|
||||
});
|
||||
|
||||
const res = await REQUEST(
|
||||
buildApp(),
|
||||
"POST",
|
||||
"/api/settings/test-ntfy",
|
||||
JSON.stringify({
|
||||
ntfyEnabled: true,
|
||||
ntfyTopic: "fresh-topic",
|
||||
ntfyBaseUrl: "https://ntfy.override.example//",
|
||||
ntfyAccessToken: "override-token",
|
||||
}),
|
||||
{ "content-type": "application/json" },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(store.updateSettings).not.toHaveBeenCalled();
|
||||
expect(store.updateGlobalSettings).not.toHaveBeenCalled();
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
||||
const url = fetchSpy.mock.calls[0]?.[0] as string;
|
||||
const options = fetchSpy.mock.calls[0]?.[1] as RequestInit;
|
||||
expect(url).toBe("https://ntfy.override.example/fresh-topic");
|
||||
expect(options.headers).toHaveProperty("Authorization", "Bearer override-token");
|
||||
});
|
||||
|
||||
it("falls back to saved ntfyBaseUrl when request override is blank", async () => {
|
||||
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
ntfyEnabled: true,
|
||||
@@ -1973,69 +2004,82 @@ describe("POST /settings/test-notification", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("ntfy provider dispatches a message-event pipeline test when messageEventType is provided", async () => {
|
||||
const dispatchSpy = vi.fn().mockResolvedValue(undefined);
|
||||
mockGetActiveNotificationService.mockReturnValue({ dispatch: dispatchSpy });
|
||||
it("ntfy provider sends a message-event test with unsaved config when messageEventType is provided", async () => {
|
||||
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
ntfyEnabled: true,
|
||||
ntfyTopic: "test-topic",
|
||||
ntfyEnabled: false,
|
||||
ntfyTopic: "saved-topic",
|
||||
ntfyBaseUrl: "https://ntfy.saved.example",
|
||||
ntfyAccessToken: "saved-token",
|
||||
});
|
||||
|
||||
const res = await REQUEST(
|
||||
buildApp(),
|
||||
"POST",
|
||||
"/api/settings/test-notification",
|
||||
JSON.stringify({ providerId: "ntfy", messageEventType: "message:agent-to-user" }),
|
||||
JSON.stringify({
|
||||
providerId: "ntfy",
|
||||
config: {
|
||||
messageEventType: "message:agent-to-user",
|
||||
ntfyEnabled: true,
|
||||
ntfyTopic: "fresh-message-topic",
|
||||
ntfyBaseUrl: "https://ntfy.message.example//",
|
||||
ntfyAccessToken: "message-token",
|
||||
},
|
||||
}),
|
||||
{ "content-type": "application/json" },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ success: true });
|
||||
expect(dispatchSpy).toHaveBeenCalledWith(
|
||||
"message:agent-to-user",
|
||||
expect.objectContaining({
|
||||
event: "message:agent-to-user",
|
||||
metadata: expect.objectContaining({
|
||||
fromId: "system",
|
||||
toId: "user",
|
||||
preview: "Fusion test message notification",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
expect(mockGetActiveNotificationService).not.toHaveBeenCalled();
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
||||
const options = fetchSpy.mock.calls[0]?.[1] as RequestInit;
|
||||
expect(fetchSpy.mock.calls[0]?.[0]).toBe("https://ntfy.message.example/fresh-message-topic");
|
||||
expect(options.headers).toMatchObject({
|
||||
Title: "New message from Fusion",
|
||||
Priority: "high",
|
||||
Authorization: "Bearer message-token",
|
||||
});
|
||||
expect(options.body).toBe("Fusion → you: Fusion test message notification");
|
||||
});
|
||||
|
||||
it("ntfy provider dispatches a room message-event pipeline test when messageEventType is message:room", async () => {
|
||||
const dispatchSpy = vi.fn().mockResolvedValue(undefined);
|
||||
mockGetActiveNotificationService.mockReturnValue({ dispatch: dispatchSpy });
|
||||
it("ntfy provider sends a room message-event test with unsaved config when messageEventType is message:room", async () => {
|
||||
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
ntfyEnabled: true,
|
||||
ntfyTopic: "test-topic",
|
||||
ntfyEnabled: false,
|
||||
ntfyTopic: "saved-topic",
|
||||
ntfyBaseUrl: "https://ntfy.saved.example",
|
||||
ntfyAccessToken: "saved-token",
|
||||
});
|
||||
|
||||
const res = await REQUEST(
|
||||
buildApp(),
|
||||
"POST",
|
||||
"/api/settings/test-notification",
|
||||
JSON.stringify({ providerId: "ntfy", messageEventType: "message:room" }),
|
||||
JSON.stringify({
|
||||
providerId: "ntfy",
|
||||
config: {
|
||||
messageEventType: "message:room",
|
||||
ntfyEnabled: true,
|
||||
ntfyTopic: "fresh-room-topic",
|
||||
ntfyBaseUrl: "https://ntfy.room.example//",
|
||||
ntfyAccessToken: "room-token",
|
||||
},
|
||||
}),
|
||||
{ "content-type": "application/json" },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ success: true });
|
||||
expect(dispatchSpy).toHaveBeenCalledWith(
|
||||
"message:room",
|
||||
expect.objectContaining({
|
||||
event: "message:room",
|
||||
metadata: expect.objectContaining({
|
||||
roomId: "test-room",
|
||||
roomName: "Test Room",
|
||||
senderName: "Fusion",
|
||||
preview: "Fusion test room notification",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
expect(mockGetActiveNotificationService).not.toHaveBeenCalled();
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
||||
const options = fetchSpy.mock.calls[0]?.[1] as RequestInit;
|
||||
expect(fetchSpy.mock.calls[0]?.[0]).toBe("https://ntfy.room.example/fresh-room-topic");
|
||||
expect(options.headers).toMatchObject({
|
||||
Title: "#Test Room — Fusion",
|
||||
Priority: "default",
|
||||
Authorization: "Bearer room-token",
|
||||
});
|
||||
expect(options.body).toBe("Fusion in #Test Room: Fusion test room notification");
|
||||
});
|
||||
|
||||
it("ntfy provider uses config override for baseUrl", async () => {
|
||||
@@ -2057,6 +2101,69 @@ describe("POST /settings/test-notification", () => {
|
||||
expect(fetchSpy.mock.calls[0]?.[0]).toBe("https://ntfy.override.example/my-topic");
|
||||
});
|
||||
|
||||
it("ntfy provider sends with unsaved config when saved settings are disabled", async () => {
|
||||
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
ntfyEnabled: false,
|
||||
ntfyTopic: undefined,
|
||||
ntfyBaseUrl: "https://ntfy.saved.example",
|
||||
ntfyAccessToken: "saved-token",
|
||||
});
|
||||
|
||||
const res = await REQUEST(
|
||||
buildApp(),
|
||||
"POST",
|
||||
"/api/settings/test-notification",
|
||||
JSON.stringify({
|
||||
providerId: "ntfy",
|
||||
config: {
|
||||
ntfyEnabled: true,
|
||||
ntfyTopic: "fresh-topic",
|
||||
ntfyBaseUrl: "https://ntfy.override.example//",
|
||||
ntfyAccessToken: "override-token",
|
||||
},
|
||||
}),
|
||||
{ "content-type": "application/json" },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(store.updateSettings).not.toHaveBeenCalled();
|
||||
expect(store.updateGlobalSettings).not.toHaveBeenCalled();
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
||||
const options = fetchSpy.mock.calls[0]?.[1] as RequestInit;
|
||||
expect(fetchSpy.mock.calls[0]?.[0]).toBe("https://ntfy.override.example/fresh-topic");
|
||||
expect(options.headers).toHaveProperty("Authorization", "Bearer override-token");
|
||||
});
|
||||
|
||||
it("ntfy provider ignores blank request baseUrl and token overrides", async () => {
|
||||
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
ntfyEnabled: true,
|
||||
ntfyTopic: "saved-topic",
|
||||
ntfyBaseUrl: "https://ntfy.saved.example",
|
||||
ntfyAccessToken: "saved-token",
|
||||
});
|
||||
|
||||
const res = await REQUEST(
|
||||
buildApp(),
|
||||
"POST",
|
||||
"/api/settings/test-notification",
|
||||
JSON.stringify({
|
||||
providerId: "ntfy",
|
||||
config: {
|
||||
ntfyEnabled: true,
|
||||
ntfyTopic: "fresh-topic",
|
||||
ntfyBaseUrl: " ",
|
||||
ntfyAccessToken: " ",
|
||||
},
|
||||
}),
|
||||
{ "content-type": "application/json" },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const options = fetchSpy.mock.calls[0]?.[1] as RequestInit;
|
||||
expect(fetchSpy.mock.calls[0]?.[0]).toBe("https://ntfy.saved.example/fresh-topic");
|
||||
expect(options.headers).toHaveProperty("Authorization", "Bearer saved-token");
|
||||
});
|
||||
|
||||
it("ntfy provider sends Authorization header from saved or override token", async () => {
|
||||
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
ntfyEnabled: true,
|
||||
|
||||
@@ -47,7 +47,6 @@ import {
|
||||
import {
|
||||
buildSessionSkillContextSync,
|
||||
createFnAgent as engineCreateFnAgent,
|
||||
getActiveNotificationService,
|
||||
probeWorktrunk,
|
||||
resolveWorktrunkBinary,
|
||||
} from "@fusion/engine";
|
||||
@@ -2044,84 +2043,160 @@ export function registerSettingsMemoryRoutes(ctx: ApiRoutesContext, deps: Settin
|
||||
* Returns the user's global pi extension settings from ~/.pi/agent/settings.json.
|
||||
* Includes packages, extension paths, skill paths, prompt template paths, and theme paths.
|
||||
*/
|
||||
router.post("/settings/test-ntfy", async (req, res) => {
|
||||
const normalizeNtfyBaseUrl = (value: string, source: "request" | "settings"): string => {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
throw badRequest("ntfy server URL cannot be empty");
|
||||
}
|
||||
const normalizeHttpUrl = (value: string, fieldName: string): string => {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
throw badRequest(`${fieldName} cannot be empty`);
|
||||
}
|
||||
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(trimmed);
|
||||
} catch {
|
||||
throw badRequest(`ntfy server URL from ${source} must be a valid URL`);
|
||||
}
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(trimmed);
|
||||
} catch {
|
||||
throw badRequest(`${fieldName} must be a valid URL`);
|
||||
}
|
||||
|
||||
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
||||
throw badRequest("ntfy server URL must use http:// or https://");
|
||||
}
|
||||
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
||||
throw badRequest(`${fieldName} must use http:// or https://`);
|
||||
}
|
||||
|
||||
return trimmed.replace(/\/+$/, "");
|
||||
return trimmed;
|
||||
};
|
||||
|
||||
const normalizeNtfyBaseUrl = (value: string, source: "request" | "settings"): string => {
|
||||
const normalized = normalizeHttpUrl(value, `ntfy server URL from ${source}`);
|
||||
return normalized.replace(/\/+$/, "");
|
||||
};
|
||||
|
||||
const getOwnValue = (source: Record<string, unknown>, key: string): unknown => (
|
||||
Object.prototype.hasOwnProperty.call(source, key) ? source[key] : undefined
|
||||
);
|
||||
|
||||
const getRequestNtfyValue = (body: Record<string, unknown>, config: Record<string, unknown>, key: string): unknown => {
|
||||
const configValue = getOwnValue(config, key);
|
||||
return configValue !== undefined ? configValue : getOwnValue(body, key);
|
||||
};
|
||||
|
||||
type NtfyTestMessageEventType = "message:agent-to-user" | "message:agent-to-agent" | "message:room";
|
||||
|
||||
function resolveEffectiveNtfyTestConfig(
|
||||
settings: Record<string, unknown>,
|
||||
body: Record<string, unknown>,
|
||||
config: Record<string, unknown> = {},
|
||||
): { topic: string; ntfyBaseUrl: string; ntfyAccessToken?: string } {
|
||||
/*
|
||||
FNXC:Notifications 2026-06-23-08:34:
|
||||
Test sends must honor unsaved Settings form state because users enable ntfy, enter a topic/server/token, and test before saving. Resolve request-scoped values ahead of persisted settings without persisting or logging tokens.
|
||||
|
||||
FNXC:Notifications 2026-06-23-10:21:
|
||||
Every ntfy test affordance, including message and room tests, must publish with the request-scoped topic/server/token instead of the active notification service's persisted provider state.
|
||||
*/
|
||||
const enabledOverride = getRequestNtfyValue(body, config, "ntfyEnabled");
|
||||
if (enabledOverride !== undefined && enabledOverride !== null && typeof enabledOverride !== "boolean") {
|
||||
throw badRequest("ntfy enabled must be a boolean");
|
||||
}
|
||||
const ntfyEnabled = typeof enabledOverride === "boolean" ? enabledOverride : settings.ntfyEnabled === true;
|
||||
if (!ntfyEnabled) {
|
||||
throw badRequest("ntfy notifications are not enabled");
|
||||
}
|
||||
|
||||
const topicOverride = getRequestNtfyValue(body, config, "ntfyTopic");
|
||||
if (topicOverride !== undefined && topicOverride !== null && typeof topicOverride !== "string") {
|
||||
throw badRequest("ntfy topic must be a string");
|
||||
}
|
||||
const topic = typeof topicOverride === "string" ? topicOverride : settings.ntfyTopic;
|
||||
if (typeof topic !== "string" || !/^[a-zA-Z0-9_-]{1,64}$/.test(topic)) {
|
||||
throw badRequest("ntfy topic is not configured or invalid");
|
||||
}
|
||||
|
||||
const baseUrlOverride = getRequestNtfyValue(body, config, "ntfyBaseUrl");
|
||||
if (baseUrlOverride !== undefined && baseUrlOverride !== null && typeof baseUrlOverride !== "string") {
|
||||
throw badRequest("ntfy server URL must be a string");
|
||||
}
|
||||
const requestBaseUrl = typeof baseUrlOverride === "string" && baseUrlOverride.trim()
|
||||
? normalizeNtfyBaseUrl(baseUrlOverride, "request")
|
||||
: undefined;
|
||||
const storedBaseUrl = typeof settings.ntfyBaseUrl === "string" && settings.ntfyBaseUrl.trim()
|
||||
? normalizeNtfyBaseUrl(settings.ntfyBaseUrl, "settings")
|
||||
: undefined;
|
||||
|
||||
const tokenOverride = getRequestNtfyValue(body, config, "ntfyAccessToken");
|
||||
if (tokenOverride !== undefined && tokenOverride !== null && typeof tokenOverride !== "string") {
|
||||
throw badRequest("ntfy access token must be a string");
|
||||
}
|
||||
const requestToken = typeof tokenOverride === "string" && tokenOverride.trim()
|
||||
? tokenOverride.trim()
|
||||
: undefined;
|
||||
const storedToken = typeof settings.ntfyAccessToken === "string" && settings.ntfyAccessToken.trim()
|
||||
? settings.ntfyAccessToken.trim()
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
topic,
|
||||
ntfyBaseUrl: requestBaseUrl ?? storedBaseUrl ?? "https://ntfy.sh",
|
||||
ntfyAccessToken: requestToken ?? storedToken,
|
||||
};
|
||||
}
|
||||
|
||||
async function sendNtfyTestNotification(
|
||||
options: { topic: string; ntfyBaseUrl: string; ntfyAccessToken?: string; messageEventType?: NtfyTestMessageEventType },
|
||||
): Promise<void> {
|
||||
const contentByEvent: Record<NtfyTestMessageEventType | "default", { title: string; message: string; priority: "default" | "high" }> = {
|
||||
default: {
|
||||
title: "Fusion test notification",
|
||||
message: "Fusion test notification — your notifications are working!",
|
||||
priority: "default",
|
||||
},
|
||||
"message:agent-to-user": {
|
||||
title: "New message from Fusion",
|
||||
message: "Fusion → you: Fusion test message notification",
|
||||
priority: "high",
|
||||
},
|
||||
"message:agent-to-agent": {
|
||||
title: "Fusion → recipient",
|
||||
message: "Fusion messaged recipient: Fusion test message notification",
|
||||
priority: "default",
|
||||
},
|
||||
"message:room": {
|
||||
title: "#Test Room — Fusion",
|
||||
message: "Fusion in #Test Room: Fusion test room notification",
|
||||
priority: "default",
|
||||
},
|
||||
};
|
||||
const content = contentByEvent[options.messageEventType ?? "default"];
|
||||
const headers: Record<string, string> = {
|
||||
Title: content.title,
|
||||
Priority: content.priority,
|
||||
"Content-Type": "text/plain",
|
||||
};
|
||||
if (options.ntfyAccessToken) {
|
||||
headers.Authorization = `Bearer ${options.ntfyAccessToken}`;
|
||||
}
|
||||
|
||||
const response = await fetch(`${options.ntfyBaseUrl}/${options.topic}`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: content.message,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new ApiError(502, `ntfy server returned ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
}
|
||||
|
||||
router.post("/settings/test-ntfy", async (req, res) => {
|
||||
|
||||
try {
|
||||
const body = (req.body ?? {}) as Record<string, unknown>;
|
||||
const configValue = body.config;
|
||||
if (configValue !== undefined && (typeof configValue !== "object" || configValue === null || Array.isArray(configValue))) {
|
||||
throw badRequest("config must be an object when provided");
|
||||
}
|
||||
const config = (configValue ?? {}) as Record<string, unknown>;
|
||||
const { store: scopedStore } = await getProjectContext(req);
|
||||
const settings = await scopedStore.getSettings();
|
||||
|
||||
// Validate ntfy is enabled
|
||||
if (!settings.ntfyEnabled) {
|
||||
throw badRequest("ntfy notifications are not enabled");
|
||||
}
|
||||
|
||||
// Validate topic exists and matches required format
|
||||
const topic = settings.ntfyTopic;
|
||||
if (!topic || !/^[a-zA-Z0-9_-]{1,64}$/.test(topic)) {
|
||||
throw badRequest("ntfy topic is not configured or invalid");
|
||||
}
|
||||
|
||||
const overrideValue = req.body?.ntfyBaseUrl;
|
||||
if (overrideValue !== undefined && overrideValue !== null && typeof overrideValue !== "string") {
|
||||
throw badRequest("ntfy server URL must be a string");
|
||||
}
|
||||
|
||||
const requestOverride = typeof overrideValue === "string" && overrideValue.trim()
|
||||
? normalizeNtfyBaseUrl(overrideValue, "request")
|
||||
: undefined;
|
||||
const storedServer = typeof settings.ntfyBaseUrl === "string" && settings.ntfyBaseUrl.trim()
|
||||
? normalizeNtfyBaseUrl(settings.ntfyBaseUrl, "settings")
|
||||
: undefined;
|
||||
const tokenOverride = req.body?.ntfyAccessToken;
|
||||
if (tokenOverride !== undefined && tokenOverride !== null && typeof tokenOverride !== "string") {
|
||||
throw badRequest("ntfy access token must be a string");
|
||||
}
|
||||
const requestToken = typeof tokenOverride === "string" && tokenOverride.trim()
|
||||
? tokenOverride.trim()
|
||||
: undefined;
|
||||
const storedToken = typeof settings.ntfyAccessToken === "string" && settings.ntfyAccessToken.trim()
|
||||
? settings.ntfyAccessToken.trim()
|
||||
: undefined;
|
||||
const ntfyBaseUrl = requestOverride ?? storedServer ?? "https://ntfy.sh";
|
||||
const url = `${ntfyBaseUrl}/${topic}`;
|
||||
const headers: Record<string, string> = {
|
||||
"Title": "Fusion test notification",
|
||||
"Priority": "default",
|
||||
"Content-Type": "text/plain",
|
||||
};
|
||||
const ntfyAccessToken = requestToken ?? storedToken;
|
||||
if (ntfyAccessToken) {
|
||||
headers.Authorization = `Bearer ${ntfyAccessToken}`;
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: "Fusion test notification — your notifications are working!",
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new ApiError(502, `ntfy server returned ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
const configForTest = resolveEffectiveNtfyTestConfig(settings as Record<string, unknown>, body, config);
|
||||
await sendNtfyTestNotification(configForTest);
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (err: unknown) {
|
||||
@@ -2133,31 +2208,6 @@ export function registerSettingsMemoryRoutes(ctx: ApiRoutesContext, deps: Settin
|
||||
});
|
||||
|
||||
router.post("/settings/test-notification", async (req, res) => {
|
||||
const normalizeHttpUrl = (value: string, fieldName: string): string => {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
throw badRequest(`${fieldName} cannot be empty`);
|
||||
}
|
||||
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(trimmed);
|
||||
} catch {
|
||||
throw badRequest(`${fieldName} must be a valid URL`);
|
||||
}
|
||||
|
||||
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
||||
throw badRequest(`${fieldName} must use http:// or https://`);
|
||||
}
|
||||
|
||||
return trimmed;
|
||||
};
|
||||
|
||||
const normalizeNtfyBaseUrl = (value: string, source: "request" | "settings"): string => {
|
||||
const normalized = normalizeHttpUrl(value, `ntfy server URL from ${source}`);
|
||||
return normalized.replace(/\/+$/, "");
|
||||
};
|
||||
|
||||
try {
|
||||
const body = (req.body ?? {}) as Record<string, unknown>;
|
||||
const providerId = body.providerId;
|
||||
@@ -2176,113 +2226,20 @@ export function registerSettingsMemoryRoutes(ctx: ApiRoutesContext, deps: Settin
|
||||
|
||||
if (providerId === "ntfy") {
|
||||
const requestedMessageEventType = config.messageEventType ?? body.messageEventType;
|
||||
if (requestedMessageEventType !== undefined) {
|
||||
if (
|
||||
requestedMessageEventType !== "message:agent-to-user"
|
||||
&& requestedMessageEventType !== "message:agent-to-agent"
|
||||
&& requestedMessageEventType !== "message:room"
|
||||
) {
|
||||
throw badRequest("messageEventType must be message:agent-to-user, message:agent-to-agent, or message:room");
|
||||
}
|
||||
|
||||
const notificationService = getActiveNotificationService();
|
||||
if (!notificationService) {
|
||||
throw new ApiError(502, "Notification service is not active");
|
||||
}
|
||||
|
||||
try {
|
||||
const messageId = `test-${crypto.randomUUID()}`;
|
||||
if (requestedMessageEventType === "message:room") {
|
||||
await notificationService.dispatch(requestedMessageEventType, {
|
||||
taskId: undefined,
|
||||
taskTitle: undefined,
|
||||
event: requestedMessageEventType,
|
||||
metadata: {
|
||||
messageId,
|
||||
roomId: "test-room",
|
||||
roomName: "Test Room",
|
||||
senderAgentId: "system",
|
||||
senderName: "Fusion",
|
||||
preview: "Fusion test room notification",
|
||||
type: "room-assistant",
|
||||
},
|
||||
});
|
||||
} else {
|
||||
const messageType = requestedMessageEventType.split(":")[1] ?? "agent-to-user";
|
||||
await notificationService.dispatch(requestedMessageEventType, {
|
||||
taskId: undefined,
|
||||
taskTitle: undefined,
|
||||
event: requestedMessageEventType,
|
||||
metadata: {
|
||||
messageId,
|
||||
fromId: "system",
|
||||
fromType: "agent",
|
||||
toId: "user",
|
||||
toType: "user",
|
||||
type: messageType,
|
||||
preview: "Fusion test message notification",
|
||||
},
|
||||
});
|
||||
}
|
||||
res.json({ success: true });
|
||||
return;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
throw new ApiError(502, `Failed to dispatch message notification: ${message}`);
|
||||
}
|
||||
if (
|
||||
requestedMessageEventType !== undefined
|
||||
&& requestedMessageEventType !== "message:agent-to-user"
|
||||
&& requestedMessageEventType !== "message:agent-to-agent"
|
||||
&& requestedMessageEventType !== "message:room"
|
||||
) {
|
||||
throw badRequest("messageEventType must be message:agent-to-user, message:agent-to-agent, or message:room");
|
||||
}
|
||||
if (!settings.ntfyEnabled) {
|
||||
throw badRequest("ntfy notifications are not enabled");
|
||||
}
|
||||
|
||||
const topic = settings.ntfyTopic;
|
||||
if (!topic || !/^[a-zA-Z0-9_-]{1,64}$/.test(topic)) {
|
||||
throw badRequest("ntfy topic is not configured or invalid");
|
||||
}
|
||||
|
||||
const overrideValue = config.ntfyBaseUrl ?? body.ntfyBaseUrl;
|
||||
if (overrideValue !== undefined && overrideValue !== null && typeof overrideValue !== "string") {
|
||||
throw badRequest("ntfy server URL must be a string");
|
||||
}
|
||||
|
||||
const requestOverride = typeof overrideValue === "string" && overrideValue.trim()
|
||||
? normalizeNtfyBaseUrl(overrideValue, "request")
|
||||
: undefined;
|
||||
const storedServer = typeof settings.ntfyBaseUrl === "string" && settings.ntfyBaseUrl.trim()
|
||||
? normalizeNtfyBaseUrl(settings.ntfyBaseUrl, "settings")
|
||||
: undefined;
|
||||
const tokenOverride = config.ntfyAccessToken ?? body.ntfyAccessToken;
|
||||
if (tokenOverride !== undefined && tokenOverride !== null && typeof tokenOverride !== "string") {
|
||||
throw badRequest("ntfy access token must be a string");
|
||||
}
|
||||
const requestToken = typeof tokenOverride === "string" && tokenOverride.trim()
|
||||
? tokenOverride.trim()
|
||||
: undefined;
|
||||
const storedToken = typeof settings.ntfyAccessToken === "string" && settings.ntfyAccessToken.trim()
|
||||
? settings.ntfyAccessToken.trim()
|
||||
: undefined;
|
||||
const ntfyBaseUrl = requestOverride ?? storedServer ?? "https://ntfy.sh";
|
||||
const url = `${ntfyBaseUrl}/${topic}`;
|
||||
const headers: Record<string, string> = {
|
||||
"Title": "Fusion test notification",
|
||||
"Priority": "default",
|
||||
"Content-Type": "text/plain",
|
||||
};
|
||||
const ntfyAccessToken = requestToken ?? storedToken;
|
||||
if (ntfyAccessToken) {
|
||||
headers.Authorization = `Bearer ${ntfyAccessToken}`;
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: "Fusion test notification — your notifications are working!",
|
||||
const configForTest = resolveEffectiveNtfyTestConfig(settings as Record<string, unknown>, body, config);
|
||||
await sendNtfyTestNotification({
|
||||
...configForTest,
|
||||
messageEventType: requestedMessageEventType as NtfyTestMessageEventType | undefined,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new ApiError(502, `ntfy server returned ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
res.json({ success: true });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -176,9 +176,13 @@ export {
|
||||
export { MeshLeaseManager, type MeshLeaseManagerOptions, type LeaseRecoveryContext } from "./mesh-lease-manager.js";
|
||||
export { MissionAutopilot, type MissionAutopilotOptions } from "./mission-autopilot.js";
|
||||
export { MissionExecutionLoop, type MissionExecutionLoopOptions, type ValidationResult, loopLog } from "./mission-execution-loop.js";
|
||||
// FNXC:MergerUnification 2026-06-22-00:00: @deprecated must sit on aiMergeTask's own
|
||||
// export so IDE/type-aware tooling flags only aiMergeTask, not the helpers it shares with
|
||||
// runAiMerge (those are NOT deprecated). A single @deprecated on the multi-member block
|
||||
// would mark every symbol below as deprecated.
|
||||
/** @deprecated Use runAiMerge — aiMergeTask is the soft-deprecated legacy path. */
|
||||
export { aiMergeTask } from "./merger.js";
|
||||
export {
|
||||
aiMergeTask,
|
||||
listAutostashOrphans,
|
||||
applyAutostashBySha,
|
||||
dropAutostashBySha,
|
||||
|
||||
@@ -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