diff --git a/packages/engine/package.json b/packages/engine/package.json index 7282a2a2e0..61df34611a 100644 --- a/packages/engine/package.json +++ b/packages/engine/package.json @@ -42,6 +42,7 @@ "@earendil-works/pi-ai": "^0.78.0", "@earendil-works/pi-coding-agent": "^0.78.0", "cron-parser": "^5.5.0", + "esbuild": "^0.25.12", "proper-lockfile": "^4.1.2", "typebox": "^1.0.0" }, diff --git a/packages/engine/src/__tests__/code-node.test.ts b/packages/engine/src/__tests__/code-node.test.ts new file mode 100644 index 0000000000..31820c35fe --- /dev/null +++ b/packages/engine/src/__tests__/code-node.test.ts @@ -0,0 +1,256 @@ +/** + * U14 (KTD-15) — code node: esbuild compile, child-process execution, the + * harness contract, result→graph mapping, and failure modes. + * + * The child-process spawning tests use tiny inline sources and the real node + * binary; they are kept to a small focused set (happy/throw/timeout) so the + * suite stays fast. The result-mapping and customFields/contextPatch/instance + * scenarios use the injected `spawnRunner` seam (no spawn) for speed + hermetic + * determinism. + */ +import { describe, expect, it, vi } from "vitest"; +import type { CustomFieldRejection, TaskDetail, WorkflowIrNode } from "@fusion/core"; + +import { + runCodeNode, + createCodeNodeRunner, + compileCodeNodeSource, + validateCodeNodeSources, + resolveCodeNodeTimeout, + CodeNodeError, + CODE_NODE_MAX_SOURCE_BYTES, + CODE_NODE_OUTPUT_CAP_BYTES, + type CodeNodeResult, +} from "../code-node-runner.js"; +import { FOREACH_ACTIVE_CONTEXT_KEY } from "../workflow-node-handlers.js"; + +const RESULT_BEGIN = "__FUSION_CODE_NODE_RESULT_BEGIN__"; +const RESULT_END = "__FUSION_CODE_NODE_RESULT_END__"; + +function task(over: Partial = {}): TaskDetail { + return { + id: "FN-CODE", + title: "T", + description: "d", + column: "work", + steps: [], + customFields: {}, + ...over, + } as unknown as TaskDetail; +} + +function codeNode(source: string, timeoutMs?: number): WorkflowIrNode { + return { id: "code1", kind: "code", config: { source, ...(timeoutMs ? { timeoutMs } : {}) } }; +} + +/** A spawnRunner that frames a fixed result, so mapping logic is testable + * without spawning a child. */ +function fakeSpawn(result: unknown, stderr = "") { + return async () => ({ + stdout: `${RESULT_BEGIN}${JSON.stringify(result)}${RESULT_END}`, + stderr, + }); +} + +function runnerDeps(over: Partial[0]> = {}) { + const writes: Array> = []; + const audits: Array<{ reason: string; detail: string }> = []; + const deps = { + resolveCwd: () => process.cwd(), + readArtifacts: () => ({ "PROMPT.md": "hello" }), + writeCustomFields: async (_t: TaskDetail, patch: Record) => { + writes.push(patch); + return { ok: true as const }; + }, + audit: (reason: string, detail: string) => audits.push({ reason, detail }), + ...over, + }; + return { deps, writes, audits }; +} + +describe("compileCodeNodeSource (U14)", () => { + it("compiles valid TS", async () => { + const out = await compileCodeNodeSource("export default async (ctx: any) => ({ value: ctx.task.id });"); + expect(out).toContain("default"); + }); + + it("throws compile-error on a syntax error", async () => { + await expect(compileCodeNodeSource("export default async (ctx => {")).rejects.toMatchObject({ + reason: "compile-error", + }); + }); + + it("rejects an over-size source defensively", async () => { + const huge = `export default async () => ({});//${"x".repeat(CODE_NODE_MAX_SOURCE_BYTES)}`; + await expect(compileCodeNodeSource(huge)).rejects.toMatchObject({ reason: "source-too-large" }); + }); +}); + +describe("resolveCodeNodeTimeout (U14)", () => { + it("defaults and clamps", () => { + expect(resolveCodeNodeTimeout(undefined)).toBe(30_000); + expect(resolveCodeNodeTimeout(500)).toBe(1000); + expect(resolveCodeNodeTimeout(999_999)).toBe(300_000); + expect(resolveCodeNodeTimeout(45_000)).toBe(45_000); + }); +}); + +describe("validateCodeNodeSources (U14, save-time helper)", () => { + it("returns failures for uncompilable code nodes incl. inside foreach templates", async () => { + const innerBad: WorkflowIrNode = { id: "inner-bad", kind: "code", config: { source: "syntax ( error" } }; + const ir = { + nodes: [ + { id: "ok", kind: "code", config: { source: "export default async () => ({});" } } as WorkflowIrNode, + { id: "fe", kind: "foreach", config: { template: { nodes: [innerBad], edges: [] } } } as WorkflowIrNode, + ], + }; + const failures = await validateCodeNodeSources(ir); + expect(failures).toHaveLength(1); + expect(failures[0].nodeId).toBe("inner-bad"); + }); + + it("returns empty for all-valid code", async () => { + const ir = { nodes: [codeNode("export default async () => ({ outcome: 'ok' });")] }; + expect(await validateCodeNodeSources(ir)).toEqual([]); + }); +}); + +describe("createCodeNodeRunner result mapping (U14, seam-injected)", () => { + it("happy path: returns value + routes success", async () => { + const { deps } = runnerDeps({ spawnRunner: fakeSpawn({ value: "computed" }) }); + const runner = createCodeNodeRunner(deps); + const result = await runner(codeNode("x"), task(), {}); + expect(result.outcome).toBe("success"); + expect(result.value).toBe("computed"); + }); + + it("outcome string routes outcome:", async () => { + const { deps } = runnerDeps({ spawnRunner: fakeSpawn({ outcome: "needs-review" }) }); + const runner = createCodeNodeRunner(deps); + const result = await runner(codeNode("x"), task(), {}); + expect(result.outcome).toBe("success"); + expect(result.value).toBe("needs-review"); + }); + + it("contextPatch is merged into the result", async () => { + const { deps } = runnerDeps({ spawnRunner: fakeSpawn({ contextPatch: { foo: 1, bar: "b" } }) }); + const runner = createCodeNodeRunner(deps); + const result = await runner(codeNode("x"), task(), {}); + expect(result.contextPatch).toMatchObject({ foo: 1, bar: "b" }); + }); + + it("customFields patch goes through the authority", async () => { + const { deps, writes } = runnerDeps({ spawnRunner: fakeSpawn({ customFields: { priority: "high" } }) }); + const runner = createCodeNodeRunner(deps); + const result = await runner(codeNode("x"), task(), {}); + expect(result.outcome).toBe("success"); + expect(writes).toEqual([{ priority: "high" }]); + }); + + it("customFields typed rejection → node failure surfacing the rejection", async () => { + const rejection: CustomFieldRejection = { + code: "type-mismatch", + fieldId: "priority", + detail: "expected number", + }; + const { deps, audits } = runnerDeps({ + spawnRunner: fakeSpawn({ customFields: { priority: "nope" } }), + writeCustomFields: async () => ({ ok: false as const, rejection }), + }); + const runner = createCodeNodeRunner(deps); + const result = await runner(codeNode("x"), task(), {}); + expect(result.outcome).toBe("failure"); + expect(result.value).toBe("custom-field-rejected"); + expect(result.contextPatch?.["node:code1:rejection"]).toContain("type-mismatch"); + expect(audits.some((a) => a.reason === "custom-field-rejected")).toBe(true); + }); + + it("instance (foreach:active) is surfaced to the ctx assembly", async () => { + let receivedCtx: unknown; + const { deps } = runnerDeps({ + spawnRunner: async ({ stdin }) => { + receivedCtx = JSON.parse(stdin); + return { stdout: `${RESULT_BEGIN}{}${RESULT_END}`, stderr: "" }; + }, + }); + const runner = createCodeNodeRunner(deps); + const active = { foreachNodeId: "fe", stepIndex: 2, instanceId: "fe#2" }; + await runner(codeNode("x"), task(), { [FOREACH_ACTIVE_CONTEXT_KEY]: active, other: "ctx" }); + expect((receivedCtx as { instance?: { stepIndex?: number } }).instance?.stepIndex).toBe(2); + // The reserved key is stripped from the generic context snapshot. + expect((receivedCtx as { context?: Record }).context).toEqual({ other: "ctx" }); + }); + + it("bad result (no sentinels) → failure", async () => { + const { deps, audits } = runnerDeps({ + spawnRunner: async () => ({ stdout: "garbage", stderr: "" }), + }); + const runner = createCodeNodeRunner(deps); + const result = await runner(codeNode("x"), task(), {}); + expect(result.outcome).toBe("failure"); + expect(result.value).toBe("bad-result"); + expect(audits.some((a) => a.reason === "bad-result")).toBe(true); + }); + + it("captures + caps stderr from a thrown child into the node result", async () => { + const big = "E".repeat(CODE_NODE_OUTPUT_CAP_BYTES * 2); + const { deps } = runnerDeps({ + spawnRunner: async () => { + const err = Object.assign(new Error("child died"), { code: 7, stderr: big }); + throw err; + }, + }); + const runner = createCodeNodeRunner(deps); + const result = await runner(codeNode("x"), task(), {}); + expect(result.outcome).toBe("failure"); + expect(result.value).toBe("runtime-throw"); + const captured = String(result.contextPatch?.["node:code1:stderr"]); + expect(captured.length).toBeLessThan(big.length); + expect(captured).toContain("[truncated]"); + }); +}); + +describe("runCodeNode real child process (U14, hermetic)", () => { + it("happy path executes the harness and returns the parsed result", async () => { + const result: CodeNodeResult = await runCodeNode({ + source: "export default async (ctx) => ({ value: ctx.task.id, outcome: undefined });", + cwd: process.cwd(), + ctx: { task: { id: "FN-CODE", title: "T", steps: [], customFields: {} }, context: {}, artifacts: {} }, + }); + expect(result.value).toBe("FN-CODE"); + }); + + it("artifacts.read(key) returns pre-read content", async () => { + const result = await runCodeNode({ + source: "export default async (ctx) => ({ value: ctx.artifacts.read('PROMPT.md') });", + cwd: process.cwd(), + ctx: { + task: { id: "x", title: "T", steps: [], customFields: {} }, + context: {}, + artifacts: { "PROMPT.md": "the-prompt" }, + }, + }); + expect(result.value).toBe("the-prompt"); + }); + + it("a runtime throw fails with stderr captured", async () => { + await expect( + runCodeNode({ + source: "export default async () => { throw new Error('boom-runtime'); };", + cwd: process.cwd(), + ctx: { task: { id: "x", title: "T", steps: [], customFields: {} }, context: {}, artifacts: {} }, + }), + ).rejects.toMatchObject({ reason: "runtime-throw" }); + }); + + it("timeout kills the child and fails with reason timeout", async () => { + await expect( + runCodeNode({ + source: "export default async () => { while (true) {} };", + timeoutMs: 1000, + cwd: process.cwd(), + ctx: { task: { id: "x", title: "T", steps: [], customFields: {} }, context: {}, artifacts: {} }, + }), + ).rejects.toMatchObject({ reason: "timeout" }); + }, 10_000); +}); diff --git a/packages/engine/src/__tests__/workflow-parse-steps.test.ts b/packages/engine/src/__tests__/workflow-parse-steps.test.ts new file mode 100644 index 0000000000..5e8bb0117e --- /dev/null +++ b/packages/engine/src/__tests__/workflow-parse-steps.test.ts @@ -0,0 +1,235 @@ +/** + * U12 (KTD-12) — parse-steps node handler, parser registry resolution, pin + * protection, and plugin-parser fail-closed posture. + */ +import { describe, expect, it, vi, beforeEach } from "vitest"; +import type { TaskDetail, TaskStep, WorkflowIr } from "@fusion/core"; +import { getStepParserRegistry, __resetStepParserRegistryForTests } from "@fusion/core"; + +import { WorkflowGraphExecutor } from "../workflow-graph-executor.js"; +import { createNoopLegacySeams, type ParseStepsHandlerDeps } from "../workflow-node-handlers.js"; +import { + registerPluginStepParsers, + unregisterPluginStepParsers, +} from "../plugin-parser-adapter.js"; + +const settingsOn = () => ({ experimentalFeatures: { workflowGraphExecutor: true } }); + +function task(): TaskDetail { + return { id: "FN-PARSE", title: "t", steps: [] as TaskStep[] } as unknown as TaskDetail; +} + +/** start → parse → end, with optional outcome edges off the parse node. */ +function parseIr(parser: string, artifact?: string, parseEdges?: WorkflowIr["edges"], extraNodes: WorkflowIr["nodes"] = []): WorkflowIr { + return { + version: "v2", + name: "parse-test", + columns: [{ id: "work", name: "Work", traits: [] }], + artifacts: artifact && artifact !== "PROMPT.md" ? [{ key: artifact }] : undefined, + nodes: [ + { id: "start", kind: "start" }, + { id: "parse", kind: "parse-steps", config: { artifact: artifact ?? "PROMPT.md", parser } }, + { id: "end", kind: "end" }, + ...extraNodes, + ], + edges: [ + { from: "start", to: "parse" }, + { from: "parse", to: "end", condition: "success" }, + ...(parseEdges ?? []), + ], + } as WorkflowIr; +} + +function makeDeps(over: Partial = {}): { + deps: ParseStepsHandlerDeps; + written: TaskStep[][]; + audits: Array<{ reason: string; detail: string }>; +} { + const written: TaskStep[][] = []; + const audits: Array<{ reason: string; detail: string }> = []; + const deps: ParseStepsHandlerDeps = { + readArtifact: async () => "### Step 1: do a\n### Step 2: do b", + writeSteps: async (_t, steps) => { + written.push(steps); + }, + audit: (reason, detail) => audits.push({ reason, detail }), + ...over, + }; + return { deps, written, audits }; +} + +async function runParse(ir: WorkflowIr, deps: ParseStepsHandlerDeps) { + const exec = new WorkflowGraphExecutor({ seams: createNoopLegacySeams(), parseStepsDeps: deps }); + return exec.run(task(), settingsOn(), ir); +} + +describe("parse-steps node handler (U12, KTD-12)", () => { + beforeEach(() => { + __resetStepParserRegistryForTests(); + }); + + it("registry resolution: step-headings parses and writes steps with statuses pending", async () => { + const { deps, written } = makeDeps(); + const result = await runParse(parseIr("step-headings"), deps); + expect(result.outcome).toBe("success"); + expect(written).toHaveLength(1); + expect(written[0]).toEqual([ + { name: "do a", status: "pending" }, + { name: "do b", status: "pending" }, + ]); + }); + + it("preserves dependsOn from the headings (depends:) annotation", async () => { + const { deps, written } = makeDeps({ + readArtifact: async () => "### Step 1: a\n### Step 2 (depends: 1): b", + }); + const result = await runParse(parseIr("step-headings"), deps); + expect(result.outcome).toBe("success"); + expect(written[0]).toEqual([ + { name: "a", status: "pending" }, + { name: "b", status: "pending", dependsOn: [0] }, + ]); + }); + + it("json-steps parser writes structured steps", async () => { + const { deps, written } = makeDeps({ + readArtifact: async () => JSON.stringify([{ name: "x" }, { name: "y", depends: [1] }]), + }); + const result = await runParse(parseIr("json-steps"), deps); + expect(result.outcome).toBe("success"); + expect(written[0]).toEqual([ + { name: "x", status: "pending" }, + { name: "y", status: "pending", dependsOn: [0] }, + ]); + }); + + it("unknown parser → parse-error (audited), no write", async () => { + const { deps, written, audits } = makeDeps(); + // Route outcome:parse-error so the run does not just propagate failure off end. + const ir = parseIr("does-not-exist", undefined, [ + { from: "parse", to: "end", condition: "outcome:parse-error" }, + ]); + const result = await runParse(ir, deps); + // The parse node fails; with the parse-error edge routed to end, the run + // surfaces the parse node's own failure outcome. + expect(written).toHaveLength(0); + expect(audits.some((a) => a.reason === "parse-error")).toBe(true); + expect(result.context["node:parse:value"]).toBe("parse-error"); + }); + + it("parser throw (malformed artifact) → parse-error, never crashes", async () => { + const { deps, audits } = makeDeps({ + readArtifact: async () => "not json at all", + }); + const result = await runParse(parseIr("json-steps"), deps); + expect(result.executed).toBe(true); + expect(result.context["node:parse:value"]).toBe("parse-error"); + expect(audits.some((a) => a.reason === "parse-error")).toBe(true); + }); + + it("missing artifact (undefined content) → parse-error", async () => { + const { deps, audits } = makeDeps({ readArtifact: async () => undefined }); + const result = await runParse(parseIr("step-headings"), deps); + expect(result.context["node:parse:value"]).toBe("parse-error"); + expect(audits.some((a) => a.reason === "parse-error")).toBe(true); + }); + + it("clean empty parse → no-steps outcome (success), writes empty list", async () => { + const { deps, written } = makeDeps({ readArtifact: async () => "no headings here" }); + const ir = parseIr("step-headings", undefined, [ + { from: "parse", to: "end", condition: "outcome:no-steps" }, + ]); + const result = await runParse(ir, deps); + expect(result.outcome).toBe("success"); + expect(result.context["node:parse:value"]).toBe("no-steps"); + expect(written).toEqual([[]]); + }); + + it("pin protection: parse after a foreach expanded → pin-mismatch failure, no write", async () => { + const { deps, written, audits } = makeDeps({ + hasExpandedForeach: async () => true, + }); + const ir = parseIr("step-headings", undefined, [ + { from: "parse", to: "end", condition: "outcome:pin-mismatch" }, + ]); + const result = await runParse(ir, deps); + expect(written).toHaveLength(0); + expect(result.context["node:parse:value"]).toBe("pin-mismatch"); + expect(audits.some((a) => a.reason === "pin-mismatch")).toBe(true); + }); + + it("default workflow parity: registry step-headings == direct parseStepHeadings call", async () => { + const { parseStepHeadings } = await import("@fusion/core"); + const content = "### Step 1: alpha\n### Step 2 (depends: 1): beta"; + const direct = parseStepHeadings(content); + const viaRegistry = getStepParserRegistry().getParser("step-headings")!.parse(content); + expect(viaRegistry.steps.map((s) => ({ name: s.name, dependsOn: s.dependsOn }))).toEqual( + direct.map((s) => ({ name: s.name, dependsOn: s.dependsOn })), + ); + }); +}); + +describe("plugin step-parser fail-closed (U12, KTD-12)", () => { + beforeEach(() => { + __resetStepParserRegistryForTests(); + }); + + it("happy path: a registered plugin parser resolves and writes steps", async () => { + registerPluginStepParsers({ + pluginId: "acme", + contributions: [{ parserId: "yaml", parse: () => ({ steps: [{ name: "from-plugin" }] }) }], + }); + const { deps, written } = makeDeps({ readArtifact: async () => "ignored" }); + const result = await runParse(parseIr("plugin:acme:yaml"), deps); + expect(result.outcome).toBe("success"); + expect(written[0]).toEqual([{ name: "from-plugin", status: "pending" }]); + unregisterPluginStepParsers("acme", ["yaml"]); + }); + + it("a throwing plugin parser maps to parse-error (fail-closed, audited), never crashes", async () => { + registerPluginStepParsers({ + pluginId: "acme", + contributions: [ + { + parserId: "boom", + parse: () => { + throw new Error("kaboom"); + }, + }, + ], + }); + const { deps, audits } = makeDeps({ readArtifact: async () => "x" }); + const ir = parseIr("plugin:acme:boom", undefined, [ + { from: "parse", to: "end", condition: "outcome:parse-error" }, + ]); + const result = await runParse(ir, deps); + expect(result.context["node:parse:value"]).toBe("parse-error"); + expect(audits.some((a) => a.reason === "parse-error")).toBe(true); + unregisterPluginStepParsers("acme", ["boom"]); + }); + + it("a plugin parser returning a bad result maps to parse-error", async () => { + registerPluginStepParsers({ + pluginId: "acme", + contributions: [{ parserId: "bad", parse: () => ({ steps: [{} as { name: string }] }) }], + }); + const { deps, audits } = makeDeps({ readArtifact: async () => "x" }); + const result = await runParse(parseIr("plugin:acme:bad"), deps); + expect(audits.some((a) => a.reason === "parse-error")).toBe(true); + expect(result.context["node:parse:value"]).toBe("parse-error"); + unregisterPluginStepParsers("acme", ["bad"]); + }); + + it("registry rejects a non-namespaced plugin parser id", () => { + expect(() => + registerPluginStepParsers({ + pluginId: "acme", + // pluginParserRegistryId always namespaces, so registration succeeds — + // verify the resulting id is correctly namespaced. + contributions: [{ parserId: "ok", parse: () => ({ steps: [] }) }], + }), + ).not.toThrow(); + expect(getStepParserRegistry().has("plugin:acme:ok")).toBe(true); + unregisterPluginStepParsers("acme", ["ok"]); + }); +}); diff --git a/packages/engine/src/code-node-runner.ts b/packages/engine/src/code-node-runner.ts new file mode 100644 index 0000000000..44652ccab7 --- /dev/null +++ b/packages/engine/src/code-node-runner.ts @@ -0,0 +1,535 @@ +/** + * Code-node runner (U14, KTD-15). + * + * Executes a workflow `code` node: arbitrary user-authored TypeScript that runs + * as a general computation escape hatch (derive a field, compute routing data, + * call an internal API). The source is: + * + * 1. compiled in-memory with esbuild (TS → ESM, no bundling, no resolution); + * 2. written to a temp module in the OS temp dir; + * 3. executed in a CHILD `node` PROCESS with `cwd = task worktree`, a minimal + * env, and the serialized `ctx` delivered on stdin; + * 4. the child default-exports `async (ctx) => result`; its JSON result is + * written to stdout between sentinels and parsed back here. + * + * Harness contract: + * ctx = { + * task: { id, title, description, column, steps, customFields }, + * context: , + * artifacts: { read(key): string | undefined }, // pre-read, plain object + * instance?: , + * } + * result = { outcome?, value?, contextPatch?, customFields? } + * - outcome string → routes outcome:; absent → success + * - contextPatch → merged into the walk context + * - customFields → written through the U11 validation authority by the + * handler wiring (NOT here — the runner has no store) + * + * Failure posture (fail-closed, audited): throw / timeout / non-zero exit / + * compile error → a thrown {@link CodeNodeError} carrying captured stderr + * (capped). The handler maps it to a `failure` node outcome with the error in + * the audit/node result. The runner never gets a store handle, engine + * internals, or the step-list write path (KTD-15 boundaries). + * + * DEVIATION (documented per the plan): artifacts are PRE-READ into a plain + * `ctx.artifacts` object (the script calls `artifacts.read(key)` synchronously + * against the pre-read map) rather than an RPC-over-stdio bridge. This is the + * plan's explicitly-sanctioned "SIMPLER" path — the child process needs no live + * channel back to the engine, keeping the boundary a one-shot stdin→stdout call. + */ + +import { execFile } from "node:child_process"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { transformSync } from "esbuild"; +import type { CustomFieldRejection, TaskDetail, WorkflowIrNode } from "@fusion/core"; + +import type { WorkflowNodeResult } from "./workflow-graph-executor.js"; +import { FOREACH_ACTIVE_CONTEXT_KEY, type CodeNodeRunner } from "./workflow-node-handlers.js"; + +/** Default code-node timeout (KTD-15). */ +export const CODE_NODE_DEFAULT_TIMEOUT_MS = 30_000; +/** Hard cap on the code-node timeout (KTD-15). */ +export const CODE_NODE_MAX_TIMEOUT_MS = 300_000; +/** Defensive re-check of the core source-size cap (KTD-15: ≤64KB). */ +export const CODE_NODE_MAX_SOURCE_BYTES = 65_536; +/** Cap on captured stdout/stderr surfaced into the node result (~16KB each). */ +export const CODE_NODE_OUTPUT_CAP_BYTES = 16_384; + +/** Sentinels framing the JSON result on the child's stdout. */ +const RESULT_BEGIN = "__FUSION_CODE_NODE_RESULT_BEGIN__"; +const RESULT_END = "__FUSION_CODE_NODE_RESULT_END__"; + +/** The JSON-safe task subset handed to the code node (KTD-15). */ +export interface CodeNodeTaskSubset { + id: string; + title: string; + description?: string; + column?: string; + steps: unknown[]; + customFields: Record; +} + +/** The harness ctx assembled for a code-node run. */ +export interface CodeNodeContext { + task: CodeNodeTaskSubset; + context: Record; + /** Declared artifacts, pre-read into a plain map (see module DEVIATION note). */ + artifacts: Record; + /** `foreach:active` instance when the node runs inside a foreach template. */ + instance?: Record; +} + +/** The result shape a code node returns (KTD-15). */ +export interface CodeNodeResult { + outcome?: string; + value?: string; + contextPatch?: Record; + customFields?: Record; +} + +/** Reason codes for a code-node failure (audit-stable). */ +export type CodeNodeFailureReason = + | "compile-error" + | "source-too-large" + | "timeout" + | "nonzero-exit" + | "runtime-throw" + | "bad-result"; + +/** Thrown on any code-node failure; carries the audit-stable reason + captured + * stderr (capped). The handler maps it to a `failure` node outcome. */ +export class CodeNodeError extends Error { + readonly reason: CodeNodeFailureReason; + readonly stderr: string; + constructor(reason: CodeNodeFailureReason, message: string, stderr = "") { + super(message); + this.name = "CodeNodeError"; + this.reason = reason; + this.stderr = stderr; + } +} + +/** Cap a string to a byte budget, appending a truncation marker. */ +function capOutput(s: string): string { + if (Buffer.byteLength(s, "utf8") <= CODE_NODE_OUTPUT_CAP_BYTES) return s; + // Slice by characters then trim until under the byte cap (good enough; output + // is for audit display, not byte-exact reconstruction). + let out = s.slice(0, CODE_NODE_OUTPUT_CAP_BYTES); + while (Buffer.byteLength(out, "utf8") > CODE_NODE_OUTPUT_CAP_BYTES) { + out = out.slice(0, -64); + } + return `${out}\n…[truncated]`; +} + +/** Resolve and clamp the configured timeout (KTD-15). */ +export function resolveCodeNodeTimeout(timeoutMs: unknown): number { + if (typeof timeoutMs !== "number" || !Number.isFinite(timeoutMs)) { + return CODE_NODE_DEFAULT_TIMEOUT_MS; + } + return Math.max(1000, Math.min(CODE_NODE_MAX_TIMEOUT_MS, Math.floor(timeoutMs))); +} + +/** + * Compile a code-node source (TS) to ESM in-memory. Throws {@link CodeNodeError} + * with reason `compile-error` on a syntax/transform failure (this is the same + * transform the save-time validator runs via {@link validateCodeNodeSources}). + */ +export async function compileCodeNodeSource(source: string): Promise { + if (Buffer.byteLength(source, "utf8") > CODE_NODE_MAX_SOURCE_BYTES) { + throw new CodeNodeError( + "source-too-large", + `code node source exceeds ${CODE_NODE_MAX_SOURCE_BYTES} bytes`, + ); + } + try { + // `transformSync` runs a short-lived per-call child that exits cleanly, + // avoiding esbuild's long-lived service process (which the test harness's + // subprocess guard would otherwise flag as a lingering child). + const out = transformSync(source, { + loader: "ts", + format: "esm", + target: "node18", + }); + return out.code; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + throw new CodeNodeError("compile-error", `code node failed to compile: ${message}`); + } +} + +/** The child harness wrapper. Reads ctx JSON from stdin, imports the compiled + * user module (default export), invokes it, frames the JSON result on stdout. */ +function buildChildHarness(userModuleFile: string): string { + return ` +import userMod from ${JSON.stringify(userModuleFile)}; + +function readStdin() { + return new Promise((resolve) => { + let data = ""; + process.stdin.setEncoding("utf8"); + process.stdin.on("data", (c) => { data += c; }); + process.stdin.on("end", () => resolve(data)); + }); +} + +(async () => { + const raw = await readStdin(); + const parsed = JSON.parse(raw); + // Reconstruct ctx.artifacts.read from the pre-read plain map. + const artifactsMap = parsed.artifacts || {}; + const ctx = { + task: parsed.task, + context: parsed.context || {}, + artifacts: { + read(key) { + return Object.prototype.hasOwnProperty.call(artifactsMap, key) + ? artifactsMap[key] + : undefined; + }, + }, + instance: parsed.instance, + }; + const fn = userMod; + if (typeof fn !== "function") { + throw new Error("code node module must default-export an async (ctx) => result function"); + } + const result = await fn(ctx); + process.stdout.write("${RESULT_BEGIN}" + JSON.stringify(result === undefined ? {} : result) + "${RESULT_END}"); +})().catch((err) => { + process.stderr.write(String(err && err.stack ? err.stack : err)); + process.exit(7); +}); +`; +} + +/** Options for {@link runCodeNode}. */ +export interface RunCodeNodeOptions { + source: string; + timeoutMs?: number; + cwd: string; + ctx: CodeNodeContext; + /** Override the node executable (tests). Defaults to the current process. */ + nodeExecPath?: string; + /** Injected process runner seam (tests). Defaults to the real child-process + * execution. Lets the suite unit-test mapping logic without spawning. */ + spawnRunner?: (params: { + nodeExecPath: string; + harnessFile: string; + cwd: string; + timeoutMs: number; + stdin: string; + }) => Promise<{ stdout: string; stderr: string }>; +} + +/** + * Compile + execute a code node and return its parsed result. Throws + * {@link CodeNodeError} on any failure (compile/timeout/exit/throw/bad-result). + */ +export async function runCodeNode(opts: RunCodeNodeOptions): Promise { + const timeoutMs = resolveCodeNodeTimeout(opts.timeoutMs); + const compiled = await compileCodeNodeSource(opts.source); + + const dir = await mkdtemp(join(tmpdir(), "fusion-code-node-")); + const userModuleFile = join(dir, "user.mjs"); + const harnessFile = join(dir, "harness.mjs"); + try { + await writeFile(userModuleFile, compiled, "utf8"); + await writeFile(harnessFile, buildChildHarness(userModuleFile), "utf8"); + + const stdin = JSON.stringify({ + task: opts.ctx.task, + context: opts.ctx.context, + artifacts: opts.ctx.artifacts, + instance: opts.ctx.instance, + }); + + const nodeExecPath = opts.nodeExecPath ?? process.execPath; + const run = opts.spawnRunner ?? defaultSpawnRunner; + let stdout: string; + let stderr: string; + try { + ({ stdout, stderr } = await run({ nodeExecPath, harnessFile, cwd: opts.cwd, timeoutMs, stdin })); + } catch (err) { + // Classify the child failure. execFile's error carries `killed` + // (timeout/SIGTERM), `signal`, and `code` (numeric exit code) or the string + // ETIMEDOUT; we narrow with a permissive shape. + const e = err as { + killed?: boolean; + signal?: string | null; + code?: number | string; + message?: string; + stderr?: string; + }; + const capturedStderr = capOutput(typeof e.stderr === "string" ? e.stderr : ""); + if (e.killed || e.signal === "SIGTERM" || e.code === "ETIMEDOUT") { + throw new CodeNodeError("timeout", `code node timed out after ${timeoutMs}ms`, capturedStderr); + } + // Exit code 7 is our harness's caught-throw sentinel; any numeric exit code + // is a runtime/non-zero-exit failure. + if (typeof e.code === "number") { + throw new CodeNodeError( + "runtime-throw", + `code node threw at runtime${capturedStderr ? `: ${capturedStderr.split("\n")[0]}` : ""}`, + capturedStderr, + ); + } + throw new CodeNodeError("nonzero-exit", `code node exited abnormally: ${e.message ?? "unknown error"}`, capturedStderr); + } + + // Parse the framed result. + const begin = stdout.indexOf(RESULT_BEGIN); + const end = stdout.indexOf(RESULT_END); + if (begin < 0 || end < 0 || end < begin) { + throw new CodeNodeError( + "bad-result", + "code node produced no parseable result", + capOutput(stderr), + ); + } + const jsonStr = stdout.slice(begin + RESULT_BEGIN.length, end); + let parsed: unknown; + try { + parsed = JSON.parse(jsonStr); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + throw new CodeNodeError("bad-result", `code node result was not valid JSON: ${message}`, capOutput(stderr)); + } + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { + throw new CodeNodeError("bad-result", "code node result must be an object", capOutput(stderr)); + } + return parsed as CodeNodeResult; + } finally { + await rm(dir, { recursive: true, force: true }).catch(() => undefined); + } +} + +/** The real child-process runner: spawns `node harness.mjs`, pipes ctx on stdin, + * captures stdout/stderr, enforces the timeout. */ +function defaultSpawnRunner(params: { + nodeExecPath: string; + harnessFile: string; + cwd: string; + timeoutMs: number; + stdin: string; +}): Promise<{ stdout: string; stderr: string }> { + return new Promise((resolve, reject) => { + const child = execFile( + params.nodeExecPath, + [params.harnessFile], + { + cwd: params.cwd, + timeout: params.timeoutMs, + // Minimal env: PATH + a few harmless basics; no inherited secrets beyond + // what the worktree-scoped script tier already has access to (KTD-15: + // same trust as existing script steps). + env: { + PATH: process.env.PATH ?? "", + HOME: process.env.HOME ?? "", + NODE_ENV: process.env.NODE_ENV ?? "", + }, + maxBuffer: 8 * 1024 * 1024, + encoding: "utf8", + }, + (err, stdout, stderr) => { + if (err) { + (err as NodeJS.ErrnoException & { stderr?: string; stdout?: string }).stderr = stderr; + reject(err); + return; + } + resolve({ stdout: stdout ?? "", stderr: stderr ?? "" }); + }, + ); + child.stdin?.end(params.stdin); + }); +} + +/** + * Save-time syntax validation (U14, KTD-15). Compiles every `code` node's source + * with the same esbuild transform the runner uses; returns the nodes that fail + * to compile with the error message. Exported so the dashboard workflow-save + * route can reject IR with uncompilable code nodes BEFORE persistence. + * + * HANDOFF: the dashboard route (`register-workflow-routes.ts` → + * `store.createWorkflowDefinition/update`) is owned by a concurrent agent and is + * NOT wired here. Until that route calls this helper, code-node sources are + * validated at EXECUTION time (a compile error surfaces as a `failure` node + * outcome via {@link CodeNodeError} reason `compile-error`). See the report + * handoff item. + */ +export async function validateCodeNodeSources( + ir: { nodes: WorkflowIrNode[] }, +): Promise> { + const failures: Array<{ nodeId: string; error: string }> = []; + for (const node of ir.nodes) { + if (node.kind !== "code") continue; + const source = (node.config as { source?: unknown } | undefined)?.source; + if (typeof source !== "string" || source.length === 0) { + failures.push({ nodeId: node.id, error: "code node has no source" }); + continue; + } + try { + await compileCodeNodeSource(source); + } catch (err) { + failures.push({ + nodeId: node.id, + error: err instanceof CodeNodeError ? err.message : String(err), + }); + } + // Recurse into foreach templates (code nodes are legal inside them, KTD-15). + const template = (node.config as { template?: { nodes?: WorkflowIrNode[] } } | undefined)?.template; + if (template?.nodes) { + failures.push(...(await validateCodeNodeSources({ nodes: template.nodes }))); + } + } + // Also recurse into any foreach templates at the top level. + for (const node of ir.nodes) { + if (node.kind !== "foreach") continue; + const template = (node.config as { template?: { nodes?: WorkflowIrNode[] } } | undefined)?.template; + if (template?.nodes) { + failures.push(...(await validateCodeNodeSources({ nodes: template.nodes }))); + } + } + return failures; +} + +/** Build the JSON-safe task subset handed to a code node (KTD-15). Only the + * allowlisted fields cross the boundary — no store handle, no engine internals. */ +export function buildCodeNodeTaskSubset(task: TaskDetail): CodeNodeTaskSubset { + return { + id: task.id, + title: task.title ?? "", + description: task.description, + column: task.column, + steps: Array.isArray(task.steps) ? (task.steps as unknown[]) : [], + customFields: (task.customFields as Record) ?? {}, + }; +} + +/** A JSON-safe deep snapshot of the walk context (drops functions/cycles via + * JSON round-trip; the reserved `foreach:active` instance is surfaced + * separately as ctx.instance, so strip it from the generic context). */ +function jsonSafeContext(context: Record): Record { + const out: Record = {}; + for (const [k, v] of Object.entries(context)) { + if (k === FOREACH_ACTIVE_CONTEXT_KEY) continue; + try { + out[k] = JSON.parse(JSON.stringify(v)); + } catch { + // Drop non-serializable values rather than failing the whole snapshot. + } + } + return out; +} + +/** Injected dependencies for {@link createCodeNodeRunner} (U14). */ +export interface CodeNodeRunnerDeps { + /** Worktree cwd for the child process (defaults to rootDir if unresolved). */ + resolveCwd: (task: TaskDetail) => Promise | string; + /** Pre-read the declared artifacts into a plain map (DEVIATION note above). + * Returns key→content for every artifact the workflow declares (or that the + * node references); missing artifacts are simply absent from the map. */ + readArtifacts: (task: TaskDetail) => Promise> | Record; + /** Write the returned customFields patch through the U11 validation authority. + * Resolves a typed rejection (not throw) so the runner maps it to a node + * failure surfacing the rejection. */ + writeCustomFields: ( + task: TaskDetail, + patch: Record, + ) => Promise<{ ok: true } | { ok: false; rejection: CustomFieldRejection }>; + /** Optional audit sink for failures (reason + detail). Never throws. */ + audit?: (reason: string, detail: string) => void; + /** Test seam: inject a process runner (forwarded to {@link runCodeNode}). */ + spawnRunner?: RunCodeNodeOptions["spawnRunner"]; +} + +/** + * Build a {@link CodeNodeRunner} bound to the executor environment. The returned + * function assembles the harness ctx (task subset, JSON-safe context, + * pre-read artifacts, `foreach:active` instance), runs the node, and maps the + * result to a {@link WorkflowNodeResult}: `outcome` string → `outcome:` + * (absent → success); `contextPatch` merged into the walk context; `customFields` + * written through the U11 authority (a typed rejection → node failure). A throw + * / timeout / non-zero exit / compile error → `failure` with the reason as the + * value and the captured stderr audited. + */ +export function createCodeNodeRunner(deps: CodeNodeRunnerDeps): CodeNodeRunner { + const audit = (reason: string, detail: string): void => { + try { + deps.audit?.(reason, detail); + } catch { + // Audit must never affect the run. + } + }; + + return async (node: WorkflowIrNode, task: TaskDetail, context: Record): Promise => { + const cfg = (node.config ?? {}) as { source?: unknown; timeoutMs?: unknown }; + const source = typeof cfg.source === "string" ? cfg.source : ""; + + const cwd = await deps.resolveCwd(task); + const artifacts = await deps.readArtifacts(task); + const instance = context[FOREACH_ACTIVE_CONTEXT_KEY] as Record | undefined; + + let result: CodeNodeResult; + try { + result = await runCodeNode({ + source, + timeoutMs: typeof cfg.timeoutMs === "number" ? cfg.timeoutMs : undefined, + cwd, + ctx: { + task: buildCodeNodeTaskSubset(task), + context: jsonSafeContext(context), + artifacts, + instance: instance ? (JSON.parse(JSON.stringify(instance)) as Record) : undefined, + }, + spawnRunner: deps.spawnRunner, + }); + } catch (err) { + const reason = err instanceof CodeNodeError ? err.reason : "runtime-throw"; + const stderr = err instanceof CodeNodeError ? err.stderr : ""; + const message = err instanceof Error ? err.message : String(err); + audit(reason, `code node '${node.id}' failed (${reason}): ${message}${stderr ? `\n${stderr}` : ""}`); + return { + outcome: "failure", + value: reason, + contextPatch: { [`node:${node.id}:error`]: message, [`node:${node.id}:stderr`]: capOutput(stderr) }, + }; + } + + // customFields patch → write through the U11 authority. A typed rejection + // surfaces as a node failure (KTD-15: fields only via the validated patch). + if (result.customFields && Object.keys(result.customFields).length > 0) { + const write = await deps.writeCustomFields(task, result.customFields); + if (!write.ok) { + const detail = `${write.rejection.code} (${write.rejection.fieldId}): ${write.rejection.detail}`; + audit("custom-field-rejected", `code node '${node.id}' customFields write rejected — ${detail}`); + return { + outcome: "failure", + value: "custom-field-rejected", + contextPatch: { [`node:${node.id}:rejection`]: detail }, + }; + } + } + + const patch: Record = { ...(result.contextPatch ?? {}) }; + // KTD-15: a returned `outcome` string routes `outcome:` edges; absent + // → success. The graph executor routes `outcome:` edges off the node result's + // `value`, so the returned outcome string becomes the routing value while the + // node outcome stays `success` (an explicit `outcome:"failure"` routes the + // `failure` edge — a routable choice, distinct from a thrown/timeout failure). + const routingValue = + typeof result.value === "string" + ? result.value + : typeof result.outcome === "string" && result.outcome.length > 0 + ? result.outcome + : undefined; + const nodeOutcome = result.outcome === "failure" ? "failure" : "success"; + return { + outcome: nodeOutcome, + value: routingValue, + contextPatch: patch, + }; + }; +} diff --git a/packages/engine/src/executor.ts b/packages/engine/src/executor.ts index dbf4833764..3b0ed66f7e 100644 --- a/packages/engine/src/executor.ts +++ b/packages/engine/src/executor.ts @@ -9,7 +9,8 @@ import { delimiter, isAbsolute, join, relative, resolve as resolvePath } from "n import { existsSync, realpathSync } from "node:fs"; import { readFile, rm, writeFile } from "node:fs/promises"; import type { TaskStore, Task, TaskDetail, TaskTokenUsage, StepStatus, Settings, WorkflowStep, MissionStore, Slice, AgentState, AgentCapability, RunMutationContext, AgentHeartbeatConfig, Agent, AgentMemoryInclusionMode, ProjectSettings, MergeResult, WorkflowIrNode } from "@fusion/core"; -import { RetryStormError, TaskDeletedError, serializeRetryStormError, isExperimentalFeatureEnabled } from "@fusion/core"; +import { RetryStormError, TaskDeletedError, serializeRetryStormError, isExperimentalFeatureEnabled, resolveWorkflowIrForTask } from "@fusion/core"; +import type { TaskStep, WorkflowIr } from "@fusion/core"; import { buildWorkflowObservationFromTask, buildWorkflowObservation, @@ -17,6 +18,8 @@ import { type WorkflowRunObservation, } from "@fusion/core"; import { WorkflowGraphTaskRunner, type WorkflowGraphTaskRunResult } from "./workflow-graph-task-runner.js"; +import { createCodeNodeRunner } from "./code-node-runner.js"; +import type { ParseStepsHandlerDeps, CodeNodeRunner } from "./workflow-node-handlers.js"; import type { WorkflowBranchPersistence, WorkflowBranchRunState } from "./workflow-graph-branches.js"; import type { WorkflowStepInstancePersistence, @@ -3296,6 +3299,13 @@ export class TaskExecutor { // the active instance's step to its persisted per-step baseline (git reset // + session rewind + step→pending) before re-entering step-execute. onReworkReset: (active) => this.applyGraphRethinkReset(task.id, active), + // Step-inversion (KTD-12, U12): parse-steps node handler deps — artifact + // read (through task-documents with PROMPT.md fallback), step-list write + // (graph-source projection), pin-protection probe, and audit. + parseStepsDeps: this.buildParseStepsDeps(), + // Step-inversion (KTD-15, U14): code node runner — esbuild compile + + // child-process execution with the harness contract. + runCode: this.buildCodeNodeRunner(), }); let result: WorkflowGraphTaskRunResult; try { @@ -3366,6 +3376,129 @@ export class TaskExecutor { }; } + /** + * Resolve which artifact/parser governs a graph-owned task's step list from its + * workflow's `parse-steps` declaration (KTD-12). Returns undefined for legacy + * tasks (no parse-steps node) so reconcile/resume keep their unchanged behavior. + * Used by reconcile read-through to know which artifact backs the step source. + */ + private resolveTaskStepSource(ir: WorkflowIr | undefined): { artifact: string; parser: string } | undefined { + if (!ir) return undefined; + for (const node of ir.nodes) { + if (node.kind !== "parse-steps") continue; + const cfg = (node.config ?? {}) as { artifact?: unknown; parser?: unknown }; + const parser = typeof cfg.parser === "string" ? cfg.parser : undefined; + if (!parser) continue; + const artifact = typeof cfg.artifact === "string" && cfg.artifact.trim() !== "" ? cfg.artifact : "PROMPT.md"; + return { artifact, parser }; + } + return undefined; + } + + /** + * Build the parse-steps node handler deps (KTD-12, U12): artifact read through + * the task-documents machinery (PROMPT.md falls back to the task's own PROMPT + * content the way step-init does), step-list write through the graph-source + * projection (`updateTask({ steps })`), pin-protection probe (persisted instance + * rows exist → re-parse illegal, KTD-3), and a logEntry-backed audit sink. + */ + private buildParseStepsDeps(): ParseStepsHandlerDeps { + return { + readArtifact: async (task, key): Promise => { + // Declared artifacts ride the task-documents layer. + try { + const doc = await this.store.getTaskDocument(task.id, key); + if (doc) return doc.content; + } catch { + // Fall through to the PROMPT fallback below. + } + // Default step-source artifact (PROMPT.md): fall back to the task's PROMPT + // content (the same source the legacy step-init reads). + if (key === "PROMPT.md") { + try { + const detail = await this.store.getTask(task.id); + if (typeof detail.prompt === "string") return detail.prompt; + } catch { + // No PROMPT available. + } + } + return undefined; + }, + writeSteps: async (task, steps: TaskStep[]): Promise => { + await this.store.updateTask(task.id, { steps }); + }, + hasExpandedForeach: async (task): Promise => { + const store = this.store as unknown as { + loadWorkflowRunStepInstances?: (taskId: string, runId: string) => WorkflowStepInstanceState[]; + }; + if (typeof store.loadWorkflowRunStepInstances !== "function") return false; + try { + // Any persisted instance row for this task (any run) means a foreach has + // expanded — re-parsing would desynchronize the pinned instance set. + const rows = store.loadWorkflowRunStepInstances(task.id, `${task.id}:run`); + return Array.isArray(rows) && rows.length > 0; + } catch { + return false; + } + }, + audit: (reason, detail) => { + // The detail string carries the task id (handler convention); emit on the + // engine log so the routable failure is auditable without a taskId arg. + executorLog.warn(`[parse-steps] ${reason}: ${detail}`); + }, + }; + } + + /** + * Build the code node runner (KTD-15, U14): worktree cwd resolution, pre-read of + * declared artifacts into the harness ctx, and customFields writes through the + * U11 validation authority. Drives the esbuild-compile + child-process runner + * in code-node-runner.ts. + */ + private buildCodeNodeRunner(): CodeNodeRunner { + return createCodeNodeRunner({ + resolveCwd: async (task): Promise => { + try { + return (await this.store.getTask(task.id)).worktree || this.rootDir; + } catch { + return this.rootDir; + } + }, + readArtifacts: async (task): Promise> => { + const out: Record = {}; + try { + const docs = await this.store.getTaskDocuments(task.id); + for (const doc of docs) out[doc.key] = doc.content; + } catch { + // No documents — pass an empty artifact map. + } + // Surface PROMPT.md from the task prompt when not already a document. + if (out["PROMPT.md"] === undefined) { + try { + const detail = await this.store.getTask(task.id); + if (typeof detail.prompt === "string") out["PROMPT.md"] = detail.prompt; + } catch { + // No prompt available. + } + } + return out; + }, + writeCustomFields: async (task, patch) => { + if (typeof this.store.updateTaskCustomFields !== "function") { + return { + ok: false as const, + rejection: { code: "no-fields-defined" as const, fieldId: "", detail: "custom fields unsupported by store" }, + }; + } + const result = await this.store.updateTaskCustomFields(task.id, patch); + return result.ok ? { ok: true as const } : { ok: false as const, rejection: result.rejection }; + }, + audit: (reason, detail) => { + executorLog.warn(`[code-node] ${reason}: ${detail}`); + }, + }); + } + /** * RETHINK reset-on-rework (KTD-4, U5): reset the active foreach instance's step * to its per-step baseline before the rework edge re-enters step-execute. Drives @@ -11362,6 +11495,26 @@ Backward compat fallback: if JSON is unavailable, you may still begin output wit const baseCommitSha = detail.baseCommitSha; if (!baseCommitSha) return; + // Step-inversion read-through (KTD-12, U12): for graph-owned tasks, resolve + // which artifact/parser governs the step list from the workflow's parse-steps + // declaration so reconcile knows the step source. The `complete step N` + // commit convention is parser-agnostic (every parser yields the same step + // ordering the agent commits against), so the git-history reconcile below is + // unchanged — this read-through records the governing source for diagnostics + // and is the seam a future parser-specific reconcile would consult. Legacy + // tasks (no parse-steps node) resolve to undefined and are untouched. + try { + const ir = await resolveWorkflowIrForTask(this.store, taskId); + const stepSource = this.resolveTaskStepSource(ir); + if (stepSource) { + executorLog.log( + `${taskId}: reconcile step source governed by parse-steps(artifact=${stepSource.artifact}, parser=${stepSource.parser})`, + ); + } + } catch { + // Read-through is diagnostic only; never block reconcile on it. + } + const pendingOrInProgressSteps = detail.steps.filter( (s, i) => (s.status === "pending" || s.status === "in-progress") && i > 0, ); diff --git a/packages/engine/src/index.ts b/packages/engine/src/index.ts index b60c72874e..fa5b8f9ab6 100644 --- a/packages/engine/src/index.ts +++ b/packages/engine/src/index.ts @@ -35,9 +35,15 @@ export { export { createDefaultNodeHandlers, createNoopLegacySeams, + createParseStepsHandler, + createCodeNodeHandler, + PARSE_STEPS_DEFAULT_ARTIFACT, type WorkflowCustomNodeRunner, type WorkflowLegacySeams, type WorkflowSeamName, + type ParseStepsHandlerDeps, + type CodeNodeRunner, + type DefaultNodeHandlerDeps, } from "./workflow-node-handlers.js"; export { WorkflowGraphTaskRunner, @@ -474,6 +480,36 @@ export { PluginTraitHasDependentsError, type PluginTraitDependent, } from "./plugin-trait-adapter.js"; +// Step-inversion U12 (KTD-12): plugin step-parser adapter. +export { + registerPluginStepParsers, + unregisterPluginStepParsers, + pluginParserRegistryId, + pluginParserToRegistryParser, + PluginParserError, + PLUGIN_PARSER_TIMEOUT_MS, + type PluginStepParserContribution, +} from "./plugin-parser-adapter.js"; +// Step-inversion U14 (KTD-15): code-node runner + save-time validation helper. +export { + runCodeNode, + createCodeNodeRunner, + compileCodeNodeSource, + validateCodeNodeSources, + buildCodeNodeTaskSubset, + resolveCodeNodeTimeout, + CodeNodeError, + CODE_NODE_DEFAULT_TIMEOUT_MS, + CODE_NODE_MAX_TIMEOUT_MS, + CODE_NODE_MAX_SOURCE_BYTES, + CODE_NODE_OUTPUT_CAP_BYTES, + type CodeNodeContext, + type CodeNodeResult, + type CodeNodeRunnerDeps, + type CodeNodeTaskSubset, + type CodeNodeFailureReason, + type RunCodeNodeOptions, +} from "./code-node-runner.js"; // Agent runtime abstraction export { type AgentRuntime, type AgentRuntimeOptions, type AgentSessionResult } from "./agent-runtime.js"; export { diff --git a/packages/engine/src/plugin-parser-adapter.ts b/packages/engine/src/plugin-parser-adapter.ts new file mode 100644 index 0000000000..928081d4db --- /dev/null +++ b/packages/engine/src/plugin-parser-adapter.ts @@ -0,0 +1,157 @@ +/** + * Plugin step-parser adapter (U12, KTD-12). + * + * Bridges plugin-contributed step parsers into core's {@link StepParserRegistry}, + * mirroring {@link import("./plugin-trait-adapter.js")} for traits. Plugins + * register parsers under namespaced ids (`plugin::`) so they + * can never collide with or override the built-ins (`step-headings`, + * `json-steps`) — the registry enforces builtin-namespace protection and the + * `plugin:` id shape on registration. + * + * Contract (KTD-12): a plugin parser is `(artifactContent) => { steps }`. The + * adapter wraps each contributed parser so that: + * - a throw is re-thrown as a {@link PluginParserError} (fail-closed): the + * engine's `parse-steps` handler maps any throw to a routable + * `outcome:parse-error` (audited) — never a crash; + * - an unavailable parser (the plugin provides no usable `parse` function) is + * likewise a fail-closed throw; + * - a result that is not a `{ steps: [...] }` object is rejected (fail-closed). + * + * Timeout posture (documented deviation): the core registry's `parse` is + * synchronous (the engine handler calls it inline), so a plugin parser cannot be + * pre-empted mid-call by a timer the way an async runtime hook (trait adapter) + * can. Plugin parsers run with the same trust tier as project-local script steps + * (KTD-15 framing). The adapter therefore enforces the timeout BUDGET it is + * given by measuring wall time AROUND the synchronous call and failing closed + * (throw → parse-error) when the parser overran — the result is discarded so a + * slow parser can never silently feed a stale/partial step list. A truly + * runaway synchronous parser is a plugin bug bounded by the same posture as a + * runaway script step. + */ + +import { StepParserRegistry, getStepParserRegistry } from "@fusion/core"; +import type { ParsedStep, StepParseResult, StepParser } from "@fusion/core"; + +/** Default budget for a plugin parser invocation (ms). */ +export const PLUGIN_PARSER_TIMEOUT_MS = 5_000; + +/** Build the registry-facing id for a plugin parser. */ +export function pluginParserRegistryId(pluginId: string, parserId: string): string { + return `plugin:${pluginId}:${parserId}`; +} + +/** A plugin's step-parser contribution. `parse` is synchronous (project-local + * trust tier); the adapter wraps it fail-closed. */ +export interface PluginStepParserContribution { + parserId: string; + /** `(artifactContent) => { steps }`. May throw on malformed input. */ + parse: (content: string) => StepParseResult; +} + +/** Fail-closed error the wrapped parser throws; the parse-steps handler maps any + * throw to a routable `outcome:parse-error` (audited). */ +export class PluginParserError extends Error { + readonly parserId: string; + readonly reason: "unavailable" | "throw" | "timeout" | "bad-result"; + constructor(parserId: string, reason: PluginParserError["reason"], message: string) { + super(message); + this.name = "PluginParserError"; + this.parserId = parserId; + this.reason = reason; + } +} + +/** Validate that a value matches the `{ steps: ParsedStep[] }` contract. */ +function assertStepParseResult(registryId: string, value: unknown): StepParseResult { + if (typeof value !== "object" || value === null || !Array.isArray((value as { steps?: unknown }).steps)) { + throw new PluginParserError(registryId, "bad-result", `plugin parser '${registryId}' returned a non-{steps} result`); + } + const steps = (value as { steps: unknown[] }).steps; + for (const s of steps) { + if (typeof s !== "object" || s === null || typeof (s as { name?: unknown }).name !== "string") { + throw new PluginParserError(registryId, "bad-result", `plugin parser '${registryId}' returned a step without a string name`); + } + } + return { steps: steps as ParsedStep[] }; +} + +/** + * Wrap a plugin contribution into a registry {@link StepParser} (fail-closed). + * The wrapped `parse` re-throws every failure as a {@link PluginParserError}; + * the engine's parse-steps handler maps the throw to `outcome:parse-error`. + */ +export function pluginParserToRegistryParser( + pluginId: string, + contribution: PluginStepParserContribution, + timeoutMs: number = PLUGIN_PARSER_TIMEOUT_MS, +): StepParser { + const registryId = pluginParserRegistryId(pluginId, contribution.parserId); + return { + id: registryId, + parse(content: string): StepParseResult { + if (typeof contribution.parse !== "function") { + throw new PluginParserError(registryId, "unavailable", `plugin parser '${registryId}' has no parse function`); + } + const started = Date.now(); + let raw: StepParseResult; + try { + raw = contribution.parse(content); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + throw new PluginParserError(registryId, "throw", `plugin parser '${registryId}' threw: ${message}`); + } + // Wall-time budget enforcement (documented sync-timeout posture): discard a + // result produced after the budget rather than feed a stale step list. + if (Date.now() - started > timeoutMs) { + throw new PluginParserError( + registryId, + "timeout", + `plugin parser '${registryId}' exceeded ${timeoutMs}ms budget`, + ); + } + return assertStepParseResult(registryId, raw); + }, + }; +} + +/** + * Register a plugin's step-parser contributions into the registry. Idempotent + * per id (a re-register of an already-present id is skipped). Returns the + * registry ids registered so the caller can later unregister them. Mirrors + * {@link import("./plugin-trait-adapter.js").registerPluginTraits}. + */ +export function registerPluginStepParsers(params: { + registry?: StepParserRegistry; + pluginId: string; + contributions: PluginStepParserContribution[]; + timeoutMs?: number; +}): string[] { + const registry = params.registry ?? getStepParserRegistry(); + const registered: string[] = []; + for (const contribution of params.contributions) { + const parser = pluginParserToRegistryParser(params.pluginId, contribution, params.timeoutMs); + if (!registry.has(parser.id)) { + // Registration enforces the `plugin:` id shape + builtin protection. + registry.register(parser, { builtin: false }); + } + registered.push(parser.id); + } + return registered; +} + +/** + * Unregister a plugin's step parsers (plugin teardown / reload). Built-ins are + * never removed (the registry refuses). Returns the removed registry ids. + */ +export function unregisterPluginStepParsers( + pluginId: string, + parserIds: string[], + registry: StepParserRegistry = getStepParserRegistry(), +): string[] { + const removed: string[] = []; + for (const parserId of parserIds) { + const id = pluginParserRegistryId(pluginId, parserId); + if (registry.unregister(id)) removed.push(id); + } + return removed; +} diff --git a/packages/engine/src/plugin-runner.ts b/packages/engine/src/plugin-runner.ts index e00648812e..68c13e9726 100644 --- a/packages/engine/src/plugin-runner.ts +++ b/packages/engine/src/plugin-runner.ts @@ -49,6 +49,11 @@ import { PluginTraitHasDependentsError, type PluginTraitDependent, } from "./plugin-trait-adapter.js"; +import { + registerPluginStepParsers, + unregisterPluginStepParsers, + type PluginStepParserContribution, +} from "./plugin-parser-adapter.js"; // Type for the task store's event data interface TaskMovedEvent { @@ -170,6 +175,9 @@ export class PluginRunner { private promptContributionsCacheVersion = 0; /** Map of pluginId → the registry trait ids it currently has registered. */ private registeredPluginTraitIds = new Map(); + /** Map of pluginId → the step-parser registry ids it currently has registered + * (U12, KTD-12; mirrors registeredPluginTraitIds). */ + private registeredPluginParserIds = new Map(); /** The custom-node runner used to execute plugin trait hooks (set via * setTraitHookRunner; mirrors how the executor wires runGraphCustomNode). */ private traitHookRunner: WorkflowCustomNodeRunner | undefined; @@ -471,6 +479,48 @@ export class PluginRunner { } } + /** + * Register all currently-loaded plugins' step-parser contributions into the + * core StepParserRegistry (plugin-namespaced ids, U12/KTD-12). Mirrors + * {@link syncPluginTraits}. Parsers for plugins no longer present are dropped. + * Reads contributions via the loader's optional `getPluginStepParsers` getter + * (graceful absence — a loader that predates parser contributions yields none). + * Fail-closed at registration is the adapter's concern; a registration error + * for one plugin is logged and never aborts the others. + */ + syncPluginStepParsers(): void { + const loader = this.options.pluginLoader as unknown as { + getPluginStepParsers?: () => Array<{ pluginId: string; parser: PluginStepParserContribution }>; + }; + const current = typeof loader.getPluginStepParsers === "function" ? loader.getPluginStepParsers() : []; + + const byPlugin = new Map(); + for (const { pluginId, parser } of current) { + const list = byPlugin.get(pluginId) ?? []; + list.push(parser); + byPlugin.set(pluginId, list); + } + + // Drop parsers for plugins no longer present. + for (const [pluginId, ids] of [...this.registeredPluginParserIds.entries()]) { + if (!byPlugin.has(pluginId)) { + const parserIds = ids.map((id) => id.split(":")[2]).filter(Boolean); + unregisterPluginStepParsers(pluginId, parserIds); + this.registeredPluginParserIds.delete(pluginId); + } + } + + for (const [pluginId, contributions] of byPlugin) { + try { + const ids = registerPluginStepParsers({ pluginId, contributions }); + this.registeredPluginParserIds.set(pluginId, ids); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + this.log.warn(`Failed to register step parsers for plugin '${pluginId}': ${msg}`); + } + } + } + /** * The live-dependents guard (KTD-7). Returns the tasks currently sitting in a * column that uses one of the plugin's traits. A non-force disable/unregister @@ -1171,6 +1221,8 @@ export class PluginRunner { // Re-register/deregister plugin traits in the core registry to match the // newly-loaded/unloaded set (mirrors the workflow-step contribution flow). this.syncPluginTraits(); + // Step parsers (U12, KTD-12) ride the same plugin lifecycle as traits. + this.syncPluginStepParsers(); } private invalidatePromptContributionsCache(): void { diff --git a/packages/engine/src/workflow-graph-executor.ts b/packages/engine/src/workflow-graph-executor.ts index 3058f0441f..2f8410a24e 100644 --- a/packages/engine/src/workflow-graph-executor.ts +++ b/packages/engine/src/workflow-graph-executor.ts @@ -5,7 +5,9 @@ import { createDefaultNodeHandlers, createNoopLegacySeams, SPLIT_ACTIVE_CONTEXT_KEY, + type CodeNodeRunner, type ForeachActiveContext, + type ParseStepsHandlerDeps, type WorkflowCustomNodeRunner, type WorkflowLegacySeams, } from "./workflow-node-handlers.js"; @@ -46,6 +48,13 @@ export interface WorkflowGraphExecutorDeps { seams?: WorkflowLegacySeams; /** Executes custom (non-seam) prompt/script/gate nodes. */ runCustomNode?: WorkflowCustomNodeRunner; + /** Step-inversion (U12, KTD-12): dependencies for the `parse-steps` node + * handler (artifact read, projection write, pin-protection probe, audit). + * Absent → a parse-steps node fails cleanly. */ + parseStepsDeps?: ParseStepsHandlerDeps; + /** Step-inversion (U14, KTD-15): runner for the `code` node (esbuild compile + + * child-process execution). Absent → a code node fails cleanly. */ + runCode?: CodeNodeRunner; maxRetriesPerNode?: number; /** Per-branch run-state persistence (U13). Optional — fully in-memory without it. */ branchPersistence?: WorkflowBranchPersistence; @@ -112,7 +121,10 @@ export class WorkflowGraphExecutor { public constructor(private readonly deps: WorkflowGraphExecutorDeps) { this.maxRetriesPerNode = Math.max(1, Math.floor(deps.maxRetriesPerNode ?? 2)); this.handlers = { - ...createDefaultNodeHandlers(deps.seams ?? createNoopLegacySeams(), deps.runCustomNode), + ...createDefaultNodeHandlers(deps.seams ?? createNoopLegacySeams(), deps.runCustomNode, { + parseSteps: deps.parseStepsDeps, + runCode: deps.runCode, + }), ...(deps.handlers ?? {}), }; } diff --git a/packages/engine/src/workflow-graph-task-runner.ts b/packages/engine/src/workflow-graph-task-runner.ts index 678c16bb68..f20cfaeb0d 100644 --- a/packages/engine/src/workflow-graph-task-runner.ts +++ b/packages/engine/src/workflow-graph-task-runner.ts @@ -3,7 +3,9 @@ import { isExperimentalFeatureEnabled } from "@fusion/core"; import { WorkflowGraphExecutor, type WorkflowNodeOutcome } from "./workflow-graph-executor.js"; import type { + CodeNodeRunner, ForeachActiveContext, + ParseStepsHandlerDeps, WorkflowCustomNodeRunner, WorkflowLegacySeams, } from "./workflow-node-handlers.js"; @@ -61,6 +63,12 @@ export interface WorkflowGraphTaskRunnerDeps { * re-entering step-execute when a rework edge was triggered by an * `outcome:rethink`. Wired to `resetStepToBaseline` in production. */ onReworkReset?: (active: ForeachActiveContext, reason: string) => void | Promise; + /** Step-inversion (U12, KTD-12): `parse-steps` node handler deps. Additive; + * a workflow with no parse-steps node never invokes it. */ + parseStepsDeps?: ParseStepsHandlerDeps; + /** Step-inversion (U14, KTD-15): `code` node runner. Additive; a workflow with + * no code node never invokes it. */ + runCode?: CodeNodeRunner; } /** @@ -164,6 +172,8 @@ export class WorkflowGraphTaskRunner { branchSemaphore: this.deps.branchSemaphore, stepInstancePersistence: this.deps.stepInstancePersistence, onReworkReset: this.deps.onReworkReset, + parseStepsDeps: this.deps.parseStepsDeps, + runCode: this.deps.runCode, runId: `${task.id}:${definition.id}`, onBranchProgress: (progress) => { this.branchProgress.set(progress.branchId, progress); diff --git a/packages/engine/src/workflow-node-handlers.ts b/packages/engine/src/workflow-node-handlers.ts index b19882f53f..0f5805579d 100644 --- a/packages/engine/src/workflow-node-handlers.ts +++ b/packages/engine/src/workflow-node-handlers.ts @@ -1,5 +1,5 @@ -import { WorkflowIrError } from "@fusion/core"; -import type { TaskDetail, WorkflowIrNode } from "@fusion/core"; +import { WorkflowIrError, getStepParser } from "@fusion/core"; +import type { TaskDetail, TaskStep, WorkflowIrNode } from "@fusion/core"; import type { WorkflowNodeHandler, WorkflowNodeResult } from "./workflow-graph-executor.js"; @@ -285,16 +285,238 @@ export function createStepReviewHandler(seams: WorkflowLegacySeams): WorkflowNod }; } +// ── parse-steps node (U12, KTD-12) ────────────────────────────────────────── + +/** The implicit default step-source artifact when a workflow declares no + * artifacts (mirrors core's IMPLICIT_DEFAULT_ARTIFACT). */ +export const PARSE_STEPS_DEFAULT_ARTIFACT = "PROMPT.md"; + +/** + * Engine-side dependencies the `parse-steps` handler needs (U12, KTD-12). All + * injected so the handler stays unit-testable with fakes and the graph layer + * stays engine-agnostic. The production wiring (executor.ts) reads the artifact + * through the task-documents machinery (falling back to the task's PROMPT + * content for the default `PROMPT.md` artifact), writes the parsed step list + * through the graph-source projection (`updateTask({ steps })`), and reports + * whether the foreach pin is already established (KTD-3 pin protection). + */ +export interface ParseStepsHandlerDeps { + /** + * Read an artifact's text content for a task. Resolves `undefined` when the + * artifact does not exist (the handler maps that to `parse-error`). The + * executor wires this to the task-documents read path with a PROMPT.md + * fallback to the task's own PROMPT content. + */ + readArtifact: (task: TaskDetail, key: string) => Promise; + /** + * Write the canonical parsed step list through the projection sink (the single + * graph-side step-list writer, KTD-12). All statuses are `pending`; + * `dependsOn` is preserved. The executor wires this to + * `store.updateTask(taskId, { steps })`. + */ + writeSteps: (task: TaskDetail, steps: TaskStep[]) => Promise; + /** + * Pin-protection probe (KTD-3): resolves true when a foreach has already + * expanded for this task+run — either persisted instance rows exist OR a + * foreach expanded earlier in this walk. Re-parsing after expansion is illegal + * (it would silently desynchronize the pinned instance set), so the handler + * fails with an audited `pin-mismatch` outcome. Optional — absent means no + * pin established (always safe to parse). + */ + hasExpandedForeach?: (task: TaskDetail) => Promise | boolean; + /** Optional audit sink: called with a stable reason code on every routable + * failure outcome (`parse-error`, `pin-mismatch`) so the run audit records it. + * Never throws into the handler. */ + audit?: (reason: string, detail: string) => void; +} + +/** + * Handler for the `parse-steps` node kind (U12, KTD-12). Reads the declared + * artifact, resolves the parser from the core registry, runs it, and writes the + * step list through the projection — the ONLY graph-side step-list writer. + * + * Outcomes: + * - unknown parser → `outcome:failure value:"parse-error"` (audited) + * - missing artifact → `outcome:failure value:"parse-error"` (audited) + * - parser throws → `outcome:failure value:"parse-error"` (audited, never crashes) + * - clean empty parse → `outcome:success value:"no-steps"` (routable; defaults to success) + * - foreach already expanded → `outcome:failure value:"pin-mismatch"` (audited, KTD-3) + * - steps parsed → `outcome:success` (steps written through projection) + */ +export function createParseStepsHandler(deps: ParseStepsHandlerDeps): WorkflowNodeHandler { + const audit = (reason: string, detail: string): void => { + try { + deps.audit?.(reason, detail); + } catch { + // Audit must never affect the run. + } + }; + + return async (node, ctx) => { + const cfg = (node.config ?? {}) as { artifact?: unknown; parser?: unknown }; + const parserId = typeof cfg.parser === "string" ? cfg.parser : ""; + const artifactKey = + typeof cfg.artifact === "string" && cfg.artifact.trim() !== "" + ? cfg.artifact + : PARSE_STEPS_DEFAULT_ARTIFACT; + + // Pin protection (KTD-3): re-parsing after a foreach has expanded is illegal. + try { + if (deps.hasExpandedForeach && (await deps.hasExpandedForeach(ctx.task))) { + audit( + "pin-mismatch", + `parse-steps node '${node.id}' reached after a foreach already expanded for task ${ctx.task.id}`, + ); + return { outcome: "failure", value: "pin-mismatch" }; + } + } catch (err) { + // A pin-probe failure must fail closed (never silently re-parse). + const message = err instanceof Error ? err.message : String(err); + audit("pin-mismatch", `parse-steps node '${node.id}' pin probe failed: ${message}`); + return { outcome: "failure", value: "pin-mismatch" }; + } + + // Resolve the parser from the registry (built-ins + plugin parsers, KTD-12). + const parser = getStepParser(parserId); + if (!parser) { + audit( + "parse-error", + `parse-steps node '${node.id}' references unknown parser '${parserId}'`, + ); + return { outcome: "failure", value: "parse-error" }; + } + + // Read the artifact content. + let content: string | undefined; + try { + content = await deps.readArtifact(ctx.task, artifactKey); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + audit( + "parse-error", + `parse-steps node '${node.id}' artifact '${artifactKey}' read failed: ${message}`, + ); + return { outcome: "failure", value: "parse-error" }; + } + if (content === undefined) { + audit( + "parse-error", + `parse-steps node '${node.id}' artifact '${artifactKey}' not found for task ${ctx.task.id}`, + ); + return { outcome: "failure", value: "parse-error" }; + } + + // Run the parser; a throw (malformed artifact) maps to parse-error. + let parsedSteps; + try { + parsedSteps = parser.parse(content).steps; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + audit( + "parse-error", + `parse-steps node '${node.id}' parser '${parserId}' threw: ${message}`, + ); + return { outcome: "failure", value: "parse-error" }; + } + + // Clean empty parse → routable no-steps outcome (defaults to success). + if (parsedSteps.length === 0) { + // Still write the (empty) projection so a re-parse is idempotent and the + // foreach reads a definitive zero-step list. + try { + await deps.writeSteps(ctx.task, []); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + audit( + "parse-error", + `parse-steps node '${node.id}' failed to write empty step list: ${message}`, + ); + return { outcome: "failure", value: "parse-error" }; + } + return { outcome: "success", value: "no-steps" }; + } + + // Project the parsed steps onto the task step list — all pending, dependsOn + // preserved. This is the single graph-side step-list write (KTD-12). + const steps: TaskStep[] = parsedSteps.map((s) => { + const step: TaskStep = { name: s.name, status: "pending" }; + if (s.dependsOn && s.dependsOn.length > 0) step.dependsOn = s.dependsOn; + return step; + }); + try { + await deps.writeSteps(ctx.task, steps); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + audit( + "parse-error", + `parse-steps node '${node.id}' failed to write ${steps.length} steps: ${message}`, + ); + return { outcome: "failure", value: "parse-error" }; + } + + return { outcome: "success" }; + }; +} + +// ── code node (U14, KTD-15) ───────────────────────────────────────────────── + +/** + * Runs a `code` node's source against the harness contract (U14, KTD-15) and + * returns the result mapped to graph behavior. Injected so the handler stays + * engine-agnostic; the production wiring (executor.ts) drives the esbuild + * compile + child-process runner in code-node-runner.ts, assembling the ctx + * (task subset, walk context, declared artifacts, `foreach:active` instance) and + * routing the returned `{ outcome, value, contextPatch, customFields }`. + */ +export type CodeNodeRunner = ( + node: WorkflowIrNode, + task: TaskDetail, + context: Record, +) => Promise; + +/** + * Handler for the `code` node kind (U14, KTD-15). Delegates to the injected + * runner. Fail-closed: a code node with no runner wired must NOT silently + * succeed (it would route an unverified path forward) — it fails with an audited + * value, mirroring the step-execute/step-review unwired posture. + */ +export function createCodeNodeHandler(runCode?: CodeNodeRunner): WorkflowNodeHandler { + return async (node, ctx) => { + if (!runCode) { + return { outcome: "failure", value: "code-node-unwired" }; + } + return runCode(node, ctx.task, ctx.context); + }; +} + +export interface DefaultNodeHandlerDeps { + /** parse-steps node deps (U12). When absent, a parse-steps node fails cleanly. */ + parseSteps?: ParseStepsHandlerDeps; + /** code node runner (U14). When absent, a code node fails cleanly. */ + runCode?: CodeNodeRunner; +} + export function createDefaultNodeHandlers( seams: WorkflowLegacySeams, runCustomNode?: WorkflowCustomNodeRunner, -): Record<"prompt" | "script" | "gate" | "step-review", WorkflowNodeHandler> { + deps?: DefaultNodeHandlerDeps, +): Record< + "prompt" | "script" | "gate" | "step-review" | "parse-steps" | "code", + WorkflowNodeHandler +> { const promptLike = createPromptLikeHandler(seams, runCustomNode); + // parse-steps without deps fails closed (would otherwise have no handler at + // all and throw "No handler registered"); a clean failure is the safe posture. + const parseSteps: WorkflowNodeHandler = deps?.parseSteps + ? createParseStepsHandler(deps.parseSteps) + : async () => ({ outcome: "failure", value: "parse-steps-unwired" }); return { prompt: promptLike, script: promptLike, gate: createGateHandler(runCustomNode), "step-review": createStepReviewHandler(seams), + "parse-steps": parseSteps, + code: createCodeNodeHandler(deps?.runCode), }; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e199c60879..6a29bff2bb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -46,10 +46,10 @@ importers: dependencies: '@earendil-works/pi-ai': specifier: ^0.78.0 - version: 0.78.0(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76))(ws@8.20.0)(zod@3.25.76) + version: 0.78.0(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6) '@earendil-works/pi-coding-agent': specifier: ^0.78.0 - version: 0.78.0(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76))(ws@8.20.0)(zod@3.25.76) + version: 0.78.0(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6) dockerode: specifier: ^4.0.12 version: 4.0.12 @@ -479,6 +479,9 @@ importers: cron-parser: specifier: ^5.5.0 version: 5.5.0 + esbuild: + specifier: ^0.25.12 + version: 0.25.12 proper-lockfile: specifier: ^4.1.2 version: 4.1.2 @@ -7080,10 +7083,6 @@ snapshots: '@jridgewell/gen-mapping': 0.3.13 '@jridgewell/trace-mapping': 0.3.31 - '@anthropic-ai/sdk@0.91.1': - dependencies: - json-schema-to-ts: 3.1.1 - '@anthropic-ai/sdk@0.91.1(zod@3.25.76)': dependencies: json-schema-to-ts: 3.1.1 @@ -7818,20 +7817,6 @@ snapshots: - ws - zod - '@earendil-works/pi-agent-core@0.78.0(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76))(ws@8.20.0)(zod@3.25.76)': - dependencies: - '@earendil-works/pi-ai': 0.78.0(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76))(ws@8.20.0)(zod@3.25.76) - ignore: 7.0.5 - typebox: 1.1.38 - yaml: 2.9.0 - transitivePeerDependencies: - - '@modelcontextprotocol/sdk' - - bufferutil - - supports-color - - utf-8-validate - - ws - - zod - '@earendil-works/pi-agent-core@0.78.0(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6)': dependencies: '@earendil-works/pi-ai': 0.78.0(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6) @@ -7862,14 +7847,14 @@ snapshots: '@earendil-works/pi-ai@0.77.0': dependencies: - '@anthropic-ai/sdk': 0.91.1 + '@anthropic-ai/sdk': 0.91.1(zod@3.25.76) '@aws-sdk/client-bedrock-runtime': 3.1048.0 - '@google/genai': 1.52.0 + '@google/genai': 1.52.0(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6)) '@mistralai/mistralai': 2.2.1 '@smithy/node-http-handler': 4.7.3 http-proxy-agent: 7.0.2 https-proxy-agent: 7.0.6 - openai: 6.26.0 + openai: 6.26.0(ws@8.20.0)(zod@3.25.76) partial-json: 0.1.7 typebox: 1.1.38 transitivePeerDependencies: @@ -7900,26 +7885,6 @@ snapshots: - ws - zod - '@earendil-works/pi-ai@0.78.0(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76))(ws@8.20.0)(zod@3.25.76)': - dependencies: - '@anthropic-ai/sdk': 0.91.1(zod@3.25.76) - '@aws-sdk/client-bedrock-runtime': 3.1048.0 - '@google/genai': 1.52.0(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76)) - '@mistralai/mistralai': 2.2.1 - '@smithy/node-http-handler': 4.7.3 - http-proxy-agent: 7.0.2 - https-proxy-agent: 7.0.6 - openai: 6.26.0(ws@8.20.0)(zod@3.25.76) - partial-json: 0.1.7 - typebox: 1.1.38 - transitivePeerDependencies: - - '@modelcontextprotocol/sdk' - - bufferutil - - supports-color - - utf-8-validate - - ws - - zod - '@earendil-works/pi-ai@0.78.0(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6)': dependencies: '@anthropic-ai/sdk': 0.91.1(zod@4.3.6) @@ -7944,7 +7909,7 @@ snapshots: dependencies: '@anthropic-ai/sdk': 0.91.1(zod@3.25.76) '@aws-sdk/client-bedrock-runtime': 3.1048.0 - '@google/genai': 1.52.0 + '@google/genai': 1.52.0(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6)) '@mistralai/mistralai': 2.2.1 '@smithy/node-http-handler': 4.7.3 http-proxy-agent: 7.0.2 @@ -8018,35 +7983,6 @@ snapshots: - ws - zod - '@earendil-works/pi-coding-agent@0.78.0(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76))(ws@8.20.0)(zod@3.25.76)': - dependencies: - '@earendil-works/pi-agent-core': 0.78.0(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76))(ws@8.20.0)(zod@3.25.76) - '@earendil-works/pi-ai': 0.78.0(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76))(ws@8.20.0)(zod@3.25.76) - '@earendil-works/pi-tui': 0.78.0 - '@silvia-odwyer/photon-node': 0.3.4 - chalk: 5.6.2 - cross-spawn: 7.0.6 - diff: 8.0.4 - glob: 13.0.6 - highlight.js: 10.7.3 - hosted-git-info: 9.0.3 - ignore: 7.0.5 - jiti: 2.7.0 - minimatch: 10.2.5 - proper-lockfile: 4.1.2 - typebox: 1.1.38 - undici: 8.3.0 - yaml: 2.9.0 - optionalDependencies: - '@mariozechner/clipboard': 0.3.9 - transitivePeerDependencies: - - '@modelcontextprotocol/sdk' - - bufferutil - - supports-color - - utf-8-validate - - ws - - zod - '@earendil-works/pi-coding-agent@0.78.0(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6)': dependencies: '@earendil-works/pi-agent-core': 0.78.0(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6) @@ -8426,30 +8362,6 @@ snapshots: '@exodus/bytes@1.15.0': {} - '@google/genai@1.52.0': - dependencies: - google-auth-library: 10.6.2 - p-retry: 4.6.2 - protobufjs: 7.5.8 - ws: 8.20.0 - transitivePeerDependencies: - - bufferutil - - supports-color - - utf-8-validate - - '@google/genai@1.52.0(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76))': - dependencies: - google-auth-library: 10.6.2 - p-retry: 4.6.2 - protobufjs: 7.5.8 - ws: 8.20.0 - optionalDependencies: - '@modelcontextprotocol/sdk': 1.28.0(zod@3.25.76) - transitivePeerDependencies: - - bufferutil - - supports-color - - utf-8-validate - '@google/genai@1.52.0(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))': dependencies: google-auth-library: 10.6.2 @@ -8956,29 +8868,6 @@ snapshots: - bufferutil - utf-8-validate - '@modelcontextprotocol/sdk@1.28.0(zod@3.25.76)': - dependencies: - '@hono/node-server': 1.19.12(hono@4.12.9) - ajv: 8.18.0 - ajv-formats: 3.0.1(ajv@8.18.0) - content-type: 1.0.5 - cors: 2.8.6 - cross-spawn: 7.0.6 - eventsource: 3.0.7 - eventsource-parser: 3.0.6 - express: 5.2.1 - express-rate-limit: 8.3.1(express@5.2.1) - hono: 4.12.9 - jose: 6.2.2 - json-schema-typed: 8.0.2 - pkce-challenge: 5.0.1 - raw-body: 3.0.2 - zod: 3.25.76 - zod-to-json-schema: 3.25.1(zod@3.25.76) - transitivePeerDependencies: - - supports-color - optional: true - '@modelcontextprotocol/sdk@1.28.0(zod@4.3.6)': dependencies: '@hono/node-server': 1.19.12(hono@4.12.9) @@ -12639,8 +12528,6 @@ snapshots: is-docker: 2.2.1 is-wsl: 2.2.0 - openai@6.26.0: {} - openai@6.26.0(ws@8.20.0)(zod@3.25.76): optionalDependencies: ws: 8.20.0