Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
47 commits
Select commit Hold shift + click to select a range
ffce42f
feat(core): persist named workflow definitions (U1)
gsxdsm Jun 3, 2026
c9250df
feat(core): compile workflow IR to executable WorkflowSteps (U2)
gsxdsm Jun 3, 2026
47a4299
feat(core): per-task workflow selection + project default (U3)
gsxdsm Jun 3, 2026
72f37a8
feat(dashboard): workflow CRUD, compile-preview, and selection API (U4)
gsxdsm Jun 3, 2026
8047015
feat(dashboard): workflow API client functions (U5)
gsxdsm Jun 3, 2026
d071aec
feat(dashboard): visual graph node editor for workflows (U6, U7)
gsxdsm Jun 3, 2026
07a0a0c
feat(dashboard): surface workflow selection on tasks and project sett…
gsxdsm Jun 3, 2026
6d49f37
refactor(core): reuse materializeWorkflowSteps in selectTaskWorkflow
gsxdsm Jun 3, 2026
8dca36b
fix(core,dashboard): code-review fixes for workflow selection
gsxdsm Jun 3, 2026
cdde7e8
docs: add executable custom workflows plan (completed)
gsxdsm Jun 3, 2026
ba27e49
feat(engine): executable custom nodes in the workflow graph interpret…
gsxdsm Jun 3, 2026
83451b1
feat(engine): WorkflowGraphTaskRunner — interpreter drives a task's l…
gsxdsm Jun 3, 2026
446879f
docs(plan): record engine seam characterization findings for cutover U3
gsxdsm Jun 3, 2026
44e77fd
feat(engine): graph interpreter owns the full task lifecycle behind t…
gsxdsm Jun 3, 2026
5502025
fix: apply safe_auto code-review fixes
gsxdsm Jun 3, 2026
6bb5eeb
fix(engine,core): code-review safety fixes for the graph interpreter
gsxdsm Jun 3, 2026
fd94d0f
feat(engine): prompt-node execution profiles + await-input nodes
gsxdsm Jun 3, 2026
5a318bf
feat(dashboard): execution profiles + await-input UI for workflow nodes
gsxdsm Jun 3, 2026
ddde3c5
feat(engine,core,api): CLI nodes run arbitrary commands with trust-on…
gsxdsm Jun 3, 2026
d6763e5
feat(dashboard): CLI command field + interactive input/approval banner
gsxdsm Jun 3, 2026
a98d14c
feat: per-node option to skip CLI first-run approval
gsxdsm Jun 3, 2026
62aba2c
feat(core): built-in workflow templates incl. compound engineering
gsxdsm Jun 3, 2026
e685dc5
feat(dashboard): select a custom workflow when creating a task
gsxdsm Jun 3, 2026
0435fef
fix(workflows): persist pausedReason + harden CLI approval, await-inp…
gsxdsm Jun 3, 2026
bd8f0fd
feat(dashboard): make built-in workflows read-only in the node editor
gsxdsm Jun 3, 2026
2910724
feat(workflows): enforce node autoApprove by bypassing the CLI approv…
gsxdsm Jun 3, 2026
5c02271
feat(engine): add fn_workflow_list and fn_workflow_select agent tools
gsxdsm Jun 3, 2026
b4d9afa
docs(changeset): note agent tools, read-only built-ins, autoApprove
gsxdsm Jun 3, 2026
46f525b
Merge main: reconcile duplicate pausedReason persistence fix + dashbo…
gsxdsm Jun 3, 2026
eb67d08
Address PR review feedback (#1363)
gsxdsm Jun 4, 2026
8bb02f7
fix(engine,core): merge-seam multi-waiter + autoMerge gate; selection…
gsxdsm Jun 4, 2026
d5397d3
fix(dashboard): inline isBuiltinWorkflowId — unbreak the app build
gsxdsm Jun 4, 2026
05f2d49
test: bump hardcoded schema-version assertions 102 → 105
gsxdsm Jun 4, 2026
e5386dc
docs(skill): document fn_workflow_list / fn_workflow_select engine tools
gsxdsm Jun 4, 2026
e954f1a
test(dashboard): mock workflow API in board-mobile and settings-mobil…
gsxdsm Jun 4, 2026
d8b88f3
test(dashboard): mock useToast in PlanningModeModal.ui-interactions
gsxdsm Jun 4, 2026
6c0ea28
feat(core): workflow parity observation builders (CU-U5 #1)
gsxdsm Jun 4, 2026
dbb8ade
feat: surface dual-observe flag + parity summary (CU-U5 #3)
gsxdsm Jun 4, 2026
8e9a3ac
feat(engine): wire dual-observe parity at the post-execute handoff (C…
gsxdsm Jun 4, 2026
3432e06
test: pin git init to 'main' in test setup (fix Linux-CI branch-group…
gsxdsm Jun 4, 2026
cd0e3e2
test: prevent Linux-CI git hangs (no prompt/editor/pager)
gsxdsm Jun 4, 2026
9e95956
test(engine): quarantine worktree-heavy reliability tests + fail-fast…
gsxdsm Jun 4, 2026
759e2e6
Merge remote-tracking branch 'origin/main' into gsxdsm/workflowbuilder
gsxdsm Jun 4, 2026
4cdf198
test(engine): quarantine remaining branch-group real-git suites to en…
gsxdsm Jun 4, 2026
07489e5
fix(core): finish deferred agentLogEntries drop when migrations 103+ …
gsxdsm Jun 4, 2026
18ba5c0
Address PR review feedback (#1363)
gsxdsm Jun 4, 2026
fcebab5
Update packages/engine/src/project-engine.ts
gsxdsm Jun 4, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .changeset/graph-custom-workflows.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
"@runfusion/fusion": minor
---

Add executable custom workflows with a visual graph node editor. Author a workflow as a graph (start → prompt/script/gate steps → end) in a new React Flow–based editor, then select it per task or set a project default. Selected workflows compile to the existing WorkflowStep engine and run at the pre/post-merge boundaries — no changes to the scheduler/executor/merger. Non-linear graphs are rejected with a clear message and reserved for the (deferred) graph interpreter.

Prompt nodes carry an execution profile: run on a chosen model, as a named agent, as a skill invocation, or as a named project script (CLI) with the prompt passed via FUSION_NODE_PROMPT — plus per-node retries and an auto-approve toggle. "User input" nodes pause the run with a needs-input badge on the task card and a banner in the task modal; replying in comments and unpausing resumes the workflow with the answer.

CLI nodes can run arbitrary commands (not just named scripts); the first run of an exact command pauses the task for explicit user approval. The task modal's input/approval banner is interactive — reply-and-resume for user-input nodes, approve-and-run for CLI commands.

Agents reach workflows too: new `fn_workflow_list` and `fn_workflow_select` task tools give agents the same list/select capability as the dashboard picker. Built-in workflows are now read-only in the editor (palette/inspector disabled, with a "Duplicate to edit" action), and a node's "Auto-approve requests" toggle now actually bypasses the CLI first-run approval pause.

Also fixes a latent persistence bug where `pausedReason` was written to the in-memory task and read by queries but never stored by the task upsert or mapped back on read — so it was lost on every reload. This silently broke any pause/resume that depends on the reason (workflow CLI-approval and await-input nodes, token-budget pauses, worktrunk failures). The approve-CLI endpoint now derives the approved command solely from the task's pausedReason (ignoring any caller-supplied command), await-input nodes only resume when this node actually paused the task (not on a pre-existing steering comment), and write-capable custom nodes are refused until a task worktree exists so they never mutate the shared repo root.

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
---
title: "feat: Workflow interpreter cutover — graph owns the full lifecycle"
type: feat
status: active
date: 2026-06-03
depth: deep
origin: docs/plans/2026-06-03-001-feat-executable-custom-workflows-node-editor-plan.md (Deferred Track)
---

# feat: Workflow interpreter cutover — graph owns the full lifecycle

## Summary

Promote `WorkflowGraphExecutor` from a flag-off no-op to the authoritative driver of a task's lifecycle, so a custom workflow graph can **replace** planning → execute → review → merge, not just inject steps around it. The user has explicitly waived the FN-4359 reliability freeze for this track; the parity invariants below remain the correctness bar regardless.

The existing scaffold already provides: graph walking with cycle detection, `success`/`failure`/`outcome:*` edge conditions, per-node retries, a `WorkflowLegacySeams` interface (`execute/review/merge/schedule`), the `workflowGraphExecutor` experimental flag, and the dual-observe parity machinery. The MVP (plan 001) provides: persisted workflow definitions, the node editor, and per-task selection. What's missing is **real seam implementations**, **custom-node handlers** (the default handlers throw for non-seam prompt/script nodes), and the **entry point** that routes a graph-selected task through the interpreter.

---

## Scope Boundaries

### In scope
- M-A: Interpreter foundation — custom-node handlers + a `WorkflowGraphTaskRunner` with injected seams, fully tested with fakes.
- M-B: Real seam wiring — delegate execute/review/merge to the legacy engine implementations via narrow injected callbacks from `TaskExecutor`/`ProjectEngine`.
- M-C: Flag-gated entry point — graph-selected tasks route through the interpreter when `experimentalFeatures.workflowGraphExecutor` is on; legacy fallback on any interpreter error.
- M-D: Parity + graduation — dual-observe on real runs, drive drift to zero, then default the flag on for graph-selected tasks.

### Deferred to Follow-Up Work
- Removing the legacy hardcoded pipeline (FN-5719 Phase 4) — only after M-D proves parity in the field.
- Planning/triage as a replaceable seam (the `schedule` seam exists but triage replacement needs the planning subsystem mapped first).
- Parallel branches (fan-out) executing concurrently — the walker is sequential; concurrency within a graph is a later extension.

---

## Key Technical Decisions

- **KTD-1 — Seams delegate, never reimplement.** The `execute`/`review`/`merge` seam implementations call the same engine functions the legacy path uses (agent session machinery, `reviewStep`, the auto-merge queue). The interpreter owns *sequencing*; the engine keeps owning *mechanics* (worktrees, leases, file-scope guard, squash contract, self-healing). This is the enqueue-only posture from DAG ADR-0001 applied to seams.
- **KTD-2 — Custom nodes run on the WorkflowStep machinery.** Non-seam prompt/script/gate nodes execute via the same prompt-session/script/verdict machinery as workflow steps (proven, readonly-tool-policy aware), invoked through an injected `runCustomNode` callback — the interpreter stays engine-agnostic and unit-testable with fakes.
- **KTD-3 — Legacy fallback on interpreter error (M-C).** Any thrown error from the interpreter path (not a graph-routed `failure` edge) falls back to the legacy pipeline for that task and emits an audit event. No task is ever stranded by interpreter bugs.
- **KTD-4 — Column transitions are seam side-effects.** `execute` seam entry → `in-progress`, review handoff → `in-review`, merge success → `done` — performed by the delegated engine code itself (KTD-1), so board invariants (FN-5147 terminal-until-merged, hard-cancel) are preserved by construction.
- **KTD-5 — Invariant bar (from FN-5719 / workflow-steps.md):** `FileScopeViolationError` guard, squash/merge contract, `autoMerge:false` terminal-until-merged, `moveTask(in-progress→todo)` hard-cancel, resume-limbo non-oscillation. Parity is machine-checked via the existing `compareWorkflowRunObservations` / `compareWorkflowRunAudits`.

---

## Implementation Units

### U1. Custom-node handlers (unblock non-seam nodes)
**Goal:** Default handlers route seam-configured prompt/script nodes to seams and **custom** prompt/script nodes to an injected runner instead of throwing.
**Files:** `packages/engine/src/workflow-node-handlers.ts`, `packages/engine/src/__tests__/workflow-node-handlers.test.ts`.
**Approach:** `createDefaultNodeHandlers(seams, runCustomNode)` — `resolveSeam` returns undefined (not throw) for non-seam nodes; gate nodes keep the context-gate behavior but also support prompt/script-backed gates via `runCustomNode` with `gateMode` semantics.
**Test scenarios:** seam node → seam called; custom prompt node → runner called with node config; custom script node → runner; gate node with context expectation → pass/fail; gate node with prompt config → runner verdict drives outcome; unknown seam string → error.

### U2. WorkflowGraphTaskRunner (engine-agnostic orchestration)
**Goal:** A runner that loads a task's selected workflow IR, runs `WorkflowGraphExecutor` with injected seams + custom-node runner, and maps the terminal outcome to a lifecycle disposition (`completed` / `failed` / `fell-back`).
**Files:** `packages/engine/src/workflow-graph-task-runner.ts` (new), `packages/engine/src/__tests__/workflow-graph-task-runner.test.ts` (new).
**Approach:** Pure DI: `{ store, seams, runCustomNode, settings }`. Reads selection via `store.getTaskWorkflowSelection` + `getWorkflowDefinition`. Falls back (disposition `fell-back`) when flag off, no selection, or IR load fails. Audit events for start/terminal/fallback.
**Test scenarios:** full graph run order with fake seams (execute→review→merge sequencing via the builtin IR); custom pre-merge node runs between start and execute when authored that way; failure edge routes to end with `failed`; thrown seam error → `fell-back`; flag off → `fell-back`; gate failure blocks merge seam.

### U3. Real seam implementations (M-B)
**Goal:** `createEngineSeams(executor, projectEngine)` delegating to real engine entry points.
**Files:** `packages/engine/src/workflow-engine-seams.ts` (new), executor/project-engine narrow accessor methods as needed, `packages/engine/src/__tests__/workflow-engine-seams.test.ts`.
**Approach:** execute → the executor's implementation-phase entry for an already-claimed task; review → `reviewStep` path with verdict mapped to `outcome:*`; merge → enqueue on the auto-merge queue and await terminal merge outcome. Each seam returns `WorkflowNodeResult` with `value` carrying verdict/outcome tokens for edge conditions.
**Execution note:** characterization-first — capture the legacy call sequence for one task end-to-end before extracting accessors.

**Characterization findings (2026-06-03):**
- **Review seam is plug-and-play today.** `reviewStep(cwd, taskId, stepNumber, stepName, reviewType, promptContent, baseline?, options)` (`packages/engine/src/reviewer.ts:306`) is a standalone exported function returning `{verdict: APPROVE|REVISE|RETHINK|UNAVAILABLE, review, summary}`. Map verdict → `outcome:*` edge tokens.
- **Merge seam is plug-and-play today.** `ProjectEngine.onMerge(taskId)` (`packages/engine/src/project-engine.ts:934`) returns `Promise<MergeResult>` (resolver-based, not polling); `MergeResult.merged` → success/failure. `aiMergeTask` (`merger.ts:7425`) is the lower-level direct call. `autoMerge:false` terminal-until-merged is honored by the queue itself.
- **Execute seam needs surgery.** The implementation phase (agent session → `fn_task_done`) is embedded inside `TaskExecutor.execute` at `executor.ts:4243–4611`, intertwined with worktree/claim setup (3152–4242) and the post-completion workflow-steps/review-handoff block (4549+). Required: extract a narrow `runImplementationPhase(task, worktreePath, settings, env) → {taskDone, modifiedFiles, completionMarkup}` (or an `implementationPhaseOnly` mode flag on `execute`), stopping before `runWorkflowSteps`/`handoffTaskToReview`. **Do not compose the full legacy `execute()` as the execute seam — it already performs review handoff and triggers auto-merge, so the graph's review/merge seams would double-run.**
- **Wiring point:** `in-process-runtime.ts:493` constructs `TaskExecutor`; ProjectEngine references the executor (not vice versa) — the runtime is where `createEngineSeams({executor, projectEngine, store, rootDir})` gets built.
- **Test harness to mimic:** `packages/engine/src/__tests__/executor-worktree.test.ts` (mocked session via `executor-test-helpers.ts`).

### U4. Flag-gated entry point + fallback (M-C)
**Goal:** Graph-selected tasks route through the runner from `TaskExecutor.execute`; interpreter errors fall back to legacy mid-flight where safe, else fail the task through existing recovery.
**Files:** `packages/engine/src/executor.ts` (top-of-execute branch only), `packages/engine/src/__tests__/workflow-graph-entry.test.ts`.

### U5. Dual-observe parity on real runs + graduation (M-D)
**Goal:** Enable `workflowInterpreterDualObserve` for graph-selected tasks, record drift, fix until `{agree:true}` is sustained, then flip `workflowGraphExecutor` default for selected-workflow tasks.
**Files:** `packages/engine/src/workflow-parity-observer.ts` call-site wiring, settings default change, docs update.

---

## Risks

- **TaskExecutor.execute is ~3k lines of intertwined state.** Mitigation: U3 extracts *accessors*, never moves logic; U4 touches only a top-of-function branch; characterization tests first.
- **Self-healing/recovery assume the legacy shape.** Mitigation: KTD-3 fallback + parity observation before authority; recovery paths treat interpreter tasks as legacy until M-D.
- **Merge queue awaiting from inside a graph walk** could deadlock with the executor's own lifecycle. Mitigation: merge seam enqueues and resolves on the queue's completion callback (same contract the legacy handoff uses), never polls.

## Sources
Plan 001 (Deferred Track), `workflow-graph-executor.ts`, `workflow-node-handlers.ts`, `workflow-parity-observer.ts`, `docs/rfcs/FN-5719-decouple-executor-merger.md`, `docs/dag/adr-0001-dag-orchestration.md`, `docs/workflow-steps.md`.
2 changes: 2 additions & 0 deletions packages/cli/skill/fusion/references/engine-tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ These tools are **not** part of the user-invokable extension surface. They are i
| `fn_task_log` | executor, heartbeat | Write significant task log entries | `message` (string), `outcome?` (string) |
| `fn_task_document_write` | triage, executor, heartbeat | Save/update a named task document revision | `key` (string), `content` (string), `author?` (string) |
| `fn_task_document_read` | triage, executor, heartbeat | Read one task document or list all | `key?` (string) |
| `fn_workflow_list` | executor | List the project's custom workflows (read-only built-ins plus user definitions) | none |
| `fn_workflow_select` | executor | Assign a custom workflow to a task (defaults to the current task) | `workflow_id` (string), `task_id?` (string) |
| `fn_memory_search` | triage, executor, heartbeat | Search project memory plus per-agent layered memory snippets | `query` (string), `limit?` (number) |
| `fn_memory_get` | triage, executor, heartbeat | Read a bounded memory file window (including bounded per-agent layered paths) | `path` (string), `startLine?` (number), `lineCount?` (number) |
| `fn_memory_append` | executor, heartbeat (when writable backend enabled) | Append memory notes with explicit scope: `scope="agent"` for private operating context, `scope="project"` for workspace-wide durable knowledge | `scope?` (`project` \| `agent`), `layer` (`long-term` \| `daily`), `content` (string) |
Expand Down
29 changes: 29 additions & 0 deletions packages/core/src/__test-utils__/vitest-setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,35 @@ const BLOCKED_TEST_CLI_PATTERN =

const originalCwd = process.cwd.bind(process);

// Harden git for every test process against host/CI differences that silently
// broke git-worktree tests in CI only (they pass on developer macOS machines):
//
// 1. Pin `git init` to the `main` branch. Git defaults the initial branch to
// `master` unless `init.defaultBranch` is set — true on Linux CI runners
// but usually `main` on dev machines. The gap broke worktree tests that
// assume `main` (e.g. the shared-branch-group reliability suite).
// 2. Never block on an interactive prompt. A Linux CI git can hang forever
// waiting on a credential/editor/pager prompt where a dev's git config
// suppresses it — the suite then runs to the job timeout and is killed
// with no test failure. Disable terminal prompts, the editor, and pagers.
//
// GIT_CONFIG_* applies config to all child git invocations without mutating the
// developer's global config; the prompt/editor/pager env vars are inherited by
// every spawned git. Config entries are appended, not clobbered.
(() => {
const base = Number.parseInt(process.env.GIT_CONFIG_COUNT ?? "0", 10) || 0;
const entries: Array<[string, string]> = [["init.defaultBranch", "main"]];
entries.forEach(([key, value], i) => {
process.env[`GIT_CONFIG_KEY_${base + i}`] = key;
process.env[`GIT_CONFIG_VALUE_${base + i}`] = value;
});
process.env.GIT_CONFIG_COUNT = String(base + entries.length);

process.env.GIT_TERMINAL_PROMPT ??= "0"; // never prompt for credentials
process.env.GIT_EDITOR ??= "true"; // merges/rebases never open an editor
process.env.GIT_PAGER ??= "cat"; // no pager waiting on a TTY
})();

function ensureValidCwd(): string {
try {
return originalCwd();
Expand Down
61 changes: 61 additions & 0 deletions packages/core/src/__tests__/builtin-workflows.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";

import { BUILTIN_WORKFLOWS, getBuiltinWorkflow, isBuiltinWorkflowId } from "../builtin-workflows.js";
import { compileWorkflowToSteps } from "../workflow-compiler.js";
import { parseWorkflowIr } from "../workflow-ir.js";
import { createTaskStoreTestHarness } from "./store-test-helpers.js";

describe("built-in workflows", () => {
it("every built-in has a valid IR and compiles without error", () => {
expect(BUILTIN_WORKFLOWS.length).toBeGreaterThanOrEqual(4);
for (const wf of BUILTIN_WORKFLOWS) {
expect(isBuiltinWorkflowId(wf.id)).toBe(true);
expect(() => parseWorkflowIr(wf.ir)).not.toThrow();
expect(() => compileWorkflowToSteps(wf.ir)).not.toThrow();
}
});

it("includes a coding and a compound-engineering workflow", () => {
expect(getBuiltinWorkflow("builtin:coding")).toBeDefined();
expect(getBuiltinWorkflow("builtin:compound-engineering")).toBeDefined();
});

it("compound-engineering compiles its skill nodes to steps", () => {
const ce = getBuiltinWorkflow("builtin:compound-engineering")!;
const steps = compileWorkflowToSteps(ce.ir);
// plan + code-review (pre-merge) + document (post-merge) — seams are skipped.
expect(steps.length).toBeGreaterThanOrEqual(3);
expect(steps.some((s) => s.name === "Plan")).toBe(true);
});

describe("store integration", () => {
const harness = createTaskStoreTestHarness();
let store: ReturnType<typeof harness.store>;
beforeEach(async () => {
await harness.beforeEach();
store = harness.store();
});
afterEach(async () => {
await harness.afterEach();
});

it("lists built-ins ahead of user workflows and resolves them by id", async () => {
const list = await store.listWorkflowDefinitions();
expect(list[0].id.startsWith("builtin:")).toBe(true);
expect(await store.getWorkflowDefinition("builtin:coding")).toBeDefined();
});

it("rejects editing or deleting a built-in", async () => {
await expect(
store.updateWorkflowDefinition("builtin:coding", { name: "x" }),
).rejects.toThrow(/cannot be edited/i);
await expect(store.deleteWorkflowDefinition("builtin:coding")).rejects.toThrow(/cannot be deleted/i);
});

it("a task can select a built-in workflow", async () => {
const task = await store.createTask({ description: "T", enabledWorkflowSteps: [] });
await store.selectTaskWorkflow(task.id, "builtin:compound-engineering");
expect(store.getTaskWorkflowSelection(task.id)?.workflowId).toBe("builtin:compound-engineering");
});
});
});
Loading
Loading