feat(sdk,dashboard,docs): U9 — save-time code validation route, SDK type exports, skill doc, step-inversion docs + changeset
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
13
.changeset/step-inversion-workflow-modelable-steps.md
Normal file
13
.changeset/step-inversion-workflow-modelable-steps.md
Normal file
@@ -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:<id>:<parser>`) 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.
|
||||
13
CONCEPTS.md
13
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 — `<foreachNodeId>#<stepIndex>:<templateNodeId>` — 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:<pluginId>:<parserId>`. 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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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: <key>, parser: "step-headings" | "json-steps" | "plugin:<id>:<parser>" }`.
|
||||
|
||||
- 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:<pluginId>:<parserId>`.
|
||||
- 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:<value>` 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.
|
||||
|
||||
@@ -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) |
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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<void> {
|
||||
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,
|
||||
|
||||
@@ -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:<pluginId>:<parserId>`. `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
|
||||
|
||||
Reference in New Issue
Block a user