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:
@@ -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"
|
||||
},
|
||||
|
||||
256
packages/engine/src/__tests__/code-node.test.ts
Normal file
256
packages/engine/src/__tests__/code-node.test.ts
Normal 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);
|
||||
});
|
||||
235
packages/engine/src/__tests__/workflow-parse-steps.test.ts
Normal file
235
packages/engine/src/__tests__/workflow-parse-steps.test.ts
Normal 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"]);
|
||||
});
|
||||
});
|
||||
535
packages/engine/src/code-node-runner.ts
Normal file
535
packages/engine/src/code-node-runner.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
157
packages/engine/src/plugin-parser-adapter.ts
Normal file
157
packages/engine/src/plugin-parser-adapter.ts
Normal 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;
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 ?? {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
131
pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user