diff --git a/.changeset/step-inversion-workflow-modelable-steps.md b/.changeset/step-inversion-workflow-modelable-steps.md new file mode 100644 index 0000000000..177dfb813a --- /dev/null +++ b/.changeset/step-inversion-workflow-modelable-steps.md @@ -0,0 +1,13 @@ +--- +"@runfusion/fusion": minor +--- + +Make task steps workflow-modelable, behind the `experimentalFeatures.workflowGraphExecutor` flag (off by default). + +Step policy — how a task breaks into steps, how each step is reviewed, and what happens on revision/rethink — was previously fixed engine law. Workflows can now model it as graph structure: a `foreach` node instantiates a per-step template subgraph once per planned step; a `step-review` node surfaces APPROVE/REVISE/RETHINK/UNAVAILABLE verdicts as outcome edges; `rework` edges (the only legal graph cycles, bounded per instance) route revisions back to a `step-execute` seam, with RETHINK triggering a substrate reset-to-baseline (git reset + session rewind). Steps additionally gain parallel execution: with `mode: parallel` + per-instance worktrees, dependency-satisfied steps (declared via `### Step N (depends: 1,2):` annotations) run concurrently off a common base, with an ordered integration stage that lands branches in step order and routes rebase conflicts to a budget-counted rework outcome. + +Step parsing itself becomes a graph node: `parse-steps(artifact, parser)` reads a workflow-declared task artifact and runs a registry parser (built-in `step-headings`/`json-steps`, or plugin-contributed parsers under `plugin::`) to write the step list, with routable `no-steps`/`parse-error` outcomes. A `code` node runs sandboxed TypeScript (esbuild + child process, clamped timeout, no store handle) for arbitrary computed routing/field logic. Workflows also declare typed custom task fields (string/text/number/boolean/enum/multi-enum/date/url, with enum options and render hints); values are validated through a single store authority and the task UI renders the field schema dynamically (detail form widgets, card badges, and a workflow-editor Fields panel). `fn_task_update` accepts a `custom_fields` patch; `fn_workflow_create/update` accept the new IR constructs. + +The default coding workflow is untouched and byte-identical (the parity oracle); a new built-in stepwise coding workflow demonstrates the full modeling. With the flag off, step execution, review, and the board are exactly as before. + +**ROLLBACK:** This is flag-gated by `experimentalFeatures.workflowGraphExecutor` and additive on disk. Schema migration v108 only ADDS the `workflow_run_step_instances` table and the `tasks.customFields` column (default `'{}'`) — it rewrites no existing rows. The flag is read once and pinned per run, so a mid-flight toggle never switches a task between the legacy and graph step paths; flag-off rollback mid-task converges via the existing fell-back + git-reconcile recovery, because `Task.steps[]` remains the always-git-reconcilable projection sink. Instance rows are per-run prunable and are never the authority over git history. IR using the new node kinds (`foreach`/`step-review`/`parse-steps`/`code`) is v2-only, and `downgradeIrToV1IfPure` already refuses non-v1 node kinds, so the v2 rollback contract from the columns track is preserved automatically. To downgrade to a pre-v108 binary, turn the flag off and let in-flight stepwise tasks settle (or reconcile from git) first; custom-field values on the dropped column are lost on downgrade, so export any needed field values beforehand. diff --git a/CONCEPTS.md b/CONCEPTS.md index a91b860eef..2b51f20872 100644 --- a/CONCEPTS.md +++ b/CONCEPTS.md @@ -159,6 +159,19 @@ The built-in workflow (`builtin:coding`) that reproduces the legacy pipeline ver ### transitionPending A persisted crash-safe marker (`tasks.transitionPending`) written in the same transaction as a column change, recording the post-commit hooks (`hooksRemaining`) that still owe idempotent execution. Cleared once they complete. Recovery reads it exclusively from SQLite (the authoritative store); a crash mid-transition re-runs the idempotent hooks. A throwing or missing hook degrades (audit) and clears its entry — it never strands the card or wedges the task lock. +## Step inversion + +*Behind the `experimentalFeatures.workflowGraphExecutor` flag (orthogonal to `workflowColumns`). With the flag off, and for the Default workflow always, step policy is the legacy engine-owned path (PROMPT.md parsing, in-session review verdicts, RETHINK reset) — unchanged.* + +### Step instance +One runtime expansion of a `foreach` template subgraph, bound to a single planned step (`Task.steps[i]`). Identity is deterministic — `#:` — so resume reconstructs the full instance set from the pinned step count without persisting the expansion itself. Each instance carries its own run-state (current node, rework count, baseline/checkpoint, and in worktree mode its branch and integration status) in `workflow_run_step_instances` (schema v108). The step count is pinned at expansion; a later disagreement with the live step list is a `pin-mismatch` failure, never a silent re-expansion. An instance's lifecycle writes flow through `store.updateStep` so `Task.steps[]` stays the physical projection sink for every existing consumer. + +### parse-steps +A workflow graph node that reads a declared Artifact and runs a registry parser to write the canonical step list (`Task.steps[]`) — the only graph-side writer of steps. Built-in parsers are `step-headings` (the `### Step N:` convention, extracted byte-identically from the legacy regex, including the `(depends: N,M)` annotation) and `json-steps`; plugins contribute parsers under `plugin::`. Parsing failures fail closed to a routable `outcome:parse-error` rather than crashing. A parse-steps node must dominate (precede on all paths) any `foreach(source:"task-steps")`, and running one after a foreach has already expanded trips pin protection (an audited failure) so re-plan loops cannot desynchronize an expanded region. + +### Custom task field +A workflow-declared, typed task field (`string | text | number | boolean | enum | multi-enum | date | url`, with enum options and render hints) whose values live in `tasks.customFields`, keyed by field id. The task model is thereby recast as core fields (title, description) + standard metadata + these workflow-defined fields. Writes pass through a single store authority (`updateTaskCustomFields`) that validates each value against the resolving workflow's schema and returns typed rejections (offending `fieldId` + `code`); agents write them via `fn_task_update`'s `custom_fields` patch. Editing a workflow's fields or switching a task's workflow orphans (never destroys) values for removed or type-incompatible ids — orphans are retained and surfaced under a detail disclosure, excluded from cards. Same id means the same field within a project; there is no cross-workflow shared field namespace. + ## Flagged ambiguities - "Merging" a shared-branch-group Task had been used for both member integration and group promotion — these are distinct steps with independent gating and must not be conflated. diff --git a/docs/architecture.md b/docs/architecture.md index b23c6e640a..7d7e8d8949 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1247,6 +1247,25 @@ Tune sensitivity by adjusting the exported constants in `stalled-review-detector **Graduation.** The flag default-flip is gated by `computeWorkflowColumnsGraduationReport()` (`workflow-parity.ts`; store method `TaskStore.computeWorkflowColumnsGraduationReport`), aggregating: five-invariant dual-observe parity, default-workflow transition parity vs `VALID_TRANSITIONS` (`checkTransitionParity`), and the U6 dual-accept marker/column disagreement count. `ready` is true only when all gates pass over a non-empty observation window. The report is the gate; it does not flip the flag. +### Step inversion: steps as workflow-modelable nodes (`experimentalFeatures.workflowGraphExecutor`) + +The columns/traits track moved *board* policy (transitions, capacity, hold, merge orchestration) onto the substrate/policy line. The **step-inversion** track extends the same inversion to *task steps* and to the *task shape itself*, riding the existing `workflowGraphExecutor` flag (orthogonal to `workflowColumns`). With the flag off — and for the default coding workflow always — step policy stays exactly as it is today (the monolithic `execute` seam, PROMPT.md `### Step N:` parsing, in-session `fn_review_step` verdicts, RETHINK git-reset/session-rewind). The default workflow is the byte-identical parity oracle; inversion is opt-in via custom workflows and a built-in stepwise coding workflow. + +**One new substrate seam pair.** The substrate gains exactly one new capability, expressed as two methods: `runTaskStep(task, stepIndex)` (run exactly one step inside the task's session and observe its `complete Step N` commit) and `resetStepToBaseline(task, stepIndex, baselineSha, checkpointId?)` (the RETHINK mechanics — git reset + session rewind + `updateStep(...,"pending")`). Both delegate to existing code (extracted from `StepSessionExecutor` and the legacy RETHINK block); neither reimplements step physics or authors commits. The substrate owns *how* a step runs and resets; the graph owns *when*. Baseline/checkpoint state, previously fragile in-memory Maps lost on restart, moves into persisted instance run-state (`workflow_run_step_instances`, schema v108). + +**Everything else becomes authored graph structure (policy).** Step granularity, per-step plan/code review, the verdict→action mapping, rework/escalation routing, parallelism, and even the existence of PROMPT.md stop being engine law: + +- A `parse-steps` node reads a workflow-declared **artifact** (PROMPT.md is just the default workflow's declared `step-source` artifact) and runs a registry **parser** (`step-headings`, `json-steps`, or a plugin-contributed parser) to write `Task.steps[]`. It is the only graph-side step-list writer and must dominate any `foreach`. Parsers fail closed to a routable `outcome:parse-error`. +- A `foreach(source:"task-steps")` node instantiates an inline template subgraph once per planned step, with `mode` (sequential/parallel) and `isolation` (shared/worktree) as explicit axes and per-instance run-state pinned + persisted for crash-safe resume. +- A `step-review` node surfaces reviewer verdicts (APPROVE/REVISE/RETHINK/UNAVAILABLE) as outcome edges; `rework` edges (the only legal graph cycles, bounded per instance) route REVISE/RETHINK back to `step-execute`, with RETHINK traversal triggering the reset seam. +- A `code` node runs sandboxed TypeScript (esbuild + child process, clamped timeout, no store handle) for arbitrary computed routing/field logic — the same trust tier as project-local script steps. + +**`Task.steps[]` stays the physical projection sink.** Instance lifecycle transitions write *through* `store.updateStep` with explicit indices (projection-first ordering closes the merge-blocker race), so every existing consumer — the merge-blocker, dashboard/TUI step display, `reconcileStepsFromGitHistory`, lost-work reset — keeps working unchanged. Git reconcile remains authoritative over the instance rows (rows are corrected to match git, never the reverse). + +**Task shape recast.** The task model reduces to core fields (title, description) + standard metadata + **workflow-defined custom fields** (typed, enum options, render hints; values in `tasks.customFields`, validated through one store authority with typed rejections). Field-schema edits orphan rather than destroy values. This round ships the field *system*; recasting existing built-in fields (priority, labels) onto it is a deferred, additive follow-up. + +**Invariant bar.** The five lifecycle invariants (FN-5147 terminal-until-merged, hard-cancel, in-review stall, file-scope, squash) plus the lost-work guard trio remain the non-configurable correctness bar on the stepwise path. The v108 migration is additive; instance rows are prunable; flag-off rollback mid-task converges via the existing fell-back + git-reconcile recovery (the projection is always git-reconcilable). + ## 10) Agent System Fusion has two complementary agent models: diff --git a/docs/workflow-steps.md b/docs/workflow-steps.md index 4818e04dd3..b093b0723f 100644 --- a/docs/workflow-steps.md +++ b/docs/workflow-steps.md @@ -61,6 +61,54 @@ FN-5769 evaluated whether those conventions required a `1.1.0` schema bump and r The `workflowColumns` track introduces **IR v2** (`version: "v2"`), where a workflow additionally defines its own **columns** (`{ id, name, traits: [{ trait, config }] }`), places nodes in columns (`node.column`), and gains `hold`, `split`, and `join` node kinds. Columns become first-class, workflow-defined task state carrying composable **traits** (declarative flags + lifecycle hooks); this generalizes the fixed pipeline + the `gateMode` semantics documented below into per-column trait configuration. v1 graphs still parse and upgrade by synthesizing default-workflow columns. The column/trait model — the trait vocabulary, the substrate/policy line, the transition authority, and the graduation gate — is documented in **`docs/architecture.md` § 9 "Workflow-defined columns & traits"** and the **Concepts** glossary (column, trait, lane, hold node, split/join, default workflow, `transitionPending`). The whole v2 model is gated behind `experimentalFeatures.workflowColumns`; with the flag off, the v1 IR and the quality-gate `WorkflowStep` model below are unchanged. +### Workflow IR v2 — step inversion (foreach, step-review, parse-steps, code) + +The **step-inversion** track makes task *steps* themselves workflow-modelable. Today the engine owns step policy end-to-end (PROMPT.md parsing, per-step review verdicts, RETHINK/REVISE control flow, merge blocking). Step inversion extracts exactly one new substrate capability — *run one step inside a task's session, and reset one step to its baseline* — and exposes everything else as authored graph structure. It is additive to IR v2 and gated by `experimentalFeatures.workflowGraphExecutor`. The default coding workflow is untouched and byte-identical (it keeps its monolithic `execute` seam and is the parity oracle); inversion is opt-in via custom workflows and a new built-in **stepwise coding workflow**. + +#### `parse-steps` node — step list as graph structure + +`parse-steps` reads a declared **artifact** and runs a named **parser** to write the canonical step list (`Task.steps[]`). Config: `{ artifact: , parser: "step-headings" | "json-steps" | "plugin::" }`. + +- Built-in parsers: `step-headings` (the `### Step N:` convention, extracted byte-identically from the legacy regex) and `json-steps` (a `[{ name, depends? }]` JSON document). Plugins register additional parsers under `plugin::`. +- Outcomes: `success`, `outcome:no-steps` (parsed cleanly, zero steps — routable, defaults to success), `outcome:parse-error` (malformed artifact or a throwing/unavailable plugin parser — fail-closed, routable, defaults to failure). A plugin parser never crashes the run. +- It is the **only** graph-side writer of the step list, and **must dominate** (precede on all paths) any `foreach(source:"task-steps")` — a validator rule that prevents merging a task that reached the foreach before steps were parsed. + +#### `foreach` node — a per-step template region + +`foreach` instantiates an inline template subgraph once per planned step. Config: + +``` +{ source: "task-steps", template: { nodes, edges }, + mode?: "sequential" | "parallel", // default sequential + isolation?: "shared" | "worktree", // default: shared (sequential), worktree (parallel) + concurrency?: number, // parallel only, 1..8, default 2 + maxReworkCycles?: number } // default 3, cap 10 +``` + +- The template has exactly one entry and one exit. A `step-execute` seam node is legal **only** inside a foreach template; `step-execute` may not appear in `split` branches. +- Expansion happens when the walk reaches the node; the step count is **pinned** at expansion and persisted (PROMPT.md edits afterward do not re-expand — a `pin-mismatch` failure surfaces if the live step list later disagrees on resume). +- Zero steps → the foreach traverses its `success` edge immediately (no merge blocker, matching today). + +#### Parallel mode & the `(depends:)` annotation + +`mode` and `isolation` are independent axes. `parallel + shared` is rejected (concurrent writers in one worktree are unguardable). Under `worktree` isolation each instance runs in its own worktree/branch off a common base, with an **ordered integration stage** that lands step branches in step order (done iff integrated); a rebase conflict routes `outcome:integration-conflict` (default: rework on the updated base, budget-counted). + +Parallelism is opt-in *per step by the planner*, not asserted by the workflow author. A step depends on the previous step unless its PROMPT.md heading carries a `(depends: N,M)` annotation listing the 1-indexed steps it actually depends on — e.g. `### Step 3 (depends: 1): Title`. An unannotated plan is fully sequential regardless of `mode`. Annotate **conservatively**: only mark a step independent when it genuinely does not read or modify the prior step's output, or heavily-overlapping "independent" steps will loop integrate→conflict→rework until the budget exhausts. + +#### `step-review` node & rework edges + +`step-review` (`{ type: "plan" | "code", model? }`, legal only inside a foreach template) runs the reviewer against the current instance's step and maps the verdict to outcome edges: `outcome:approve` (marks the step done), `outcome:revise` (typically a rework edge — revise in place, no reset), `outcome:rethink` (a rework edge whose traversal first triggers reset-to-baseline: git reset + session rewind + step→pending), `outcome:unavailable` (bounded retry then route). The validator requires `approve` and `revise` routed; `rethink` defaults to the revise target with reset semantics. Verdict authority is single-writer — review nodes inside `split` branches are advisory-only. + +`rework` edges (`edge.kind: "rework"`) are the **only legal cycles**: a loop-back within one foreach instance, bounded by `maxReworkCycles`. Exhaustion emits `outcome:rework-exhausted` (validator requires it routed — escalation, hold, or failure; defaults to failure). Non-rework cycles still throw. + +#### `code` node — sandboxed TypeScript + +`code` (`{ source, timeoutMs? }`, default 30s, cap 300s) runs inline TypeScript (compiled with esbuild, executed in a timeout-bounded child process with cwd = the task worktree) for logic no built-in node covers. The script default-exports `async (ctx) => result` where `ctx = { task, steps, customFields, context, artifacts: { read(key) }, instance? }` (`instance` present inside a foreach template). The returned `{ outcome?, value?, contextPatch?, customFields? }` routes `outcome:` edges, merges `contextPatch` into walk context, and writes `customFields` through the validated field authority. It gets **no store handle**, cannot write the step list, and a throw/timeout/non-zero exit becomes an audited `failure`. Source compile errors are rejected at save time (a dashboard 400 listing the failing node ids). It runs at the same trust tier as existing project-local script steps. + +#### Workflow-defined custom task fields + +Workflows declare typed task fields via IR `fields: [{ id, name, type, required?, default?, options?, render? }]` (`type ∈ string | text | number | boolean | enum | multi-enum | date | url`; `options` for enum kinds; `render.placement ∈ card | detail | detail-section`, `render.widget`, `render.badge`). Values live in `tasks.customFields` and are validated through a single store authority (`updateTaskCustomFields`) with typed rejections (offending `fieldId` + `code`). Editing or switching a workflow **orphans** (never destroys) values for removed/incompatible fields — orphans are retained and shown under a detail disclosure. The task UI renders the schema dynamically (detail-form widgets by type, up to 3 card badges by placement). Agents read/write fields via `fn_task_update`'s `custom_fields` patch; authors set them via `fn_workflow_create/update`. Field values are surfaced in task/session context. + ## What They Are A workflow step is a reusable check (AI prompt or script) that can be enabled on tasks. diff --git a/packages/cli/skill/fusion/references/engine-tools.md b/packages/cli/skill/fusion/references/engine-tools.md index bf1ce2eddc..08ee7968ea 100644 --- a/packages/cli/skill/fusion/references/engine-tools.md +++ b/packages/cli/skill/fusion/references/engine-tools.md @@ -17,8 +17,8 @@ These tools are **not** part of the user-invokable extension surface. They are i | `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_workflow_create` | executor | Create a custom workflow definition from a graph IR (validated server-side) | `name` (string), `description?` (string), `ir` (object), `layout?` (object) | -| `fn_workflow_update` | executor | Update a custom workflow definition's name/description/ir/layout (built-ins cannot be edited) | `workflow_id` (string), `name?` (string), `description?` (string), `ir?` (object), `layout?` (object), `rehome_to?` (string) | +| `fn_workflow_create` | executor | Create a custom workflow definition from a graph IR (validated server-side). v2 IR supports step-inversion constructs: `parse-steps`, `foreach` (mode/isolation/concurrency/maxReworkCycles), `step-execute`, `step-review`, `code` nodes, `rework` edges, plus `artifacts` and custom `fields` declarations | `name` (string), `description?` (string), `ir` (object), `layout?` (object) | +| `fn_workflow_update` | executor | Update a custom workflow definition's name/description/ir/layout (built-ins cannot be edited; same step-inversion IR constructs as create; editing `fields` orphans rather than destroys existing task values) | `workflow_id` (string), `name?` (string), `description?` (string), `ir?` (object), `layout?` (object), `rehome_to?` (string) | | `fn_workflow_delete` | executor | Delete a custom workflow definition (built-ins cannot be deleted); selecting tasks are re-homed to the default workflow's entry column | `workflow_id` (string) | | `fn_task_promote` | executor | Promote a held task out of a manual-release hold column (defaults to the current task) | `task_id?` (string) | | `fn_trait_list` | executor | List the registered column trait catalog (built-in and plugin traits) | none | @@ -58,7 +58,7 @@ Note: step-session execution (`step-session-executor.ts`) reuses executor coordi | Tool | Purpose | Parameters | |---|---|---| -| `fn_task_update` | Update a spec step status (`pending`/`in-progress`/`done`/`skipped`) | `step` (number), `status` (enum) | +| `fn_task_update` | Update a spec step status (`pending`/`in-progress`/`done`/`skipped`), task dependencies, and/or workflow-defined custom field values | `step?` (number, 1-indexed), `status?` (enum), `dependencies?` (string[]), `custom_fields?` (object keyed by field id; validated against the workflow field schema, `null` clears a field) | | `fn_task_add_dep` | Add a dependency to current task (confirmation-gated) | `task_id` (string), `confirm?` (boolean) | | `fn_task_done` | Mark task complete and optionally store summary | `summary?` (string) | | `fn_review_step` | Spawn step plan/code reviewer | `step` (number), `type` (`plan` \| `code`), `step_name` (string), `baseline?` (string) | diff --git a/packages/dashboard/src/__tests__/workflow-routes.test.ts b/packages/dashboard/src/__tests__/workflow-routes.test.ts index b10d7cc769..9e1b750332 100644 --- a/packages/dashboard/src/__tests__/workflow-routes.test.ts +++ b/packages/dashboard/src/__tests__/workflow-routes.test.ts @@ -158,6 +158,65 @@ describe("workflow routes (U4)", () => { expect(res.status).toBe(400); }); + // ── Handoff (KTD-15): save-time code-node compile validation ──────────────── + /** A v2 IR with a single `code` node whose `source` core accepts (non-empty, + * under the size cap) but which esbuild may or may not compile. */ + function codeNodeIr(source: string): WorkflowIr { + return { + version: "v2", + name: "code-wf", + columns: [{ id: "intake-col", name: "Intake", traits: [{ trait: "intake" }] }], + nodes: [ + { id: "start", kind: "start", column: "intake-col" }, + { id: "calc", kind: "code", column: "intake-col", config: { source } }, + { id: "end", kind: "end", column: "intake-col" }, + ], + edges: [ + { from: "start", to: "calc", condition: "success" }, + { from: "calc", to: "end", condition: "success" }, + ], + } as WorkflowIr; + } + + it("POST /workflows rejects an uncompilable code node with 400 + per-node errors", async () => { + // Valid TS (compiles) is accepted. + const ok = await post("/api/workflows", { + name: "GoodCode", + ir: codeNodeIr("export default async (ctx) => ({ outcome: 'success' });"), + }); + expect(ok.status).toBe(201); + + // A syntax error passes core's non-empty source check but fails esbuild. + const bad = await post("/api/workflows", { + name: "BadCode", + ir: codeNodeIr("export default async (ctx) => { return ((( }"), + }); + expect(bad.status).toBe(400); + const details = (bad.body as { details?: { codeNodeErrors?: Array<{ nodeId: string; error: string }> } }).details; + expect(Array.isArray(details?.codeNodeErrors)).toBe(true); + expect(details?.codeNodeErrors?.some((e) => e.nodeId === "calc")).toBe(true); + }); + + it("PATCH /workflows/:id rejects an uncompilable code node with 400", async () => { + const created = await post("/api/workflows", { + name: "EditableCode", + ir: codeNodeIr("export default async (ctx) => ({});"), + }); + expect(created.status).toBe(201); + const id = (created.body as { id: string }).id; + + const res = await request( + app, + "PATCH", + `/api/workflows/${id}`, + JSON.stringify({ ir: codeNodeIr("export default async (ctx) => { return ((( }") }), + { "content-type": "application/json" }, + ); + expect(res.status).toBe(400); + const details = (res.body as { details?: { codeNodeErrors?: unknown[] } }).details; + expect((details?.codeNodeErrors?.length ?? 0)).toBeGreaterThan(0); + }); + it("GET /workflows lists created workflows (ahead of read-only built-ins)", async () => { await post("/api/workflows", { name: "A", ir: linearIr() }); const res = await get("/api/workflows"); diff --git a/packages/dashboard/src/routes/register-workflow-routes.ts b/packages/dashboard/src/routes/register-workflow-routes.ts index d2628248a9..fcca66624b 100644 --- a/packages/dashboard/src/routes/register-workflow-routes.ts +++ b/packages/dashboard/src/routes/register-workflow-routes.ts @@ -1,5 +1,6 @@ -import type { WorkflowIr } from "@fusion/core"; +import type { WorkflowIr, WorkflowIrNode } from "@fusion/core"; import { ColumnTraitValidationError, OccupiedColumnsError, InvalidRehomeTargetError, WorkflowCompileError, WorkflowIrError, compileWorkflowToSteps, listTraits } from "@fusion/core"; +import { validateCodeNodeSources } from "@fusion/engine"; import { ApiError, badRequest, conflict, notFound } from "../api-error.js"; import { emitWorkflowSseEvent } from "../sse.js"; import type { ApiRoutesContext } from "./types.js"; @@ -20,6 +21,26 @@ export function registerWorkflowRoutes(ctx: ApiRoutesContext): void { return ir as WorkflowIr; } + /** + * Save-time `code` node compile validation (KTD-15 handoff). Runs the engine's + * esbuild transform over every `code` node's source (including nodes nested in + * foreach templates) and throws a 400 listing the failing nodes BEFORE the IR is + * persisted, so a workflow with an uncompilable code node can never be saved and + * deferred to an execution-time failure. A null/non-object IR is left to the + * store's own validator (this only inspects node arrays it can read). + */ + async function assertCodeNodesCompile(ir: unknown): Promise { + const nodes = (ir as { nodes?: unknown })?.nodes; + if (!Array.isArray(nodes)) return; + const failures = await validateCodeNodeSources({ nodes: nodes as WorkflowIrNode[] }); + if (failures.length > 0) { + throw badRequest( + `Workflow has ${failures.length} code node(s) that failed to compile`, + { codeNodeErrors: failures }, + ); + } + } + // GET /api/traits — trait catalog for the node editor's trait picker (U10). // Returns the registry's listTraits() (built-ins + any registered plugin // traits): id, name, description, flags, hook descriptors, and config schema. @@ -66,6 +87,7 @@ export function registerWorkflowRoutes(ctx: ApiRoutesContext): void { throw badRequest("name is required"); } const ir = requireIr(req.body); + await assertCodeNodesCompile(ir); const created = await store.createWorkflowDefinition({ name, description, ir, layout }); emitWorkflowSseEvent("workflow:created", created, projectId); res.status(201).json(created); @@ -108,6 +130,9 @@ export function registerWorkflowRoutes(ctx: ApiRoutesContext): void { if (rehomeTo !== undefined && typeof rehomeTo !== "string") { throw badRequest("rehomeTo must be a string column id"); } + if (ir !== undefined) { + await assertCodeNodesCompile(ir); + } const updated = await store.updateWorkflowDefinition(req.params.id, { name, description, diff --git a/packages/plugin-sdk/src/index.ts b/packages/plugin-sdk/src/index.ts index 686fc89c09..d9e87b586e 100644 --- a/packages/plugin-sdk/src/index.ts +++ b/packages/plugin-sdk/src/index.ts @@ -96,6 +96,50 @@ export type { PluginInstallation, } from "@fusion/core"; +// ── Step-inversion IR types (type-only) ────────────────────────────────────── +// TYPE-ONLY re-exports of the workflow-modelable step constructs (KTD-3/12/13/15) +// so plugin authors can author/validate workflow IR and step parsers against the +// canonical shapes. These are erased at build time, so the standalone plugin-sdk +// artifact carries no @fusion runtime specifiers (see cli plugin-sdk-export test). +export type { + // Graph IR primitives. + WorkflowIr, + WorkflowIrV1, + WorkflowIrV2, + WorkflowIrNode, + WorkflowIrEdge, + WorkflowIrNodeKind, + // Foreach / artifacts / custom fields (step inversion). + WorkflowForeachConfig, + WorkflowIrArtifact, + WorkflowFieldDefinition, + WorkflowFieldType, + WorkflowFieldOption, + WorkflowFieldRender, + // Step-parser contract. + StepParser, + StepParseResult, + ParsedStep, +} from "@fusion/core"; + +import type { StepParseResult } from "@fusion/core"; + +/** + * A plugin's step-parser contribution (KTD-12). A plugin's runtime loader returns + * these from its `getPluginStepParsers` getter; the engine wraps each fail-closed + * and registers it under `plugin::`. `parse` is SYNCHRONOUS + * (project-local trust tier) and may throw on malformed input — a throw maps to a + * routable `outcome:parse-error`. + * + * Structurally identical to the engine-side `PluginStepParserContribution` the + * plugin runner consumes; defined here so plugin authors do not depend on the + * engine package (the SDK depends on @fusion/core only). + */ +export interface PluginStepParserContribution { + parserId: string; + parse: (content: string) => StepParseResult; +} + import type { FusionPlugin } from "@fusion/core"; // NOTE (U8): trait-contribution VALIDATION lives in @fusion/core