feat(engine): U12e+U14 — parse-steps node handler, plugin parser adapter, code node runner (esbuild + child-process harness)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-06-04 12:44:43 -07:00
parent 5fa5740afc
commit 14758f8edb
12 changed files with 1683 additions and 127 deletions

View File

@@ -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"
},

View File

@@ -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> = {}): 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<Parameters<typeof createCodeNodeRunner>[0]> = {}) {
const writes: Array<Record<string, unknown>> = [];
const audits: Array<{ reason: string; detail: string }> = [];
const deps = {
resolveCwd: () => process.cwd(),
readArtifacts: () => ({ "PROMPT.md": "hello" }),
writeCustomFields: async (_t: TaskDetail, patch: Record<string, unknown>) => {
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:<value>", 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<string, unknown> }).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);
});

View File

@@ -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<ParseStepsHandlerDeps> = {}): {
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"]);
});
});

View File

@@ -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: <walk context snapshot, JSON-safe>,
* artifacts: { read(key): string | undefined }, // pre-read, plain object
* instance?: <foreach:active when inside a foreach template>,
* }
* result = { outcome?, value?, contextPatch?, customFields? }
* - outcome string → routes outcome:<value>; 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<string, unknown>;
}
/** The harness ctx assembled for a code-node run. */
export interface CodeNodeContext {
task: CodeNodeTaskSubset;
context: Record<string, unknown>;
/** Declared artifacts, pre-read into a plain map (see module DEVIATION note). */
artifacts: Record<string, string>;
/** `foreach:active` instance when the node runs inside a foreach template. */
instance?: Record<string, unknown>;
}
/** The result shape a code node returns (KTD-15). */
export interface CodeNodeResult {
outcome?: string;
value?: string;
contextPatch?: Record<string, unknown>;
customFields?: Record<string, unknown>;
}
/** 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<string> {
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<CodeNodeResult> {
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<Array<{ nodeId: string; error: string }>> {
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<string, unknown>) ?? {},
};
}
/** 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<string, unknown>): Record<string, unknown> {
const out: Record<string, unknown> = {};
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> | 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<string, string>> | Record<string, string>;
/** 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<string, unknown>,
) => 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:<value>`
* (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<string, unknown>): Promise<WorkflowNodeResult> => {
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<string, unknown> | 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<string, unknown>) : 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<string, unknown> = { ...(result.contextPatch ?? {}) };
// KTD-15: a returned `outcome` string routes `outcome:<value>` 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,
};
};
}

View File

@@ -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<string | undefined> => {
// 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<void> => {
await this.store.updateTask(task.id, { steps });
},
hasExpandedForeach: async (task): Promise<boolean> => {
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<string> => {
try {
return (await this.store.getTask(task.id)).worktree || this.rootDir;
} catch {
return this.rootDir;
}
},
readArtifacts: async (task): Promise<Record<string, string>> => {
const out: Record<string, string> = {};
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,
);

View File

@@ -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 {

View File

@@ -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:<pluginId>:<parserId>`) 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;
}

View File

@@ -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<string, string[]>();
/** Map of pluginId → the step-parser registry ids it currently has registered
* (U12, KTD-12; mirrors registeredPluginTraitIds). */
private registeredPluginParserIds = new Map<string, string[]>();
/** 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<string, PluginStepParserContribution[]>();
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 {

View File

@@ -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 ?? {}),
};
}

View File

@@ -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<void>;
/** 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);

View File

@@ -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<string | undefined>;
/**
* 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<void>;
/**
* 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> | 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<string, unknown>,
) => Promise<WorkflowNodeResult>;
/**
* 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),
};
}

131
pnpm-lock.yaml generated
View File

@@ -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