diff --git a/docs/plans/2026-06-04-003-feat-workflow-editor-consolidation-plan.md b/docs/plans/2026-06-04-003-feat-workflow-editor-consolidation-plan.md
new file mode 100644
index 0000000000..8e2952a3f8
--- /dev/null
+++ b/docs/plans/2026-06-04-003-feat-workflow-editor-consolidation-plan.md
@@ -0,0 +1,379 @@
+---
+title: "feat: Workflow editor consolidation — primary entry, step migration, import/export, AI design"
+type: feat
+status: active
+date: 2026-06-04
+depth: deep
+origin: none (solo planning bootstrap)
+---
+
+# feat: Workflow editor consolidation — primary entry, step migration, import/export, AI design
+
+## Summary
+
+Make the graph node editor the single workflow surface: the header/mobile entry opens it directly and the legacy `WorkflowStepManager` is retired; legacy `WorkflowStep` records auto-migrate into a template library (fragments + a combined default workflow); workflow creation gains a template picker; workflows and templates round-trip as JSON files; and an in-editor "design with AI" affordance plus verified agent-tool exposure make workflows fully agent-authorable. Engine execution semantics are untouched.
+
+---
+
+## Problem Frame
+
+The node editor is feature-rich (cards, success/failure edges, auto-layout, dialogs — plan 002) but buried: the Header's "Workflow Steps" button opens the legacy flat-steps `WorkflowStepManager`, and the graph editor is only reachable through a hand-off button inside it. Two parallel models confuse users: flat per-step records with `enabled`/`defaultOn` flags feeding per-step checkboxes in `TaskForm`, versus graph `WorkflowDefinition`s selected per task. Steps authored in the legacy screen have no representation in the editor; built-ins are the only "templates"; nothing imports or exports; and although `fn_workflow_*` agent tools exist in the executor, there is no user-facing AI authoring affordance and chat/planning-agent exposure is absent (they pass no workflow tools today).
+
+---
+
+## Scope Boundaries
+
+### In scope
+- Entry-point rewire (Header desktop + overflow, MobileNavBar) → node editor; full retirement of `WorkflowStepManager`.
+- Steps→IR converter; idempotent migration of user-authored steps into fragments + a combined default workflow; `kind` discriminator on workflow definitions.
+- Template picker on workflow creation; dynamic palette templates (fragments, built-in step templates, plugin-contributed step templates); safe fragment insertion.
+- JSON import/export for workflows and fragments (built-ins exportable), with approval-flag stripping at the write boundary.
+- `POST /api/workflows/design` AI endpoint + in-editor affordances; wire `fn_workflow_*` into chat/planning lanes via `customTools`.
+- Workflow-centric `TaskForm`: per-step checkboxes replaced by a workflow picker, with a create-time `workflowId` path.
+
+### Deferred to Follow-Up Work
+- Removing the `WorkflowStep` records/routes/engine path itself — steps remain the compiled execution substrate (`materializeWorkflowSteps`, `enabledWorkflowSteps`); only their authoring UI is retired.
+- Per-node "Refine with AI" inside the editor inspector (the legacy manager's refine button dies with it; whole-graph AI design covers v1).
+- AI design retry/repair loops and streaming/detached turns (sync v1; upgrade path noted in KTD-6).
+- Cross-project template gallery beyond file import/export; workflow versioning/history.
+- Mobile-optimized canvas authoring.
+- Schema-level rejection of trust-escalating config fields in `parseWorkflowIr` (point-fixed at import/design boundaries here; systemic enforcement is a follow-up).
+
+### Outside this product's identity
+- Changing how compiled steps execute, the seam model, or `parseWorkflowIr`/compiler semantics. Migration and import produce artifacts the existing validators accept; they never relax validation.
+
+---
+
+## Requirements
+
+**Entry & consolidation**
+- R1 — The Header button (desktop + overflow) and MobileNavBar item open the node editor directly; labels say "Workflows". No surface routes through the legacy manager.
+- R2 — `WorkflowStepManager` is fully removed: component, tests, `useModalManager` state (`workflowStepsOpen`/`open`/`close`, `anyModalOpen` membership), `onOpenGraphEditor` hand-off, and `App.tsx`/`AppModals.tsx` wiring. `/api/workflow-steps` routes and step records remain (execution substrate). Removal lands only after R3's picker has replaced the per-step checkboxes (no release window with neither surface).
+- R3 — `TaskForm`'s per-step checkbox list is replaced by a workflow picker: project default preselected and badged "(default)"; "No workflow" listed first; built-ins + user workflows (fragments excluded); loading placeholder while definitions fetch; helper text explains what the selected workflow runs. Selection is applied at create time via a new `workflowId` create parameter materialized server-side inside the task-creation transaction (see KTD-4). An empty/new project shows a CTA into the editor.
+
+**Templates & migration**
+- R4 — A pure steps→IR converter produces a valid v1 IR from a `WorkflowStep[]` (seams encoded per the `linear()` convention; `phase` honored, with undefined phase mapping to pre-merge; the merge seam always emitted); compiling the produced IR yields steps equivalent to the input over all compiler-visible fields (round-trip parity).
+- R5 — On first editor open per project, user-authored steps (excluding `WORKFLOW_COMPILED_STEP_TEMPLATE_PREFIX`-tagged rows) migrate idempotently: **every** user step (enabled or not) → a single-node fragment; the **defaultOn** set → one combined "Migrated steps" workflow; when that set is non-empty the migrated workflow becomes the project default (preserving "new tasks run these" behavior). Enabled-but-optional steps remain available as fragments. Source steps are marked migrated; re-runs and concurrent opens create no duplicates. After a migration that produced anything, the editor shows a one-time dismissible notice explaining where steps went, and the migrated workflow carries a system description ("Converted from your legacy workflow steps").
+- R6 — Workflow definitions carry `kind: "workflow" | "fragment"`; fragments never appear in task workflow pickers, default-workflow selection, or compile/selection paths.
+- R7 — Workflow creation offers a template picker: blank, built-in workflows, and user workflows as starting points — copies with fresh IDs, never references. Each entry shows name, description, and node count; the copy's default name is the source name + " copy" and inherits the source description. With no user workflows, the picker shows blank + built-ins only.
+- R8 — The editor palette gains a Templates section grouped into Fragments / Built-in steps / Plugin steps subsections (alphabetical within groups; a filter input appears when the combined list exceeds 8 entries; plugin entries carry the owner badge). Entries insert pre-configured nodes; insertion remaps node IDs and pre-validates seam duplication, surfacing conflicts as a persistent inline error in the palette section.
+
+**Import/export**
+- R9 — Any workflow or fragment (including built-ins) exports as a JSON envelope (format marker, schema version, kind, name, description, ir, layout) via file download. Export is disabled while the canvas is dirty (tooltip: save first); the export affordance notes that files contain full prompt/command text.
+- R10 — Import validates the envelope and IR server-side at the write boundary; always mints a fresh ID (stripping `builtin:`); suffixes the name on collision; **strips `cliSkipApproval`/`autoApprove` from all node configs** (flagged in the response so the UI can notify); rejects IRs referencing unavailable traits/columns with a message naming the missing trait; warns (without blocking) when a script node's `scriptName` is absent from the target project. Envelopes with `schemaVersion` ≤ the server's are accepted; newer are rejected with a version message. Invalid files never persist partial state. Validation failures render as a persistent inline error near the import affordance (not a toast); the file input resets either way.
+
+**AI & agents**
+- R11 — "Design with AI" takes a prompt (and, for the edit flow, the persisted workflow read server-side by ID — the client never posts IR) and returns a server-validated IR with the same approval-flag stripping as import; failures and interpreter-only results reuse the existing banner triage; the canvas is never replaced without the dirty guard, and never on failure. The affordance shows an in-flight state (disabled control + spinner + `aria-busy`) and a client-side cancel; the route is rate-limited like the other AI routes.
+- R12 — `fn_workflow_create/update/delete/list/get/select` are verifiably exposed to the task executor (existing), and to chat and planning lanes via `createFnAgent`'s `customTools`, with a guard test asserting all six tool names per lane.
+
+---
+
+## Key Technical Decisions
+
+- KTD-1 — **Fragments are `WorkflowDefinition`s with a `kind` column, stored as parseable full IRs.** Add `kind TEXT NOT NULL DEFAULT 'workflow'` to `workflows` (`addColumnIfMissing`, SCHEMA_VERSION 108→109). A fragment's IR is a normal `start → nodes → end` v1 graph so `parseWorkflowIr` validates it unchanged; insertion strips `start`/`end` and remaps IDs. `createWorkflowDefinition` gains `input.kind` in both the definition object and the INSERT column list; fragment IRs are pure v1 so `downgradeIrToV1IfPure` leaves them intact. **Caching:** `listWorkflowDefinitions` keeps caching the full merged set; the `kind` filter applies to the returned slice after the cache read — never cache a filtered result (the single unconditional cache would otherwise poison unfiltered consumers).
+- KTD-2 — **Steps→IR converter is the literal inverse of the compiler, proven by round-trip — and its blind spots are named.** New pure `stepsToWorkflowIr(steps)` in `@fusion/core` inverts `nodeToStepInput` field-by-field and encodes seams exactly as `linear()` does. Parity covers exactly the compiler-visible fields (`name/mode/phase/gateMode/prompt/scriptName/toolMode/modelProvider/modelId`); `enabled`/`defaultOn`/`templateId` are **not** compiler-visible and are handled by migration policy (KTD-3), not the converter. Undefined `phase` maps to pre-merge; the merge seam is always emitted. A comment at both `nodeToStepInput` and the converter requires extending parity when fields are added.
+- KTD-3 — **Migration is lazy, server-side, marker-idempotent — and preserves defaultOn semantics via the project default.** `POST /api/workflows/migrate-legacy-steps` runs on first editor open inside `transactionImmediate` (write lock before the unmigrated-rows SELECT, matching `selectTaskWorkflow`'s pattern); each source row is stamped with `migratedFragmentId`. Representation policy: every user step → fragment (composable reuse in the palette); the **defaultOn** subset → the combined "Migrated steps" workflow (these were the steps that ran automatically on new tasks); if that subset is non-empty the migrated workflow is set as the project default so new-task behavior is preserved. Enabled-but-optional steps deliberately do NOT join the combined workflow — their per-task opt-in granularity maps to "insert the fragment" or "pick a different workflow", not to always-on execution. The combined workflow is a migration-continuity artifact (the TaskForm landing path for legacy users); fragments are the reusable pieces — that division is the payoff of the dual representation.
+- KTD-4 — **TaskForm goes workflow-centric with a create-time `workflowId` parameter (user decision + feasibility correction).** `selectTaskWorkflow` requires an existing task ID and is NOT reusable at create time; instead, task-create input gains `workflowId?: string`, and the store materializes the selection inside the same task-creation transaction (mirroring the existing `materializeDefaultWorkflowSteps` + `pendingWorkflowSelection` + `writeTaskWorkflowSelection` default-workflow block at store.ts ~3974-4035). No create-then-select window exists, so the executor can never pick up a task with the wrong step set. `enabled`/`defaultOn` flags stop being user-facing.
+- KTD-5 — **Import/export follows the Settings JSON pattern with a versioned envelope and trust-boundary stripping.** Client: `createObjectURL` download / `FileReader` upload. Server: `POST /api/workflows/import` validates envelope marker + schema version (≤ current accepted; newer rejected) + `parseWorkflowIr` + trait availability before any write; strips `cliSkipApproval`/`autoApprove` from node configs (these flags bypass the CLI first-run approval gate — the only user-visible gate on arbitrary command execution — and must not survive an untrusted file boundary); collision policy = fresh ID always, name suffix ` (imported)`.
+- KTD-6 — **AI design reuses the refine-route pattern — synchronous, output-extracted, validated, stripped, canvas-safe, rate-limited.** `POST /api/workflows/design` constructs a one-shot tool-less agent via the `createFnAgent` DI seam (module-level `__setCreateFnAgentForDesign` co-located with the route), planning-lane model, system prompt that emits IR JSON (vocabulary per `fn_workflow_create`'s description). The accumulated text passes through the repo's existing JSON-from-text extraction helper (planning/agent-generation precedent — models fence and wrap JSON) before `parseWorkflowIr` + compile triage (interpreter-only via the message-suffix convention) + approval-flag stripping. For the edit flow the route takes a `workflowId` and reads the persisted IR from the store — the client never posts IR (removes the injection vector and matches how compile/selection routes work). Rate-limited 10/hour like `/ai/refine-text`. A detached-turn upgrade (observable-long-running pattern) is the documented path if design ever grows tools.
+- KTD-7 — **Agent exposure is wired via `customTools`, not assumed.** `fn_workflow_*` live only in the task executor's toolset today; chat (`tools: "coding"`, no customTools) and planning (`customTools: createPlanningBoardTools`) gain the workflow tool factories through `createFnAgent`'s `customTools` option with a scoped store handle. A guard test asserts all six tool names per intended lane. Tool handlers parse args defensively (string-JSON accepted).
+- KTD-8 — **Plugin step templates surface as a palette subsection.** The editor fetches `WORKFLOW_STEP_TEMPLATES` + the plugin template registry (client fn `fetchPluginWorkflowStepTemplates` already exists — currently consumed by the manager; it survives U3's deletion) and renders preset single nodes with config prefilled via the converter's field mapping.
+
+---
+
+## High-Level Technical Design
+
+### Consolidation map
+
+```mermaid
+flowchart TB
+ subgraph Entry
+ HB[Header button + overflow] --> NE
+ MN[MobileNavBar item] --> NE
+ TF[TaskForm workflow picker] -->|workflowId at create| CT
+ end
+ subgraph Editor[WorkflowNodeEditor]
+ NE[Canvas + palette]
+ TP[Create: template picker]
+ PAL[Palette Templates:
Fragments / Built-in steps / Plugin steps]
+ IE[Import / Export JSON]
+ AI[Design with AI]
+ end
+ subgraph Server[dashboard src/]
+ MIG[POST /workflows/migrate-legacy-steps]
+ IMP[POST /workflows/import - strip approval flags]
+ DES[POST /workflows/design - createFnAgent DI,
JSON extract, strip, rate-limit]
+ end
+ subgraph Core[@fusion/core]
+ CONV[stepsToWorkflowIr - inverse of compiler]
+ WD[(workflows + kind)]
+ WS[(workflow_steps + migratedFragmentId)]
+ CT[create-time workflowId materialization]
+ end
+ NE --> MIG --> CONV
+ CONV --> WD
+ MIG -->|stamp + set project default| WS
+ IE --> IMP --> WD
+ AI --> DES -->|validated IR| NE
+ TP --> WD
+ PAL --> WD
+ X[WorkflowStepManager] -.retired after TaskForm picker ships.-> NE
+```
+
+### Steps→IR conversion and migration policy (directional)
+
+```
+WorkflowStep[] (user-authored) migration output
+────────────────────────────── ────────────────────────────
+every step ───────────────▶ fragment kind=fragment, start→node→end
+defaultOn steps ──────────▶ combined workflow kind=workflow "Migrated steps"
+ + becomes project default
+enabled-but-optional ─────▶ fragment only (opt-in granularity → palette)
+
+converter: pre-merge nodes → [execute][review][merge seams] → post-merge → end
+ seams per linear(): config.seam, success chain, failure→end
+ phase undefined → pre-merge; merge seam always emitted
+contract: compileWorkflowToSteps(stepsToWorkflowIr(steps)) ≡ steps
+ over compiler-visible fields (enabled/defaultOn are policy, not parity)
+```
+
+### Import envelope (directional)
+
+```
+{ "fusionWorkflowExport": 1, "schemaVersion": ,
+ "kind": "workflow" | "fragment", "name", "description", "ir", "layout" }
+
+import: envelope check → version gate (≤ current ok, newer 409)
+ → parseWorkflowIr → trait availability (422 naming trait)
+ → strip cliSkipApproval/autoApprove (flag in response)
+ → scriptName existence warning (non-blocking)
+ → fresh id (strip builtin:) → name collision suffix → create
+ any failure → 4xx, zero writes; UI shows persistent inline error
+```
+
+---
+
+## Implementation Units
+
+### U1. `kind` discriminator + steps→IR converter (core)
+
+**Goal:** Storage and conversion foundations: fragments distinguishable from workflows; a proven inverse of the compiler.
+**Requirements:** R4, R6.
+**Dependencies:** none.
+**Files:**
+- `packages/core/src/db.ts` (modify) — `kind` column on `workflows`, `migratedFragmentId` column on `workflow_steps` (both `addColumnIfMissing`); `SCHEMA_VERSION` 108→109.
+- `packages/core/src/workflow-definition-types.ts` (modify) — `kind` on `WorkflowDefinition`/`Input`.
+- `packages/core/src/store.ts` (modify) — `createWorkflowDefinition` accepts `input.kind`, includes it in the definition object AND the INSERT column list (default `workflow`); confirm fragment IRs survive `downgradeIrToV1IfPure`; `listWorkflowDefinitions({ kind? })` filtering the returned slice AFTER the cache read (cache always holds the full merged set); fragments excluded from task-selection/default-workflow consumers; `selectTaskWorkflow` rejects fragment IDs.
+- `packages/core/src/workflow-steps-to-ir.ts` (new) — `stepsToWorkflowIr(steps, name)`, `stepToFragmentIr(step)`; inverse of `nodeToStepInput`; seams per `linear()`; undefined phase → pre-merge; parity-extension comments at both sites.
+- `packages/core/src/index.ts` (modify) — re-exports.
+- `packages/core/src/__tests__/workflow-steps-to-ir.test.ts` (new), `packages/core/src/__tests__/workflow-definition-store.test.ts` (extend).
+**Approach:** Additive, forward-only migration. Fragment IRs are full parseable graphs (KTD-1). Converter is pure, no I/O.
+**Patterns to follow:** `addColumnIfMissing` (db.ts ~3795); `linear()` builder; `compileWorkflowToSteps`/`nodeToStepInput` as the inversion source of truth.
+**Test scenarios:**
+- Covers R4: round-trip parity — mixed pre/post-merge steps with prompt/script/gate modes, model overrides, toolMode → `compileWorkflowToSteps(stepsToWorkflowIr(steps))` reproduces every compiler-visible field.
+- All-phase-undefined step set → parseable IR with the canonical seam pipeline; round-trips to phase `pre-merge`.
+- Produced IR passes `parseWorkflowIr`; seams once each, execute→review→merge order, seam `failure → end` edges.
+- Empty step list → minimal valid IR; post-merge-only set → nodes after the merge seam.
+- `stepToFragmentIr` → `start → node → end`, parseable, config mirrors the step.
+- Covers R6: kind=fragment persists and round-trips (INSERT includes kind); `listWorkflowDefinitions({kind:"fragment"})` returns only fragments; calling filtered-then-unfiltered (and reverse) returns correct sets both times (cache-poisoning regression); task-selection list excludes fragments; `selectTaskWorkflow(fragmentId)` rejects; fragment IR unchanged by `downgradeIrToV1IfPure`.
+- Migration 109 applies on a v108 DB and is idempotent on re-run.
+
+### U2. Lazy idempotent step migration (server + core)
+
+**Goal:** Existing user steps become fragments + a combined default workflow, exactly once per project, with user awareness.
+**Requirements:** R5.
+**Dependencies:** U1.
+**Files:**
+- `packages/core/src/store.ts` (modify) — `migrateLegacyWorkflowSteps()`: `transactionImmediate` (write lock before the unmigrated-rows SELECT); every unmigrated user step → fragment; defaultOn subset → combined "Migrated steps" workflow with the system description; sets project default workflow when that subset is non-empty; stamps `migratedFragmentId`.
+- `packages/dashboard/src/routes/register-workflow-routes.ts` (modify) — `POST /api/workflows/migrate-legacy-steps` returning `{migrated, skipped, combinedWorkflowId?}`.
+- `packages/dashboard/app/components/WorkflowNodeEditor.tsx` (modify) — fire the migration call once on editor open (refetch list after); non-fatal on error (404 tolerated if the route ships later); when `migrated > 0`, show a one-time dismissible notice (persisted dismissal, localStorage) explaining where steps went.
+- `packages/dashboard/src/routes/__tests__/workflow-migrate-route.test.ts` (new), `packages/core/src/__tests__/workflow-step-migration.test.ts` (new).
+**Approach:** KTD-3 representation policy. No deletion of step records. Zero user steps → no-op, no combined workflow, no notice.
+**Execution note:** exercise the real store seam in route tests — do not mock store methods the route depends on (mock-masked dead-wiring learning).
+**Test scenarios:**
+- Covers R5: defaultOn + enabled-optional + disabled steps + one compiled-prefixed row → 3 fragments; combined workflow contains ONLY the defaultOn step; project default set to the migrated workflow; compiled row untouched; all sources stamped.
+- No defaultOn steps → fragments created, NO combined workflow, project default unchanged.
+- Second run → `{migrated: 0, skipped: n}`, no new definitions (idempotency).
+- Two sequential invocations racing the marker → definitions created once (transactionImmediate honored).
+- Zero user steps → no-op.
+- Editor: notice shown once when migrated > 0; dismissal persists; absent when migrated = 0.
+
+### U3. Entry rewire + retire WorkflowStepManager
+
+**Goal:** Node editor is the only workflow surface; the legacy manager is gone — with no coverage gap.
+**Requirements:** R1, R2.
+**Dependencies:** U1, U6 (the TaskForm picker must land before or with the manager's removal — no release window where users can neither author steps nor pick workflows). U2 must merge before release so the editor's migrate call has a route, but does not gate this unit's landing (the call is non-fatal).
+**Files:**
+- `packages/dashboard/app/components/Header.tsx` (modify) — button (~1601) + overflow item (~1947) → `openWorkflowEditor`, label `t("header.workflows", "Workflows")`.
+- `packages/dashboard/app/components/MobileNavBar.tsx` (modify) — more-menu item (~592) → editor.
+- `packages/dashboard/app/hooks/useModalManager.ts` (modify) — remove `workflowStepsOpen`/`openWorkflowSteps`/`closeWorkflowSteps` + `anyModalOpen` membership.
+- `packages/dashboard/app/App.tsx`, `packages/dashboard/app/components/AppModals.tsx` (modify) — drop wiring + `onOpenGraphEditor`.
+- `packages/dashboard/app/components/WorkflowStepManager.tsx` + `.css` + tests (delete). The `fetchPluginWorkflowStepTemplates`/`fetchWorkflowStepTemplates` client fns it consumed live in `app/api` and survive (consumed by U9).
+- `packages/dashboard/app/components/__tests__/AppModals.test.tsx`, Header/MobileNavBar tests (modify).
+**Approach:** Surface Enumeration: Header desktop, Header overflow, MobileNavBar more-menu, AppModals mount, useModalManager state + `anyModalOpen`, App.tsx props, `onOpenGraphEditor`. `/api/workflow-steps` routes, the refine route, and `WORKFLOW_STEP_TEMPLATES` exports stay.
+**Test scenarios:**
+- Covers R1: Header button opens the node editor; desktop + overflow + mobile more-menu.
+- Covers R2: no `WorkflowStepManager` references remain; `anyModalOpen` correct with the editor open; `openWorkflowSteps` no longer exported (type-level).
+- Mobile breakpoint: more-menu item opens the editor.
+
+### U4. Template picker on workflow creation (editor)
+
+**Goal:** Creation starts from a previewable template choice.
+**Requirements:** R7.
+**Dependencies:** U1 (kinds); U8 (shares fresh-ID copy helpers).
+**Files:**
+- `packages/dashboard/app/components/WorkflowNodeEditor.tsx` (modify) — create dialog gains a template step: a focus-trapped option list (arrow-key navigable; each entry shows name, description, node count) of blank / built-ins / user kind=workflow definitions; selecting seeds a fresh-ID copy (name = source + " copy", description inherited).
+- `packages/dashboard/app/components/WorkflowNodeEditor.css` (modify).
+- `packages/dashboard/app/components/__tests__/WorkflowNodeEditor.test.tsx` (extend).
+**Approach:** Copies via the U8 ID-remap helpers applied to the whole graph. With no user workflows: blank + built-ins only. The migrated workflow appears with its system description.
+**Test scenarios:**
+- Covers R7: create-from-builtin seeds a copy (fresh IDs, same node count, name "X copy", description inherited); builtin stays read-only; blank unchanged.
+- Picker entries render name/description/node count; empty-user-workflow state lists blank + builtins.
+- Fragments never appear in the picker.
+- Keyboard: arrow navigation + Enter selects (a11y).
+
+### U5. Import/export (server + editor)
+
+**Goal:** Workflows and fragments round-trip as files, safely.
+**Requirements:** R9, R10.
+**Dependencies:** U1.
+**Files:**
+- `packages/dashboard/src/routes/register-workflow-routes.ts` (modify) — `GET /api/workflows/:id/export`, `POST /api/workflows/import` per KTD-5 (strip approval flags + response flag; scriptName warning; version gate ≤ current).
+- `packages/dashboard/app/components/WorkflowNodeEditor.tsx` (modify) — Export button (disabled while dirty, tooltip "Save to export"; enabled for built-ins; tooltip notes files contain full prompt/command text), Import affordance (sidebar; keyboard-accessible trigger for ``; persistent inline error region for validation failures; toast only for network errors; input resets after any attempt; notice when approval flags were stripped).
+- `packages/dashboard/app/api/legacy.ts` (modify) — client fns.
+- `packages/dashboard/src/routes/__tests__/workflow-import-export.test.ts` (new), `__tests__/WorkflowNodeEditor.test.tsx` (extend).
+**Approach:** Envelope per HTD. Server is the sole validator. Export reads the persisted definition (dirty-guard makes stale export impossible).
+**Test scenarios:**
+- Covers R9: export → import reproduces ir/layout/description semantically; fragment kind preserved; export blocked while dirty (button disabled).
+- Covers R10: name collision → suffixed; builtin export → fresh non-builtin editable ID; missing envelope marker → 400; malformed IR → 422 + parser message, zero writes; unknown trait → 422 naming the trait; `schemaVersion` older → accepted; newer → 409 version message; CLI node with `cliSkipApproval: true` → persisted node lacks the field and the response flags the strip; script node with unknown scriptName → 200 + warning field.
+- Editor: validation failure renders the inline error (not a toast) and the list is unchanged; success refreshes + activates; strip notice shown when flagged.
+
+### U6. Workflow-centric TaskForm + create-time workflowId
+
+**Goal:** Tasks pick a workflow, applied atomically at creation.
+**Requirements:** R3.
+**Dependencies:** U1 (fragment exclusion). (U2's migrated workflow appears automatically once present — runtime ordering, not a build dependency.)
+**Files:**
+- `packages/core/src/types.ts` + `packages/core/src/store.ts` (modify) — `workflowId?: string` on the task-create input; materialization inside the creation transaction mirroring the default-workflow block (`materializeDefaultWorkflowSteps`/`pendingWorkflowSelection`/`writeTaskWorkflowSelection`, store.ts ~3974-4035); explicit `workflowId` overrides the project default; fragment IDs rejected.
+- `packages/dashboard/src/routes` task-create route (modify) — accept + pass `workflowId`.
+- `packages/dashboard/app/components/TaskForm.tsx` (modify) — replace the per-step checkbox section (~280, ~330-339, ~1331-1337) with the workflow dropdown (states per R3); remove `fetchWorkflowSteps` usage; empty-workflow-list CTA into the editor.
+- `packages/dashboard/app/components/__tests__/` TaskForm tests (extend), `packages/core/src/__tests__/` task-create tests (extend).
+**Approach:** The engine path is untouched — materialization writes `enabledWorkflowSteps` exactly as the default-workflow path does, in the same transaction, so no executor-pickup race exists. `selectTaskWorkflow` remains the post-create path only.
+**Test scenarios:**
+- Covers R3: create with `workflowId` → task's `enabledWorkflowSteps` populated within the creation write (no intermediate empty state observable); explicit pick overrides project default; "No workflow" → no custom steps; fragment ID → rejected.
+- Dropdown: loading placeholder; "(default)" badge on the project default; "No workflow" listed first; fragments absent; built-ins present.
+- Empty project → CTA opens the editor.
+- Regression: per-step checkboxes gone; no `fetchWorkflowSteps` call remains in TaskForm.
+
+### U7. AI design route (server)
+
+**Goal:** Prompt → validated, stripped, rate-limited IR.
+**Requirements:** R11 (server half).
+**Dependencies:** U1.
+**Files:**
+- `packages/dashboard/src/routes/register-workflow-routes.ts` (modify) — `POST /api/workflows/design` `{prompt, workflowId?}` per KTD-6: module-level `__setCreateFnAgentForDesign` DI seam co-located with the route; planning-lane model; tool-less; JSON-from-text extraction via the existing helper (planning/agent-generation precedent); `parseWorkflowIr` + compile triage (`interpreterOnly` flag) + approval-flag stripping; `workflowId` read from the store (client never posts IR); rate limit 10/hour mirroring `/ai/refine-text`; bounded prompt length.
+- `packages/dashboard/src/routes/__tests__/workflow-design-route.test.ts` (new).
+**Execution note:** route tests use the DI seam with a fake agent — no real model calls.
+**Test scenarios:**
+- Covers R11: fake agent returns valid linear IR → 200 `{ir, interpreterOnly:false}`; branching IR → 200 `{interpreterOnly:true}`; fenced/prose-wrapped JSON → still extracted and 200; invalid JSON / IR failing `parseWorkflowIr` → 422 + message, nothing persisted; IR containing `cliSkipApproval` → returned IR lacks it + strip flag set.
+- `workflowId` flow: route reads the persisted IR; unknown ID → 404.
+- Rate limit: 11th call within the window → 429.
+- Over-length prompt → 400.
+
+### U8. Fragment insertion + graph-copy helpers (mapping layer)
+
+**Goal:** Pure, tested primitives for inserting fragments and copying graphs.
+**Requirements:** R8 (helper half), R7 (copy helpers).
+**Dependencies:** U1.
+**Files:**
+- `packages/dashboard/app/components/workflow-flow-mapping.ts` (modify) — `insertFragment(nodes, edges, fragmentIr, position)` (strips start/end, remaps all node IDs to fresh `newNodeId`s, rewires internal edges), `fragmentSeamConflicts(fragmentIr, nodes)`, `copyIrWithFreshIds(ir, layout)`.
+- `packages/dashboard/app/components/__tests__/workflow-flow-mapping.test.ts` (extend).
+**Approach:** Pure-helper-first so jsdom limits don't bite; consumed by U4 (copies) and U9 (palette insertion).
+**Test scenarios:**
+- Covers R8: `insertFragment` remaps every node ID (no collisions), strips start/end, preserves internal edges/config; double-insert → disjoint ID sets.
+- Fragment containing a `merge` seam vs a graph that has one → `fragmentSeamConflicts` flags it.
+- `copyIrWithFreshIds` → same structure, all-new IDs, layout keys remapped consistently.
+
+### U9. Palette Templates section (editor)
+
+**Goal:** The template library is insertable from the palette.
+**Requirements:** R8.
+**Dependencies:** U1, U8. (U2's fragments appear once migrated — runtime ordering.)
+**Files:**
+- `packages/dashboard/app/components/WorkflowNodeEditor.tsx` (modify) — Templates palette section: Fragments / Built-in steps / Plugin steps subsections (alphabetical; filter input when combined > 8; plugin owner badges); entries keyboard-activatable (Enter/Space) with descriptive aria-labels; fragment insertion via U8 with the persistent inline conflict error in the section; preset step nodes via the converter field mapping; section collapsed state persisted.
+- `packages/dashboard/app/api/legacy.ts` (modify) — fragments fetch (kind param); reuse existing `fetchWorkflowStepTemplates`/`fetchPluginWorkflowStepTemplates`.
+- `packages/dashboard/app/components/WorkflowNodeEditor.css` (modify).
+- `packages/dashboard/app/components/__tests__/WorkflowNodeEditor.test.tsx` (extend).
+**Test scenarios:**
+- Covers R8: three subsections render with their sources; plugin entry carries owner badge; inserting a step template adds a node with prefilled config; inserting a fragment with a seam conflict → inline error, no insertion; filter input appears above 8 combined entries and filters across groups.
+- Empty fragment library → Fragments subsection hidden.
+- Builtin active → insertion disabled (read-only gating).
+- Keyboard activation inserts (a11y).
+
+### U10. Design-with-AI editor affordances
+
+**Goal:** Prompt-to-workflow UX in both entry points.
+**Requirements:** R11 (client half).
+**Dependencies:** U7.
+**Files:**
+- `packages/dashboard/app/components/WorkflowNodeEditor.tsx` (modify) — create dialog: an optional "Describe it instead" textarea (placeholder with an example prompt) before template selection — submitting designs a new workflow from the result; toolbar: "Design with AI" opens a popover panel (textarea + submit) targeting the active workflow via `workflowId`; proposed replacement applies only through the dirty-guard confirm. In-flight: control disabled + spinner + `aria-busy`, client-side cancel (abort the fetch); failure → server message inline, canvas untouched; `interpreterOnly` → existing info banner on the seeded graph; strip notice when flagged.
+- `packages/dashboard/app/components/WorkflowNodeEditor.css` (modify).
+- `packages/dashboard/app/components/__tests__/WorkflowNodeEditor.test.tsx` (extend).
+**Test scenarios:**
+- Covers R11: mocked design success in create dialog → new workflow seeded from returned IR; toolbar flow over a dirty canvas → discard confirm first, cancel keeps edits.
+- Mocked 422 → inline error, canvas untouched.
+- In-flight state: control disabled, aria-busy set; cancel aborts and re-enables.
+- interpreterOnly result → info banner visible.
+
+### U11. Agent-lane exposure (engine)
+
+**Goal:** Chat and planning agents can author workflows; drift-guarded.
+**Requirements:** R12.
+**Dependencies:** none (independent; lands first safely).
+**Files:**
+- `packages/dashboard/src/chat.ts` (modify, ~1288/1612) — pass `fn_workflow_*` factories via `createFnAgent`'s `customTools` (chat passes none today — introduce the array) with the scoped store.
+- `packages/dashboard/src/planning.ts` (modify, ~842) — append the workflow tool factories to the existing `customTools: [...createPlanningBoardTools(store)]`.
+- `packages/engine/src/__tests__/agent-workflow-tools-exposure.test.ts` (new) — asserts all six names (`fn_workflow_create/update/delete/list/get/select`) per lane: executor, chat, planning.
+- Touched tool handlers (verify) — defensive arg parsing (string-JSON accepted).
+**Approach:** Grep every `fn_workflow_` registration surface first and mirror all hits (drift learning).
+**Test scenarios:**
+- Covers R12: exposure test enumerates executor + chat + planning toolsets and asserts `fn_workflow_create/update/delete/list/get/select` membership in each; fails when any lane loses one.
+- Chat lane: customTools array introduced without disturbing existing chat tool behavior (existing chat tests stay green).
+- A workflow tool invoked with stringified-JSON args still parses.
+
+---
+
+## Risks & Dependencies
+
+- **Migration writes to user DBs.** Additive only (2 columns, new rows, marker stamps, one project-default settings write); `transactionImmediate`; idempotent by stored marker; nothing deleted or rewritten.
+- **defaultOn policy is a behavior interpretation.** Mapping defaultOn → combined-workflow-as-project-default preserves "new tasks run these" but collapses per-task uncheckability into workflow choice + fragments. Named in release notes; the migration test suite pins the policy.
+- **Round-trip fidelity gaps.** Parity covers compiler-visible fields only — by design; `enabled`/`defaultOn`/`templateId` are policy-handled. Extend parity when `nodeToStepInput` gains fields (comments at both sites).
+- **Trust boundary.** `cliSkipApproval`/`autoApprove` bypass the CLI approval gate; import and design strip them (R10/R11). Systemic schema-level rejection is an explicit follow-up. Exported files contain full prompt/command text — disclosure note on the export affordance.
+- **Removal blast radius.** U3's Surface Enumeration is the sweep list; U3 is gated on U6 to avoid the no-surface window.
+- **Import strictness vs. portability.** Unknown traits block (422 naming trait + owning plugin); unknown scriptNames warn without blocking — scripts are project-settings content the user can add after import.
+- **AI output variance.** JSON extraction + server validation bound the failure mode to a clean 422; retry/repair deferred. Synchronous route bounded by rate limit + prompt-length cap; detached-turn upgrade documented.
+- **Registration drift** (agent tools, palette template sources): grep-and-mirror; U11 guard test.
+- **Mid-migration TaskForm state.** A user can open TaskForm before ever opening the editor — they see built-ins (+ any existing workflows) until migration runs on first editor open; acceptable, noted here so it isn't mistaken for a bug.
+- **Changeset:** user-facing feature in the bundled CLI → `@runfusion/fusion` minor changeset.
+
+---
+
+## System-Wide Impact
+
+- **Schema:** SCHEMA_VERSION 108→109 (two additive columns).
+- **Engine:** no execution-semantics change; chat/planning lanes gain workflow tools (additive).
+- **Existing users:** flat steps keep executing on existing tasks; the step-authoring UI is replaced by migrated workflows/fragments; defaultOn behavior is preserved via the migrated project default; TaskForm visibly changes (workflow picker) — release-notes worthy, plus the in-editor one-time migration notice.
+- **Plugins:** contributed step templates move to the editor palette; plugin API unchanged.
+
+---
+
+## Sources & Research
+
+- `packages/dashboard/app/components/WorkflowStepManager.tsx` (surface inventory: form fields ~718-963, templates tab + plugin templates ~185-200, refine ~830-844, onOpenGraphEditor ~430).
+- `packages/dashboard/app/components/Header.tsx` (~1601, ~1947), `MobileNavBar.tsx` (~592), `useModalManager.ts` (~180, ~199, ~348-351), `AppModals.tsx` (~377-400), `TaskForm.tsx` (~242, ~280, ~330-339, ~1331-1337).
+- `packages/core/src/workflow-compiler.ts` (`compileWorkflowToSteps` ~200, `nodeToStepInput` ~162-189 — emits neither `enabled` nor `defaultOn`), `builtin-workflows.ts` (`linear()` ~25-44), `store.ts` (`createWorkflowDefinition` ~12238 — fixed INSERT, no name uniqueness; `listWorkflowDefinitions` ~12289 — single unconditional cache; `selectTaskWorkflow` ~13266 — requires task id; default-workflow materialization ~3974-4035; `materializeWorkflowSteps` ~13230; `transactionImmediate` precedent), `db.ts` (`SCHEMA_VERSION = 108` ~152, `addColumnIfMissing` ~3795), `types.ts` (`WorkflowStep` ~510-548 — `enabled` required, `defaultOn` optional; `WORKFLOW_STEP_TEMPLATES` ~772; task-create input carries only `enabledWorkflowSteps`).
+- `packages/dashboard/src/routes.ts` (refine route + `__setCreateFnAgentForRefine` ~370, ~3019-3092 — free-text accumulation, no JSON extraction; rate limits on `/ai/refine-text` ~1717), `register-workflow-routes.ts`; JSON-from-text extraction precedent in `planning.ts`/agent-generation.
+- `packages/engine/src/agent-tools.ts` (`fn_workflow_*` ~1007-1365), `executor.ts` (~5687-5694 toolset; `cliSkipApproval`/`autoApprove` gate ~4576-4581), `packages/dashboard/src/chat.ts` (`tools: "coding"`, no customTools ~1288/1612), `planning.ts` (`customTools` ~842).
+- Import/export precedent: `SettingsModal.tsx` (~1625-1671, ~7662), `register-agent-import-export-generation-routes.ts`, `AgentImportModal.tsx` (~246-257, ~495-507).
+- Learnings: `docs/solutions/integration-issues/bundled-plugin-registration-drift.md`, `docs/solutions/integration-issues/branch-group-single-pr-synthetic-id-dead-wiring.md`, `docs/solutions/architecture-patterns/observable-long-running-agent-turns-through-blocking-plugin-route-seam.md`, `docs/solutions/architecture-patterns/mass-migration-agent-fleet-orchestration.md`.
+- Prior plans: `docs/plans/2026-06-03-001-feat-executable-custom-workflows-node-editor-plan.md`, `docs/plans/2026-06-04-002-feat-node-editor-visual-edge-upgrade-plan.md`.
diff --git a/packages/core/src/__tests__/db-migrate.test.ts b/packages/core/src/__tests__/db-migrate.test.ts
index b36c04e128..756152ac05 100644
--- a/packages/core/src/__tests__/db-migrate.test.ts
+++ b/packages/core/src/__tests__/db-migrate.test.ts
@@ -715,7 +715,7 @@ describe("schema migration", () => {
const row = db.prepare("SELECT deletedAt FROM tasks WHERE id = 'FN-legacy'").get() as { deletedAt: string | null };
expect(row.deletedAt).toBeNull();
- expect(db.getSchemaVersion()).toBe(108);
+ expect(db.getSchemaVersion()).toBe(109);
db.close();
});
@@ -748,7 +748,7 @@ describe("schema migration", () => {
{ id: "WS-001", mode: "prompt", gateMode: "advisory" },
{ id: "WS-002", mode: "script", gateMode: "advisory" },
]);
- expect(db.getSchemaVersion()).toBe(108);
+ expect(db.getSchemaVersion()).toBe(109);
db.close();
});
@@ -798,7 +798,7 @@ describe("schema migration", () => {
reviewerContextRetryCount: 0,
reviewerFallbackRetryCount: 0,
});
- expect(db.getSchemaVersion()).toBe(108);
+ expect(db.getSchemaVersion()).toBe(109);
db.close();
});
@@ -827,7 +827,7 @@ describe("schema migration", () => {
const columns = db.prepare("PRAGMA table_info(milestones)").all() as Array<{ name: string }>;
expect(columns.map((column) => column.name)).toContain("acceptanceCriteria");
- expect(db.getSchemaVersion()).toBe(108);
+ expect(db.getSchemaVersion()).toBe(109);
db.close();
});
@@ -868,7 +868,7 @@ describe("schema migration", () => {
const missionColumns = db.prepare("PRAGMA table_info(missions)").all() as Array<{ name: string }>;
expect(missionColumns.map((column) => column.name)).toContain("autoMerge");
- expect(db.getSchemaVersion()).toBe(108);
+ expect(db.getSchemaVersion()).toBe(109);
db.close();
});
@@ -902,7 +902,7 @@ describe("schema migration", () => {
{ id: "WS-002", mode: "script", enabled: 1, gateMode: "advisory" },
{ id: "WS-003", mode: "prompt", enabled: 0, gateMode: "advisory" },
]);
- expect(db.getSchemaVersion()).toBe(108);
+ expect(db.getSchemaVersion()).toBe(109);
db.close();
});
@@ -939,7 +939,7 @@ describe("schema migration", () => {
const indexes = db.prepare("PRAGMA index_list(mission_goals)").all() as Array<{ name: string }>;
expect(indexes.some((index) => index.name === "idxMissionGoalsGoalId")).toBe(true);
- expect(db.getSchemaVersion()).toBe(108);
+ expect(db.getSchemaVersion()).toBe(109);
db.close();
});
@@ -1000,7 +1000,79 @@ describe("schema migration", () => {
expect(customFieldsColumn).toBeDefined();
expect(customFieldsColumn?.dflt_value).toBe("'{}'");
- expect(db.getSchemaVersion()).toBe(108);
+ expect(db.getSchemaVersion()).toBe(109);
db.close();
});
+
+ it("adds workflows.kind + workflow_steps.migrated_fragment_id when migrating from schema version 108", () => {
+ const db = new Database(fusionDir);
+ db.exec("CREATE TABLE IF NOT EXISTS __meta (key TEXT PRIMARY KEY, value TEXT)");
+ db.exec("INSERT INTO __meta (key, value) VALUES ('schemaVersion', '108')");
+ db.exec("INSERT INTO __meta (key, value) VALUES ('lastModified', '1000')");
+ db.exec(`
+ CREATE TABLE IF NOT EXISTS workflows (
+ id TEXT PRIMARY KEY,
+ name TEXT NOT NULL,
+ description TEXT NOT NULL DEFAULT '',
+ ir TEXT NOT NULL,
+ layout TEXT NOT NULL DEFAULT '{}',
+ createdAt TEXT NOT NULL,
+ updatedAt TEXT NOT NULL
+ )
+ `);
+ db.exec(`
+ CREATE TABLE IF NOT EXISTS workflow_steps (
+ id TEXT PRIMARY KEY,
+ name TEXT NOT NULL,
+ description TEXT NOT NULL,
+ mode TEXT NOT NULL DEFAULT 'prompt',
+ phase TEXT NOT NULL DEFAULT 'pre-merge',
+ prompt TEXT NOT NULL DEFAULT '',
+ enabled INTEGER NOT NULL DEFAULT 1,
+ createdAt TEXT NOT NULL,
+ updatedAt TEXT NOT NULL
+ )
+ `);
+ db.exec(
+ `INSERT INTO workflows (id, name, ir, createdAt, updatedAt) VALUES ('WF-legacy', 'Legacy', '{"version":"v1","name":"x","nodes":[],"edges":[]}', '2025-01-01T00:00:00.000Z', '2025-01-01T00:00:00.000Z')`,
+ );
+ db.exec(
+ "INSERT INTO workflow_steps (id, name, description, createdAt, updatedAt) VALUES ('WS-legacy', 'Legacy', 'desc', '2025-01-01T00:00:00.000Z', '2025-01-01T00:00:00.000Z')",
+ );
+
+ db.init();
+
+ const workflowColumns = db.prepare("PRAGMA table_info(workflows)").all() as Array<{
+ name: string;
+ }>;
+ expect(workflowColumns.map((c) => c.name)).toContain("kind");
+ // Existing rows default to 'workflow'.
+ const wfRow = db.prepare("SELECT kind FROM workflows WHERE id = 'WF-legacy'").get() as { kind: string };
+ expect(wfRow.kind).toBe("workflow");
+
+ const stepColumns = db.prepare("PRAGMA table_info(workflow_steps)").all() as Array<{ name: string }>;
+ expect(stepColumns.map((c) => c.name)).toContain("migrated_fragment_id");
+ const stepRow = db
+ .prepare("SELECT migrated_fragment_id FROM workflow_steps WHERE id = 'WS-legacy'")
+ .get() as { migrated_fragment_id: string | null };
+ expect(stepRow.migrated_fragment_id).toBeNull();
+
+ expect(db.getSchemaVersion()).toBe(109);
+ db.close();
+ });
+
+ it("migration 109 is idempotent on re-init", () => {
+ const db = new Database(fusionDir);
+ db.init();
+ expect(db.getSchemaVersion()).toBe(109);
+ db.close();
+
+ // Re-open the same on-disk DB: already at 109, the 109 block must be a no-op.
+ const reopened = new Database(fusionDir);
+ reopened.init();
+ expect(reopened.getSchemaVersion()).toBe(109);
+ const workflowColumns = reopened.prepare("PRAGMA table_info(workflows)").all() as Array<{ name: string }>;
+ expect(workflowColumns.filter((c) => c.name === "kind")).toHaveLength(1);
+ reopened.close();
+ });
});
diff --git a/packages/core/src/__tests__/db.test.ts b/packages/core/src/__tests__/db.test.ts
index 8a618bd583..191b67591a 100644
--- a/packages/core/src/__tests__/db.test.ts
+++ b/packages/core/src/__tests__/db.test.ts
@@ -334,7 +334,7 @@ describe("Database", () => {
});
it("seeds schema version", () => {
- expect(db.getSchemaVersion()).toBe(108);
+ expect(db.getSchemaVersion()).toBe(109);
});
it("includes tokenUsageCacheWriteTokens on freshly initialized tasks table", () => {
@@ -393,7 +393,7 @@ describe("Database", () => {
it("is idempotent - calling init() twice does not fail", () => {
expect(() => db.init()).not.toThrow();
- expect(db.getSchemaVersion()).toBe(108);
+ expect(db.getSchemaVersion()).toBe(109);
});
it("does not overwrite existing config on re-init", () => {
// Update the config
@@ -1463,7 +1463,7 @@ describe("schema migrations", () => {
db.init();
// Verify version bumped to 29 (includes v1→v2 through v26→v29)
- expect(db.getSchemaVersion()).toBe(108);
+ expect(db.getSchemaVersion()).toBe(109);
// Verify new columns exist and existing data is intact
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
@@ -1488,11 +1488,11 @@ describe("schema migrations", () => {
const db = new Database(fusionDir);
db.init();
- expect(db.getSchemaVersion()).toBe(108);
+ expect(db.getSchemaVersion()).toBe(109);
// Re-init should not fail
db.init();
- expect(db.getSchemaVersion()).toBe(108);
+ expect(db.getSchemaVersion()).toBe(109);
db.close();
});
@@ -1527,7 +1527,7 @@ describe("schema migrations", () => {
db.init();
- expect(db.getSchemaVersion()).toBe(108);
+ expect(db.getSchemaVersion()).toBe(109);
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
expect(cols.map((col) => col.name)).toContain("priority");
@@ -1568,7 +1568,7 @@ describe("schema migrations", () => {
db.init();
- expect(db.getSchemaVersion()).toBe(108);
+ expect(db.getSchemaVersion()).toBe(109);
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
const colNames = cols.map((col) => col.name);
@@ -1640,7 +1640,7 @@ describe("schema migrations", () => {
db.init();
- expect(db.getSchemaVersion()).toBe(108);
+ expect(db.getSchemaVersion()).toBe(109);
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
const colNames = cols.map((col) => col.name);
@@ -1880,7 +1880,7 @@ describe("schema migrations", () => {
db.init();
- expect(db.getSchemaVersion()).toBe(108);
+ expect(db.getSchemaVersion()).toBe(109);
const cols = db.prepare("PRAGMA table_info(chat_messages)").all() as Array<{ name: string }>;
expect(cols.map((col) => col.name)).toContain("attachments");
@@ -1954,7 +1954,7 @@ describe("schema migrations", () => {
db.init();
- expect(db.getSchemaVersion()).toBe(108);
+ expect(db.getSchemaVersion()).toBe(109);
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = 'agentRatings'").all() as Array<{ name: string }>;
expect(tables).toEqual([{ name: "agentRatings" }]);
@@ -1978,7 +1978,7 @@ describe("schema migrations", () => {
db.init();
- expect(db.getSchemaVersion()).toBe(108);
+ expect(db.getSchemaVersion()).toBe(109);
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = 'mission_events'").all() as Array<{ name: string }>;
expect(tables).toEqual([{ name: "mission_events" }]);
@@ -2082,7 +2082,7 @@ describe("schema migrations", () => {
db.init();
// Verify version bumped to 29
- expect(db.getSchemaVersion()).toBe(108);
+ expect(db.getSchemaVersion()).toBe(109);
// Verify new columns exist and existing data is intact
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
@@ -2301,7 +2301,7 @@ describe("schema migrations", () => {
localDb.init();
- expect(localDb.getSchemaVersion()).toBe(108);
+ expect(localDb.getSchemaVersion()).toBe(109);
const columns = localDb.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
expect(columns.map((column) => column.name)).toContain("tokenUsageCacheWriteTokens");
@@ -2612,7 +2612,7 @@ describe("createDatabase factory", () => {
const db = createDatabase(fusionDir);
db.init();
- expect(db.getSchemaVersion()).toBe(108);
+ expect(db.getSchemaVersion()).toBe(109);
expect(db.getLastModified()).toBeGreaterThan(0);
db.close();
@@ -2766,7 +2766,7 @@ describe("migration v77 task token budget columns", () => {
migrated = new Database(fusion);
migrated.init();
- expect(migrated.getSchemaVersion()).toBe(108);
+ expect(migrated.getSchemaVersion()).toBe(109);
const rows = migrated.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
const names = new Set(rows.map((row) => row.name));
expect(names.has("tokenBudgetSoftAlertedAt")).toBe(true);
@@ -2797,7 +2797,7 @@ describe("migration v106 adds tasks.transitionPending (FN-1417)", () => {
const fresh = new Database(fusion);
try {
fresh.init();
- expect(fresh.getSchemaVersion()).toBe(108);
+ expect(fresh.getSchemaVersion()).toBe(109);
const names = new Set(
(fresh.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>).map((r) => r.name),
);
@@ -2825,7 +2825,7 @@ describe("migration v106 adds tasks.transitionPending (FN-1417)", () => {
migrated = new Database(fusion);
migrated.init();
- expect(migrated.getSchemaVersion()).toBe(108);
+ expect(migrated.getSchemaVersion()).toBe(109);
const names = new Set(
(migrated.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>).map((r) => r.name),
);
@@ -2851,7 +2851,7 @@ describe("migration v107 adds workflow_run_branches + index (FN-1417)", () => {
const fresh = new Database(fusion);
try {
fresh.init();
- expect(fresh.getSchemaVersion()).toBe(108);
+ expect(fresh.getSchemaVersion()).toBe(109);
const table = fresh
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = 'workflow_run_branches'")
.get() as { name: string } | undefined;
@@ -2885,7 +2885,7 @@ describe("migration v107 adds workflow_run_branches + index (FN-1417)", () => {
migrated = new Database(fusion);
migrated.init();
- expect(migrated.getSchemaVersion()).toBe(108);
+ expect(migrated.getSchemaVersion()).toBe(109);
const table = migrated
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = 'workflow_run_branches'")
.get() as { name: string } | undefined;
@@ -2926,7 +2926,7 @@ describe("migration v67 drops orphan project auth tables", () => {
migrated = new Database(fusion);
migrated.init();
- expect(migrated.getSchemaVersion()).toBe(108);
+ expect(migrated.getSchemaVersion()).toBe(109);
const tables = migrated
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'project_auth_%'")
.all() as Array<{ name: string }>;
@@ -2953,7 +2953,7 @@ describe("migration v67 drops orphan project auth tables", () => {
try {
fresh.init();
- expect(fresh.getSchemaVersion()).toBe(108);
+ expect(fresh.getSchemaVersion()).toBe(109);
const tables = fresh
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'project_auth_%'")
.all() as Array<{ name: string }>;
diff --git a/packages/core/src/__tests__/goals-schema.test.ts b/packages/core/src/__tests__/goals-schema.test.ts
index 333a68e551..03398b9515 100644
--- a/packages/core/src/__tests__/goals-schema.test.ts
+++ b/packages/core/src/__tests__/goals-schema.test.ts
@@ -91,6 +91,6 @@ describe("goals schema", () => {
});
it("reports schema version 101", () => {
- expect(db.getSchemaVersion()).toBe(108);
+ expect(db.getSchemaVersion()).toBe(109);
});
});
diff --git a/packages/core/src/__tests__/insight-store.test.ts b/packages/core/src/__tests__/insight-store.test.ts
index 7aa0e611ad..5d8ac9b47a 100644
--- a/packages/core/src/__tests__/insight-store.test.ts
+++ b/packages/core/src/__tests__/insight-store.test.ts
@@ -1000,7 +1000,7 @@ describe("Migration: pre-33 DB upgrade", () => {
// Step 1: Create a fresh database at v33 (runs all migrations up to 33)
const db1 = createDatabase(legacyDir);
db1.init();
- expect(db1.getSchemaVersion()).toBe(108);
+ expect(db1.getSchemaVersion()).toBe(109);
db1.close();
// Step 2: Manually downgrade to version 32 and drop insight tables
@@ -1035,7 +1035,7 @@ describe("Migration: pre-33 DB upgrade", () => {
expect(tableNamesBefore).not.toContain("project_insight_runs");
// Now run init — this triggers the v32→v33 migration
db3.init();
- expect(db3.getSchemaVersion()).toBe(108);
+ expect(db3.getSchemaVersion()).toBe(109);
// Step 4: Verify insight tables exist after migration
const tablesAfter = db3.prepare(
@@ -1066,12 +1066,12 @@ describe("Migration: pre-33 DB upgrade", () => {
try {
const db1 = createDatabase(testDir);
db1.init();
- expect(db1.getSchemaVersion()).toBe(108);
+ expect(db1.getSchemaVersion()).toBe(109);
db1.close();
const db2 = createDatabase(testDir);
expect(() => db2.init()).not.toThrow();
- expect(db2.getSchemaVersion()).toBe(108);
+ expect(db2.getSchemaVersion()).toBe(109);
db2.close();
} finally {
rmSync(testDir, { recursive: true, force: true });
@@ -1085,7 +1085,7 @@ describe("Migration: pre-33 DB upgrade", () => {
// Step 1: Create a fresh DB and run migrations
const db1 = createDatabase(compatDir);
db1.init();
- expect(db1.getSchemaVersion()).toBe(108);
+ expect(db1.getSchemaVersion()).toBe(109);
// Step 2: Strip lifecycle and cancelledAt columns by recreating the
// table without them. This simulates a DB that was created before the
diff --git a/packages/core/src/__tests__/merge-request-record.test.ts b/packages/core/src/__tests__/merge-request-record.test.ts
index ef2a10d136..64dc82817b 100644
--- a/packages/core/src/__tests__/merge-request-record.test.ts
+++ b/packages/core/src/__tests__/merge-request-record.test.ts
@@ -38,7 +38,7 @@ describe("TaskStore merge request record + completion handoff marker", () => {
.all() as Array<{ name: string }>;
expect(tableRows).toEqual([{ name: "completion_handoff_markers" }, { name: "merge_requests" }]);
- expect(db.getSchemaVersion()).toBe(108);
+ expect(db.getSchemaVersion()).toBe(109);
});
it("upserts merge request records", async () => {
diff --git a/packages/core/src/__tests__/mission-store.test.ts b/packages/core/src/__tests__/mission-store.test.ts
index 7410346c67..2ded53b090 100644
--- a/packages/core/src/__tests__/mission-store.test.ts
+++ b/packages/core/src/__tests__/mission-store.test.ts
@@ -3746,7 +3746,7 @@ describe("MissionStore", () => {
describe("Loop State & Validator Run Schema (v31)", () => {
it("schema version is 101 after migration", () => {
- expect(db.getSchemaVersion()).toBe(108);
+ expect(db.getSchemaVersion()).toBe(109);
});
it("mission_features table has loop state columns", () => {
diff --git a/packages/core/src/__tests__/run-audit.test.ts b/packages/core/src/__tests__/run-audit.test.ts
index 3be684ea9a..ca428b6914 100644
--- a/packages/core/src/__tests__/run-audit.test.ts
+++ b/packages/core/src/__tests__/run-audit.test.ts
@@ -584,7 +584,7 @@ describe("Run Audit", () => {
});
it("schema version is bumped to 40", () => {
- expect(db.getSchemaVersion()).toBe(108);
+ expect(db.getSchemaVersion()).toBe(109);
});
});
});
diff --git a/packages/core/src/__tests__/store-merge-queue.test.ts b/packages/core/src/__tests__/store-merge-queue.test.ts
index 6c09641156..96a9ea905b 100644
--- a/packages/core/src/__tests__/store-merge-queue.test.ts
+++ b/packages/core/src/__tests__/store-merge-queue.test.ts
@@ -60,7 +60,7 @@ describe("TaskStore merge queue", () => {
expect.arrayContaining(["idx_mergeQueue_lease_ready", "idx_mergeQueue_leaseExpiresAt"]),
);
- expect(store.getDatabase().getSchemaVersion()).toBe(108);
+ expect(store.getDatabase().getSchemaVersion()).toBe(109);
});
it("migrates a legacy v88 database and preserves task rows", async () => {
diff --git a/packages/core/src/__tests__/task-documents.test.ts b/packages/core/src/__tests__/task-documents.test.ts
index 7352ec08bf..63e5eebfad 100644
--- a/packages/core/src/__tests__/task-documents.test.ts
+++ b/packages/core/src/__tests__/task-documents.test.ts
@@ -51,7 +51,7 @@ describe("TaskStore task documents", () => {
expect(tableNames.has("task_documents")).toBe(true);
expect(tableNames.has("task_document_revisions")).toBe(true);
- expect(db.getSchemaVersion()).toBe(108);
+ expect(db.getSchemaVersion()).toBe(109);
const index = db
.prepare(
diff --git a/packages/core/src/__tests__/workflow-definition-store.test.ts b/packages/core/src/__tests__/workflow-definition-store.test.ts
index 7d78accce5..b7501997de 100644
--- a/packages/core/src/__tests__/workflow-definition-store.test.ts
+++ b/packages/core/src/__tests__/workflow-definition-store.test.ts
@@ -180,4 +180,97 @@ describe("TaskStore workflow definitions (U1)", () => {
const c = await store.createWorkflowDefinition({ name: "C", ir: makeIr() });
expect(c.id).toBe("WF-003");
});
+
+ // ── kind discriminator (U1, R6/KTD-1) ────────────────────────────────
+
+ // A pure-v1 start→node→end fragment IR.
+ function fragmentIr(): WorkflowIr {
+ return {
+ version: "v1",
+ name: "frag",
+ nodes: [
+ { id: "start", kind: "start" },
+ { id: "step-1", kind: "prompt", config: { name: "Doc", gateMode: "advisory", prompt: "doc it" } },
+ { id: "end", kind: "end" },
+ ],
+ edges: [
+ { from: "start", to: "step-1", condition: "success" },
+ { from: "step-1", to: "end", condition: "success" },
+ ],
+ };
+ }
+
+ it("defaults a created workflow to kind 'workflow'", async () => {
+ const created = await store.createWorkflowDefinition({ name: "W", ir: makeIr() });
+ expect(created.kind).toBe("workflow");
+ expect((await store.getWorkflowDefinition(created.id))?.kind).toBe("workflow");
+ });
+
+ it("persists and round-trips kind 'fragment' (INSERT includes kind)", async () => {
+ const created = await store.createWorkflowDefinition({ name: "Frag", ir: fragmentIr(), kind: "fragment" });
+ expect(created.kind).toBe("fragment");
+ // Raw column persisted.
+ const raw = (store as any).db.prepare("SELECT kind FROM workflows WHERE id = ?").get(created.id) as { kind: string };
+ expect(raw.kind).toBe("fragment");
+ // Reload.
+ expect((await store.getWorkflowDefinition(created.id))?.kind).toBe("fragment");
+ });
+
+ it("preserves kind across updateWorkflowDefinition", async () => {
+ const created = await store.createWorkflowDefinition({ name: "Frag", ir: fragmentIr(), kind: "fragment" });
+ const updated = await store.updateWorkflowDefinition(created.id, { description: "edited" });
+ expect(updated.kind).toBe("fragment");
+ expect((await store.getWorkflowDefinition(created.id))?.kind).toBe("fragment");
+ });
+
+ it("listWorkflowDefinitions({kind:'fragment'}) returns only fragments", async () => {
+ await store.createWorkflowDefinition({ name: "W1", ir: makeIr() });
+ const frag = await store.createWorkflowDefinition({ name: "F1", ir: fragmentIr(), kind: "fragment" });
+ const fragments = await store.listWorkflowDefinitions({ kind: "fragment" });
+ expect(fragments.map((w) => w.id)).toEqual([frag.id]);
+ expect(fragments.every((w) => w.kind === "fragment")).toBe(true);
+ });
+
+ it("built-in list entries are kind 'workflow'", async () => {
+ const all = await store.listWorkflowDefinitions();
+ const builtins = all.filter((w) => isBuiltinWorkflowId(w.id));
+ expect(builtins.length).toBeGreaterThan(0);
+ expect(builtins.every((w) => w.kind === "workflow")).toBe(true);
+ // The workflow filter includes built-ins; the fragment filter excludes them.
+ expect((await store.listWorkflowDefinitions({ kind: "workflow" })).some((w) => isBuiltinWorkflowId(w.id))).toBe(true);
+ expect((await store.listWorkflowDefinitions({ kind: "fragment" })).some((w) => isBuiltinWorkflowId(w.id))).toBe(false);
+ });
+
+ it("cache regression: filtered then unfiltered (and reverse) are both correct", async () => {
+ await store.createWorkflowDefinition({ name: "W1", ir: makeIr() });
+ const frag = await store.createWorkflowDefinition({ name: "F1", ir: fragmentIr(), kind: "fragment" });
+
+ // filtered → unfiltered
+ const f1 = await store.listWorkflowDefinitions({ kind: "fragment" });
+ expect(f1.map((w) => w.id)).toEqual([frag.id]);
+ const allAfterFiltered = await store.listWorkflowDefinitions();
+ expect(allAfterFiltered.filter((w) => !isBuiltinWorkflowId(w.id)).map((w) => w.kind).sort()).toEqual([
+ "fragment",
+ "workflow",
+ ]);
+
+ // unfiltered → filtered (cache already populated by the unfiltered call)
+ const f2 = await store.listWorkflowDefinitions({ kind: "fragment" });
+ expect(f2.map((w) => w.id)).toEqual([frag.id]);
+ const w2 = await store.listWorkflowDefinitions({ kind: "workflow" });
+ expect(w2.filter((w) => !isBuiltinWorkflowId(w.id)).every((w) => w.kind === "workflow")).toBe(true);
+ });
+
+ it("a fragment IR survives downgradeIrToV1IfPure unchanged (persists as v1)", async () => {
+ const created = await store.createWorkflowDefinition({ name: "Frag", ir: fragmentIr(), kind: "fragment" });
+ const raw = (store as any).db.prepare("SELECT ir FROM workflows WHERE id = ?").get(created.id) as { ir: string };
+ expect(JSON.parse(raw.ir).version).toBe("v1");
+ });
+
+ it("selectTaskWorkflow rejects a fragment id with a clear error", async () => {
+ const frag = await store.createWorkflowDefinition({ name: "Frag", ir: fragmentIr(), kind: "fragment" });
+ // Create a task to select against.
+ const task = await store.createTask({ description: "t" });
+ await expect(store.selectTaskWorkflow(task.id, frag.id)).rejects.toThrow(/fragment/i);
+ });
});
diff --git a/packages/core/src/__tests__/workflow-steps-to-ir.test.ts b/packages/core/src/__tests__/workflow-steps-to-ir.test.ts
new file mode 100644
index 0000000000..de041be2d3
--- /dev/null
+++ b/packages/core/src/__tests__/workflow-steps-to-ir.test.ts
@@ -0,0 +1,226 @@
+import { describe, it, expect } from "vitest";
+
+import { stepsToWorkflowIr, stepToFragmentIr, layoutForIr } from "../workflow-steps-to-ir.js";
+import { compileWorkflowToSteps } from "../workflow-compiler.js";
+import { parseWorkflowIr } from "../workflow-ir.js";
+import type { WorkflowStep, WorkflowStepInput } from "../types.js";
+
+/** Build a fully-specified WorkflowStep fixture. */
+function step(overrides: Partial): WorkflowStep {
+ return {
+ id: overrides.id ?? "WS-000",
+ name: overrides.name ?? "Step",
+ description: overrides.description ?? "",
+ mode: overrides.mode ?? "prompt",
+ phase: overrides.phase,
+ gateMode: overrides.gateMode ?? "advisory",
+ prompt: overrides.prompt ?? "",
+ toolMode: overrides.toolMode,
+ scriptName: overrides.scriptName,
+ enabled: overrides.enabled ?? true,
+ defaultOn: overrides.defaultOn,
+ modelProvider: overrides.modelProvider,
+ modelId: overrides.modelId,
+ migratedFragmentId: overrides.migratedFragmentId,
+ createdAt: "2026-01-01T00:00:00.000Z",
+ updatedAt: "2026-01-01T00:00:00.000Z",
+ };
+}
+
+/** Project a compiled step input down to exactly the compiler-visible fields the
+ * round-trip contract pins (KTD-2). Normalizes optional fields for comparison. */
+function visible(input: WorkflowStepInput) {
+ return {
+ name: input.name,
+ mode: input.mode,
+ phase: input.phase,
+ gateMode: input.gateMode,
+ prompt: input.mode === "script" ? undefined : (input.prompt ?? ""),
+ scriptName: input.scriptName,
+ toolMode: input.mode === "script" ? undefined : input.toolMode,
+ modelProvider: input.modelProvider,
+ modelId: input.modelId,
+ };
+}
+
+function visibleStep(s: WorkflowStep) {
+ return {
+ name: s.name,
+ mode: s.mode,
+ phase: s.phase ?? "pre-merge",
+ gateMode: s.gateMode,
+ prompt: s.mode === "script" ? undefined : (s.prompt ?? ""),
+ scriptName: s.mode === "script" ? s.scriptName : undefined,
+ toolMode: s.mode === "script" ? undefined : (s.toolMode ?? "readonly"),
+ modelProvider: s.mode === "prompt" ? s.modelProvider : undefined,
+ modelId: s.mode === "prompt" ? s.modelId : undefined,
+ };
+}
+
+describe("stepsToWorkflowIr — round-trip parity (R4/KTD-2)", () => {
+ it("reproduces every compiler-visible field for a mixed step set", () => {
+ const steps: WorkflowStep[] = [
+ step({
+ id: "WS-1",
+ name: "Implement",
+ description: "do the work",
+ mode: "prompt",
+ gateMode: "advisory",
+ prompt: "Implement the change",
+ toolMode: "coding",
+ phase: "pre-merge",
+ }),
+ step({
+ id: "WS-2",
+ name: "Lint",
+ mode: "script",
+ gateMode: "gate",
+ scriptName: "lint",
+ phase: "pre-merge",
+ }),
+ step({
+ id: "WS-3",
+ name: "Security gate",
+ mode: "prompt",
+ gateMode: "gate",
+ prompt: "Block on exploitable findings",
+ toolMode: "readonly",
+ modelProvider: "anthropic",
+ modelId: "claude-sonnet-4-5",
+ phase: "pre-merge",
+ }),
+ step({
+ id: "WS-4",
+ name: "Document",
+ mode: "prompt",
+ gateMode: "advisory",
+ prompt: "Write docs",
+ phase: "post-merge",
+ }),
+ step({
+ id: "WS-5",
+ name: "Deploy script",
+ mode: "script",
+ gateMode: "advisory",
+ scriptName: "deploy",
+ phase: "post-merge",
+ }),
+ ];
+
+ const ir = stepsToWorkflowIr(steps, "Migrated");
+ const compiled = compileWorkflowToSteps(ir);
+
+ expect(compiled.map(visible)).toEqual(steps.map(visibleStep));
+ });
+
+ it("undefined phase maps to pre-merge and round-trips", () => {
+ const steps: WorkflowStep[] = [
+ step({ id: "WS-1", name: "A", mode: "prompt", gateMode: "advisory", prompt: "a" }),
+ step({ id: "WS-2", name: "B", mode: "prompt", gateMode: "advisory", prompt: "b" }),
+ ];
+ const ir = stepsToWorkflowIr(steps, "AllUndefined");
+ // parseable
+ expect(() => parseWorkflowIr(ir)).not.toThrow();
+ const compiled = compileWorkflowToSteps(ir);
+ expect(compiled.map((c) => c.phase)).toEqual(["pre-merge", "pre-merge"]);
+ expect(compiled.map(visible)).toEqual(steps.map(visibleStep));
+ });
+
+ it("empty step list yields a minimal valid IR that compiles to []", () => {
+ const ir = stepsToWorkflowIr([], "Empty");
+ expect(() => parseWorkflowIr(ir)).not.toThrow();
+ expect(compileWorkflowToSteps(ir)).toEqual([]);
+ // start + 3 seams + end.
+ expect(ir.nodes.map((n) => n.id)).toEqual(["start", "execute", "review", "merge", "end"]);
+ });
+
+ it("post-merge-only set places nodes after the merge seam", () => {
+ const steps: WorkflowStep[] = [
+ step({ id: "WS-1", name: "After", mode: "prompt", gateMode: "advisory", prompt: "x", phase: "post-merge" }),
+ ];
+ const ir = stepsToWorkflowIr(steps, "PostOnly");
+ const ids = ir.nodes.map((n) => n.id);
+ expect(ids.indexOf("merge")).toBeLessThan(ids.indexOf("step-1"));
+ const compiled = compileWorkflowToSteps(ir);
+ expect(compiled).toHaveLength(1);
+ expect(compiled[0].phase).toBe("post-merge");
+ });
+
+ it("produced IR passes parseWorkflowIr and encodes seams exactly per linear()", () => {
+ const steps: WorkflowStep[] = [
+ step({ id: "WS-1", name: "A", mode: "prompt", gateMode: "advisory", prompt: "a" }),
+ ];
+ const ir = stepsToWorkflowIr(steps, "Seams");
+ expect(() => parseWorkflowIr(ir)).not.toThrow();
+
+ // Each seam appears exactly once, in execute → review → merge order.
+ const seamNodes = ir.nodes.filter((n) => typeof n.config?.seam === "string");
+ expect(seamNodes.map((n) => n.config!.seam)).toEqual(["execute", "review", "merge"]);
+
+ // Each seam has a failure → end edge.
+ for (const seam of ["execute", "review", "merge"]) {
+ const failEdge = ir.edges.find((e) => e.from === seam && e.condition === "failure");
+ expect(failEdge?.to).toBe("end");
+ }
+ // No duplicate failure edges per seam.
+ const failureEdges = ir.edges.filter((e) => e.condition === "failure");
+ expect(failureEdges).toHaveLength(3);
+ });
+
+ it("gate vs advisory both round-trip for prompt and script modes", () => {
+ const steps: WorkflowStep[] = [
+ step({ id: "WS-1", name: "PG", mode: "prompt", gateMode: "gate", prompt: "p" }),
+ step({ id: "WS-2", name: "PA", mode: "prompt", gateMode: "advisory", prompt: "p" }),
+ step({ id: "WS-3", name: "SG", mode: "script", gateMode: "gate", scriptName: "s" }),
+ step({ id: "WS-4", name: "SA", mode: "script", gateMode: "advisory", scriptName: "s" }),
+ ];
+ const compiled = compileWorkflowToSteps(stepsToWorkflowIr(steps, "Gates"));
+ expect(compiled.map((c) => c.gateMode)).toEqual(["gate", "advisory", "gate", "advisory"]);
+ expect(compiled.map(visible)).toEqual(steps.map(visibleStep));
+ });
+});
+
+describe("stepToFragmentIr (R6/KTD-1)", () => {
+ it("produces a parseable start → node → end fragment mirroring the step", () => {
+ const s = step({
+ id: "WS-1",
+ name: "Doc",
+ description: "doc it",
+ mode: "prompt",
+ gateMode: "advisory",
+ prompt: "Document the change",
+ toolMode: "readonly",
+ });
+ const ir = stepToFragmentIr(s);
+ expect(() => parseWorkflowIr(ir)).not.toThrow();
+ expect(ir.nodes.map((n) => n.id)).toEqual(["start", "step-1", "end"]);
+ expect(ir.nodes.map((n) => n.kind)).toEqual(["start", "prompt", "end"]);
+
+ // The single node compiles back to a step mirroring the source.
+ const compiled = compileWorkflowToSteps(ir);
+ expect(compiled).toHaveLength(1);
+ expect(visible(compiled[0])).toEqual(visibleStep(s));
+ });
+
+ it("fragment IR is pure v1 (no v2-only features)", () => {
+ const ir = stepToFragmentIr(step({ id: "WS-1", name: "S", mode: "script", gateMode: "gate", scriptName: "lint" }));
+ // parseWorkflowIr upgrades to v2 in-memory; the SOURCE we built is v1-shaped.
+ const compiled = compileWorkflowToSteps(ir);
+ expect(compiled[0].mode).toBe("script");
+ expect(compiled[0].scriptName).toBe("lint");
+ });
+});
+
+describe("layoutForIr", () => {
+ it("produces x-spaced positions for every node", () => {
+ const ir = stepsToWorkflowIr(
+ [step({ id: "WS-1", name: "A", mode: "prompt", gateMode: "advisory", prompt: "a" })],
+ "L",
+ );
+ const layout = layoutForIr(ir);
+ expect(Object.keys(layout).sort()).toEqual(ir.nodes.map((n) => n.id).sort());
+ expect(layout.start).toEqual({ x: 60, y: 160 });
+ // Second node is one column over.
+ expect(layout[ir.nodes[1].id].x).toBe(60 + 170);
+ });
+});
diff --git a/packages/core/src/builtin-workflows.ts b/packages/core/src/builtin-workflows.ts
index 62e734cf44..270fda980d 100644
--- a/packages/core/src/builtin-workflows.ts
+++ b/packages/core/src/builtin-workflows.ts
@@ -47,6 +47,8 @@ function linear(spec: BuiltinSpec): WorkflowDefinition {
id: spec.id,
name: spec.name,
description: spec.description,
+ // Built-ins are always selectable workflows, never fragments (KTD-1).
+ kind: "workflow",
ir,
layout,
createdAt: BUILTIN_TS,
@@ -152,6 +154,7 @@ export const BUILTIN_WORKFLOWS: WorkflowDefinition[] = [
name: "Stepwise coding (built-in)",
description:
"Per-step plan, execute, and review modeled as graph structure: each planned step runs and is reviewed (approve / revise / rethink) before the next, with bounded rework. Requires the workflow graph executor.",
+ kind: "workflow",
ir: BUILTIN_STEPWISE_CODING_WORKFLOW_IR,
layout: {
start: { x: 60, y: 160 },
diff --git a/packages/core/src/db.ts b/packages/core/src/db.ts
index 6403a186f1..886b80a64b 100644
--- a/packages/core/src/db.ts
+++ b/packages/core/src/db.ts
@@ -149,7 +149,7 @@ export function probeFts5(db: DatabaseSync): boolean {
// ── Schema Definition ────────────────────────────────────────────────
-const SCHEMA_VERSION = 108;
+const SCHEMA_VERSION = 109;
export { SCHEMA_VERSION };
@@ -385,6 +385,10 @@ CREATE TABLE IF NOT EXISTS workflow_steps (
defaultOn INTEGER DEFAULT 0,
modelProvider TEXT,
modelId TEXT,
+ -- (workflow-editor-consolidation U1/U2) when this step has been migrated into a
+ -- fragment WorkflowDefinition, the fragment's id is stamped here so re-runs of
+ -- the lazy migration skip already-migrated rows (marker idempotency).
+ migrated_fragment_id TEXT,
createdAt TEXT NOT NULL,
updatedAt TEXT NOT NULL
);
@@ -398,6 +402,11 @@ CREATE TABLE IF NOT EXISTS workflows (
description TEXT NOT NULL DEFAULT '',
ir TEXT NOT NULL,
layout TEXT NOT NULL DEFAULT '{}',
+ -- (workflow-editor-consolidation U1, KTD-1) discriminates reusable single-node
+ -- "fragment" templates from full "workflow" definitions. Fragments never appear
+ -- in task workflow pickers, default-workflow selection, or compile/selection
+ -- paths. Legacy rows default to 'workflow'.
+ kind TEXT NOT NULL DEFAULT 'workflow',
createdAt TEXT NOT NULL,
updatedAt TEXT NOT NULL
);
@@ -4291,6 +4300,19 @@ export class Database {
});
}
+ // Migration 109: Workflow editor consolidation (workflow-editor-consolidation
+ // U1, KTD-1). Adds workflows.kind (fragment vs workflow discriminator;
+ // existing rows default to 'workflow') and workflow_steps.migrated_fragment_id
+ // (nullable marker stamping a step that has been migrated into a fragment, so
+ // the lazy step migration is idempotent). Additive-only, idempotent
+ // (addColumnIfMissing guards); no backfill.
+ if (version < 109) {
+ this.applyMigration(109, () => {
+ this.addColumnIfMissing("workflows", "kind", "TEXT NOT NULL DEFAULT 'workflow'");
+ this.addColumnIfMissing("workflow_steps", "migrated_fragment_id", "TEXT");
+ });
+ }
+
}
/**
diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts
index 999eb211cb..7e58b172f7 100644
--- a/packages/core/src/index.ts
+++ b/packages/core/src/index.ts
@@ -220,6 +220,7 @@ export type {
WorkflowDefinition,
WorkflowDefinitionInput,
WorkflowDefinitionUpdate,
+ WorkflowDefinitionKind,
WorkflowNodeLayout,
} from "./workflow-definition-types.js";
export {
@@ -227,6 +228,11 @@ export {
validateLinearity,
WorkflowCompileError,
} from "./workflow-compiler.js";
+export {
+ stepsToWorkflowIr,
+ stepToFragmentIr,
+ layoutForIr,
+} from "./workflow-steps-to-ir.js";
export {
BUILTIN_WORKFLOWS,
BUILTIN_WORKFLOW_ID_PREFIX,
diff --git a/packages/core/src/store.ts b/packages/core/src/store.ts
index 352b281004..072e5b5212 100644
--- a/packages/core/src/store.ts
+++ b/packages/core/src/store.ts
@@ -3738,6 +3738,7 @@ export class TaskStore extends EventEmitter {
defaultOn: number | null;
modelProvider: string | null;
modelId: string | null;
+ migrated_fragment_id?: string | null;
createdAt: string;
updatedAt: string;
}): import("./types.js").WorkflowStep {
@@ -3758,6 +3759,7 @@ export class TaskStore extends EventEmitter {
defaultOn: row.defaultOn === null || row.defaultOn === undefined ? undefined : Boolean(row.defaultOn),
modelProvider: row.modelProvider ?? undefined,
modelId: row.modelId ?? undefined,
+ migratedFragmentId: row.migrated_fragment_id ?? undefined,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
};
@@ -11827,6 +11829,7 @@ ${stepsSection}`;
defaultOn: input.defaultOn !== undefined ? input.defaultOn : undefined,
modelProvider: mode === "prompt" ? input.modelProvider : undefined,
modelId: mode === "prompt" ? input.modelId : undefined,
+ migratedFragmentId: input.migratedFragmentId,
createdAt: now,
updatedAt: now,
};
@@ -11847,9 +11850,10 @@ ${stepsSection}`;
defaultOn,
modelProvider,
modelId,
+ migrated_fragment_id,
createdAt,
updatedAt
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
).run(
step.id,
step.templateId ?? null,
@@ -11865,6 +11869,7 @@ ${stepsSection}`;
step.defaultOn === undefined ? null : step.defaultOn ? 1 : 0,
step.modelProvider ?? null,
step.modelId ?? null,
+ step.migratedFragmentId ?? null,
step.createdAt,
step.updatedAt,
);
@@ -12087,6 +12092,7 @@ ${stepsSection}`;
if ("modelProvider" in updates) step.modelProvider = updates.modelProvider;
if ("modelId" in updates) step.modelId = updates.modelId;
}
+ if ("migratedFragmentId" in updates) step.migratedFragmentId = updates.migratedFragmentId;
step.updatedAt = new Date().toISOString();
this.db.prepare(
@@ -12104,6 +12110,7 @@ ${stepsSection}`;
defaultOn = ?,
modelProvider = ?,
modelId = ?,
+ migrated_fragment_id = ?,
updatedAt = ?
WHERE id = ?`,
).run(
@@ -12120,6 +12127,7 @@ ${stepsSection}`;
step.defaultOn === undefined ? null : step.defaultOn ? 1 : 0,
step.modelProvider ?? null,
step.modelId ?? null,
+ step.migratedFragmentId ?? null,
step.updatedAt,
step.id,
);
@@ -12195,6 +12203,7 @@ ${stepsSection}`;
description: string;
ir: string;
layout: string;
+ kind?: string | null;
createdAt: string;
updatedAt: string;
}): WorkflowDefinition {
@@ -12202,6 +12211,8 @@ ${stepsSection}`;
id: row.id,
name: row.name,
description: row.description,
+ // Legacy rows (pre-migration-109) have no kind column; default to "workflow".
+ kind: row.kind === "fragment" ? "fragment" : "workflow",
ir: parseWorkflowIr(row.ir),
layout: this.parseWorkflowLayout(row.layout),
createdAt: row.createdAt,
@@ -12256,6 +12267,9 @@ ${stepsSection}`;
id,
name,
description: input.description ?? "",
+ // KTD-1: fragments are pure-v1 IRs and pass through downgradeIrToV1IfPure
+ // unchanged; default to "workflow" when the caller omits the kind.
+ kind: input.kind === "fragment" ? "fragment" : "workflow",
ir,
layout,
createdAt: now,
@@ -12264,8 +12278,8 @@ ${stepsSection}`;
this.db
.prepare(
- `INSERT INTO workflows (id, name, description, ir, layout, createdAt, updatedAt)
- VALUES (?, ?, ?, ?, ?, ?, ?)`,
+ `INSERT INTO workflows (id, name, description, ir, layout, kind, createdAt, updatedAt)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
)
.run(
definition.id,
@@ -12275,6 +12289,7 @@ ${stepsSection}`;
flagOnForCreate ? definition.ir : downgradeIrToV1IfPure(definition.ir),
),
JSON.stringify(definition.layout),
+ definition.kind,
definition.createdAt,
definition.updatedAt,
);
@@ -12285,8 +12300,26 @@ ${stepsSection}`;
});
}
- /** List all workflow definitions, oldest first. Cached until a mutation. */
- async listWorkflowDefinitions(): Promise {
+ /** List workflow definitions, oldest first. The `kind` filter (KTD-1) selects
+ * only workflows or only fragments; omit it to get the full merged set.
+ *
+ * Cache invariant: `workflowDefinitionsCache` ALWAYS holds the full merged set
+ * (built-ins + every row of every kind). The `kind` filter is applied to a
+ * slice taken AFTER the cache read — a filtered result is never cached, so a
+ * filtered call can never poison an unfiltered consumer (or vice versa).
+ */
+ async listWorkflowDefinitions(
+ options?: { kind?: WorkflowDefinition["kind"] },
+ ): Promise {
+ const all = await this.readAllWorkflowDefinitions();
+ if (options?.kind) return all.filter((wf) => wf.kind === options.kind);
+ return all;
+ }
+
+ /** Read (and cache) the full merged workflow-definition set, oldest first.
+ * Built-in templates lead the list and cannot be edited/deleted; built-ins
+ * are always kind "workflow". */
+ private async readAllWorkflowDefinitions(): Promise {
if (this.workflowDefinitionsCache) return this.workflowDefinitionsCache;
const rows = this.db.prepare("SELECT * FROM workflows ORDER BY createdAt ASC").all() as Array<{
id: string;
@@ -12294,10 +12327,10 @@ ${stepsSection}`;
description: string;
ir: string;
layout: string;
+ kind?: string | null;
createdAt: string;
updatedAt: string;
}>;
- // Built-in templates lead the list and cannot be edited/deleted.
this.workflowDefinitionsCache = [...BUILTIN_WORKFLOWS, ...rows.map((row) => this.toWorkflowDefinition(row))];
return this.workflowDefinitionsCache;
}
@@ -12315,6 +12348,7 @@ ${stepsSection}`;
description: string;
ir: string;
layout: string;
+ kind?: string | null;
createdAt: string;
updatedAt: string;
}
@@ -13250,6 +13284,9 @@ ${stepsSection}`;
if (!workflowId) return undefined;
const def = await this.getWorkflowDefinition(workflowId);
if (!def) return undefined;
+ // KTD-1/R6: a fragment must never act as a project default (it is not a
+ // selectable workflow); fall back to no default rather than materializing it.
+ if (def.kind === "fragment") return undefined;
// Compile (and validate) before creating any rows so a non-compilable
// default falls back cleanly with nothing written.
const inputs = compileWorkflowToSteps(def.ir);
@@ -13271,6 +13308,12 @@ ${stepsSection}`;
return this.withTaskLock(taskId, async () => {
const def = await this.getWorkflowDefinition(workflowId);
if (!def) throw new Error(`Workflow '${workflowId}' not found`);
+ // KTD-1/R6: fragments are reusable single-node palette templates, not
+ // selectable workflows. Reject them from task selection with a clear error
+ // rather than materializing a degenerate single-step task.
+ if (def.kind === "fragment") {
+ throw new Error(`Workflow '${workflowId}' is a fragment and cannot be selected for a task`);
+ }
// Compile once up front: a non-linear graph aborts before any mutation.
const inputs = compileWorkflowToSteps(def.ir);
diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts
index a856b620fb..4709197136 100644
--- a/packages/core/src/types.ts
+++ b/packages/core/src/types.ts
@@ -541,6 +541,11 @@ export interface WorkflowStep {
* Must be set together with `modelProvider`. When both model fields are undefined,
* the executor uses global settings defaults. Only used when mode is "prompt". */
modelId?: string;
+ /** (workflow-editor-consolidation U1/U2, KTD-1/KTD-3) when this legacy step has
+ * been migrated into a fragment WorkflowDefinition, the fragment's id is stamped
+ * here so the lazy step migration is idempotent (already-stamped rows are
+ * skipped). Stored in the `migrated_fragment_id` column. */
+ migratedFragmentId?: string;
/** ISO-8601 timestamp of creation */
createdAt: string;
/** ISO-8601 timestamp of last update */
@@ -651,6 +656,9 @@ export interface WorkflowStepInput {
modelProvider?: string;
/** AI model ID override. Must be set together with modelProvider. Only used when mode is "prompt". */
modelId?: string;
+ /** (workflow-editor-consolidation U2, KTD-3) fragment id stamped when this step
+ * was migrated into a fragment WorkflowDefinition. Set by the migration only. */
+ migratedFragmentId?: string;
}
/** Result of a workflow step execution on a task. */
diff --git a/packages/core/src/workflow-compiler.ts b/packages/core/src/workflow-compiler.ts
index 3561610449..dcf7ce0363 100644
--- a/packages/core/src/workflow-compiler.ts
+++ b/packages/core/src/workflow-compiler.ts
@@ -159,6 +159,18 @@ function defaultGateMode(node: WorkflowIrNode, mode: "prompt" | "script"): Workf
return mode === "script" ? "gate" : "advisory";
}
+/**
+ * Map a single user IR node onto a WorkflowStepInput. This is the forward half
+ * of the steps↔IR round-trip contract (workflow-editor-consolidation R4/KTD-2);
+ * its exact inverse is `stepInputToNode` in `workflow-steps-to-ir.ts`. Parity is
+ * pinned by `__tests__/workflow-steps-to-ir.test.ts` over exactly the
+ * compiler-visible fields: name / mode / phase / gateMode / prompt / scriptName /
+ * toolMode / modelProvider / modelId. `enabled` / `defaultOn` / `templateId` are
+ * NOT compiler-visible and are handled by migration policy, not the converter.
+ *
+ * INVERSION CONTRACT: when you add a field here, extend `stepInputToNode` (and
+ * the parity test) in `workflow-steps-to-ir.ts` to keep the round-trip exact.
+ */
function nodeToStepInput(node: WorkflowIrNode, phase: "pre-merge" | "post-merge"): WorkflowStepInput {
const scriptName = configString(node, "scriptName");
const mode: "prompt" | "script" = node.kind === "script" || (node.kind === "gate" && scriptName) ? "script" : "prompt";
diff --git a/packages/core/src/workflow-definition-types.ts b/packages/core/src/workflow-definition-types.ts
index 60aee809e4..6b88fc5809 100644
--- a/packages/core/src/workflow-definition-types.ts
+++ b/packages/core/src/workflow-definition-types.ts
@@ -7,6 +7,12 @@ export interface WorkflowNodeLayout {
y: number;
}
+/** Discriminates a full, selectable workflow from a reusable single-node
+ * "fragment" template (workflow-editor-consolidation U1, KTD-1). Fragments are
+ * excluded from task workflow pickers, default-workflow selection, and the
+ * compile/selection paths; both kinds are stored as parseable full IRs. */
+export type WorkflowDefinitionKind = "workflow" | "fragment";
+
/** A named, persisted workflow authored as a WorkflowIr graph plus editor layout. */
export interface WorkflowDefinition {
/** Unique identifier (e.g., "WF-001"). */
@@ -15,6 +21,8 @@ export interface WorkflowDefinition {
name: string;
/** Short description for UI display. */
description: string;
+ /** Discriminates full workflows from reusable fragment templates (KTD-1). */
+ kind: WorkflowDefinitionKind;
/** The validated workflow graph (v1 IR contract). */
ir: WorkflowIr;
/** Editor node positions keyed by IR node id. May be empty (auto-layout). */
@@ -32,6 +40,9 @@ export interface WorkflowDefinitionInput {
/** Workflow graph; validated via parseWorkflowIr on write. */
ir: WorkflowIr;
layout?: Record;
+ /** Discriminates full workflows from reusable fragment templates (KTD-1).
+ * Defaults to "workflow" when omitted. */
+ kind?: WorkflowDefinitionKind;
}
/** Partial update for an existing workflow definition. */
diff --git a/packages/core/src/workflow-steps-to-ir.ts b/packages/core/src/workflow-steps-to-ir.ts
new file mode 100644
index 0000000000..5d4f3cbe96
--- /dev/null
+++ b/packages/core/src/workflow-steps-to-ir.ts
@@ -0,0 +1,162 @@
+import type { WorkflowStep } from "./types.js";
+import type { WorkflowIr, WorkflowIrNode, WorkflowIrEdge } from "./workflow-ir-types.js";
+import type { WorkflowNodeLayout } from "./workflow-definition-types.js";
+import { parseWorkflowIr } from "./workflow-ir.js";
+
+/**
+ * Steps → IR converter (workflow-editor-consolidation U1, R4/KTD-2).
+ *
+ * This module is the exact INVERSE of the compiler's `nodeToStepInput`
+ * (`workflow-compiler.ts`). The round-trip contract is:
+ *
+ * compileWorkflowToSteps(stepsToWorkflowIr(steps, name)) ≡ steps
+ *
+ * over exactly the compiler-visible fields: name / mode / phase / gateMode /
+ * prompt / scriptName / toolMode / modelProvider / modelId. `enabled` /
+ * `defaultOn` / `templateId` / `migratedFragmentId` are NOT compiler-visible and
+ * are handled by migration policy (KTD-3), not by this converter. Parity is
+ * pinned by `__tests__/workflow-steps-to-ir.test.ts`.
+ *
+ * INVERSION CONTRACT: when a compiler-visible field is added to `nodeToStepInput`
+ * (see the contract comment there), extend `stepInputToNode` below and the parity
+ * test to keep the round-trip exact.
+ *
+ * Seam encoding mirrors `linear()` in `builtin-workflows.ts` exactly: the fixed
+ * execute → review → merge pipeline is emitted as prompt-kind nodes carrying
+ * `config.seam`, chained by `success` edges, with each seam also wired
+ * `failure → end`.
+ */
+
+/** The fixed seam pipeline, in canonical order. The `merge` seam is the
+ * pre-/post-merge boundary and is always emitted (R4). */
+const SEAM_ORDER = ["execute", "review", "merge"] as const;
+
+/** Horizontal spacing used by `linear()`; reused so migrated graphs lay out the
+ * same way built-ins do. */
+const LAYOUT_X0 = 60;
+const LAYOUT_DX = 170;
+const LAYOUT_Y = 160;
+
+/**
+ * Inverse of `nodeToStepInput` (workflow-compiler.ts). Produces a single user IR
+ * node whose forward compilation reproduces every compiler-visible field of the
+ * given step.
+ *
+ * kind ↔ mode/gateMode mapping (the heart of the contract):
+ * - mode "script" → kind "script", `config.scriptName` set. The compiler reads
+ * mode from `kind === "script"`, so this round-trips to mode "script".
+ * - mode "prompt" → kind "prompt", `config.prompt`/`toolMode`/model overrides.
+ * - gateMode is ALWAYS written to `config.gateMode` (both "gate" and "advisory").
+ * The compiler's `defaultGateMode` returns an explicit `config.gateMode` for
+ * non-gate-kind nodes verbatim, so this round-trips for both modes without
+ * needing the `gate` node kind (which the compiler only emits via scriptName
+ * heuristics — using explicit `config.gateMode` keeps the inverse total).
+ */
+function stepInputToNode(step: WorkflowStep, id: string): WorkflowIrNode {
+ const config: Record = {
+ name: step.name,
+ // Always carry gateMode so the compiler reproduces it exactly for both modes.
+ gateMode: step.gateMode,
+ };
+ if (step.description) config.description = step.description;
+
+ if (step.mode === "script") {
+ if (step.scriptName) config.scriptName = step.scriptName;
+ return { id, kind: "script", config };
+ }
+
+ // prompt mode
+ config.prompt = step.prompt ?? "";
+ config.toolMode = step.toolMode === "coding" ? "coding" : "readonly";
+ // Model overrides only round-trip when BOTH are present (compiler requirement).
+ if (step.modelProvider && step.modelId) {
+ config.modelProvider = step.modelProvider;
+ config.modelId = step.modelId;
+ }
+ return { id, kind: "prompt", config };
+}
+
+/** Build a seam node exactly as `linear()` does: a prompt-kind node tagged with
+ * `config.seam`. */
+function seamNode(seam: (typeof SEAM_ORDER)[number]): WorkflowIrNode {
+ return { id: seam, kind: "prompt", config: { seam } };
+}
+
+/**
+ * Convert an ordered `WorkflowStep[]` into a valid v1 WorkflowIr:
+ *
+ * start → [pre-merge user nodes] → execute → review → merge
+ * → [post-merge user nodes] → end
+ *
+ * Steps with `phase` undefined map to pre-merge (R4). Seam nodes get an extra
+ * `failure → end` edge, mirroring `linear()`. The result always passes
+ * `parseWorkflowIr`. An empty step list yields the minimal seam-only pipeline
+ * (which compiles back to `[]`).
+ */
+export function stepsToWorkflowIr(steps: WorkflowStep[], name: string): WorkflowIr {
+ const preMerge = steps.filter((s) => (s.phase ?? "pre-merge") === "pre-merge");
+ const postMerge = steps.filter((s) => s.phase === "post-merge");
+
+ const nodes: WorkflowIrNode[] = [{ id: "start", kind: "start" }];
+ const userNodeIds = new Set();
+
+ // Deterministic ids that cannot collide with the reserved start/end/seam ids.
+ const userNode = (step: WorkflowStep, index: number): WorkflowIrNode => {
+ let id = `step-${index + 1}`;
+ while (userNodeIds.has(id)) id = `${id}-x`;
+ userNodeIds.add(id);
+ return stepInputToNode(step, id);
+ };
+
+ preMerge.forEach((step, i) => nodes.push(userNode(step, i)));
+ // Fixed execute → review → merge seam pipeline; merge is the boundary (R4).
+ for (const seam of SEAM_ORDER) nodes.push(seamNode(seam));
+ postMerge.forEach((step, i) => nodes.push(userNode(step, preMerge.length + i)));
+ nodes.push({ id: "end", kind: "end" });
+
+ const edges: WorkflowIrEdge[] = [];
+ for (let i = 0; i < nodes.length - 1; i += 1) {
+ edges.push({ from: nodes[i].id, to: nodes[i + 1].id, condition: "success" });
+ }
+ // Seam nodes also fail straight to end (mirrors `linear()` / the legacy pipeline).
+ for (const node of nodes) {
+ if (typeof node.config?.seam === "string") {
+ edges.push({ from: node.id, to: "end", condition: "failure" });
+ }
+ }
+
+ return parseWorkflowIr({ version: "v1", name, nodes, edges });
+}
+
+/**
+ * Convert a single `WorkflowStep` into a minimal fragment IR (R6/KTD-1):
+ *
+ * start → node → end
+ *
+ * No seams. The node mirrors the step via `stepInputToNode`. The result passes
+ * `parseWorkflowIr` and is a pure-v1 graph (survives `downgradeIrToV1IfPure`).
+ */
+export function stepToFragmentIr(step: WorkflowStep): WorkflowIr {
+ const node = stepInputToNode(step, "step-1");
+ return parseWorkflowIr({
+ version: "v1",
+ name: step.name,
+ nodes: [{ id: "start", kind: "start" }, node, { id: "end", kind: "end" }],
+ edges: [
+ { from: "start", to: node.id, condition: "success" },
+ { from: node.id, to: "end", condition: "success" },
+ ],
+ });
+}
+
+/**
+ * Deterministic x-spaced layout for an IR, matching `linear()`'s geometry. Keyed
+ * by node id; supply alongside the IR when persisting a `WorkflowDefinitionInput`.
+ */
+export function layoutForIr(ir: WorkflowIr): Record {
+ const layout: Record = {};
+ ir.nodes.forEach((node, i) => {
+ layout[node.id] = { x: LAYOUT_X0 + i * LAYOUT_DX, y: LAYOUT_Y };
+ });
+ return layout;
+}
diff --git a/packages/engine/src/__tests__/workflow-graph-task-runner.test.ts b/packages/engine/src/__tests__/workflow-graph-task-runner.test.ts
index 9e865bf9d3..e1fe727b40 100644
--- a/packages/engine/src/__tests__/workflow-graph-task-runner.test.ts
+++ b/packages/engine/src/__tests__/workflow-graph-task-runner.test.ts
@@ -44,6 +44,7 @@ function definition(ir: WorkflowIr): WorkflowDefinition {
id: "WF-001",
name: "Full lifecycle",
description: "",
+ kind: "workflow",
ir,
layout: {},
createdAt: "2026-06-03T00:00:00.000Z",