@boardwalk-labs/engine 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/README.md +69 -0
- package/bin/boardwalk-server.js +16 -0
- package/dist/agent/conversation.d.ts +42 -0
- package/dist/agent/conversation.js +4 -0
- package/dist/agent/leaf.d.ts +81 -0
- package/dist/agent/leaf.js +190 -0
- package/dist/agent/providers.d.ts +23 -0
- package/dist/agent/providers.js +347 -0
- package/dist/agent/rates.d.ts +13 -0
- package/dist/agent/rates.js +35 -0
- package/dist/agent/redact.d.ts +9 -0
- package/dist/agent/redact.js +27 -0
- package/dist/agent/resolve.d.ts +58 -0
- package/dist/agent/resolve.js +153 -0
- package/dist/agent/sse.d.ts +2 -0
- package/dist/agent/sse.js +30 -0
- package/dist/agent/tools.d.ts +57 -0
- package/dist/agent/tools.js +324 -0
- package/dist/clock.d.ts +8 -0
- package/dist/clock.js +32 -0
- package/dist/cron/cron.d.ts +34 -0
- package/dist/cron/cron.js +331 -0
- package/dist/engine.d.ts +106 -0
- package/dist/engine.js +183 -0
- package/dist/errors.d.ts +15 -0
- package/dist/errors.js +40 -0
- package/dist/ids.d.ts +7 -0
- package/dist/ids.js +42 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +8 -0
- package/dist/json_value.d.ts +7 -0
- package/dist/json_value.js +29 -0
- package/dist/mcp/client.d.ts +39 -0
- package/dist/mcp/client.js +112 -0
- package/dist/mcp/jsonrpc.d.ts +57 -0
- package/dist/mcp/jsonrpc.js +117 -0
- package/dist/mcp/oauth.d.ts +72 -0
- package/dist/mcp/oauth.js +337 -0
- package/dist/mcp/token_store.d.ts +30 -0
- package/dist/mcp/token_store.js +101 -0
- package/dist/mcp/transport_http.d.ts +38 -0
- package/dist/mcp/transport_http.js +143 -0
- package/dist/mcp/transport_stdio.d.ts +27 -0
- package/dist/mcp/transport_stdio.js +94 -0
- package/dist/run/child.d.ts +1 -0
- package/dist/run/child.js +139 -0
- package/dist/run/child_host.d.ts +26 -0
- package/dist/run/child_host.js +124 -0
- package/dist/run/idempotency.d.ts +5 -0
- package/dist/run/idempotency.js +31 -0
- package/dist/run/ipc.d.ts +159 -0
- package/dist/run/ipc.js +150 -0
- package/dist/run/run_dir.d.ts +31 -0
- package/dist/run/run_dir.js +106 -0
- package/dist/run/supervisor.d.ts +107 -0
- package/dist/run/supervisor.js +676 -0
- package/dist/scheduler/scheduler.d.ts +54 -0
- package/dist/scheduler/scheduler.js +215 -0
- package/dist/server/http.d.ts +42 -0
- package/dist/server/http.js +183 -0
- package/dist/server/routes/api.d.ts +17 -0
- package/dist/server/routes/api.js +107 -0
- package/dist/server/routes/hooks.d.ts +2 -0
- package/dist/server/routes/hooks.js +88 -0
- package/dist/server/routes/router.d.ts +15 -0
- package/dist/server/routes/router.js +75 -0
- package/dist/server/routes/stream.d.ts +2 -0
- package/dist/server/routes/stream.js +79 -0
- package/dist/server/routes/ui.d.ts +2 -0
- package/dist/server/routes/ui.js +120 -0
- package/dist/server/server.d.ts +25 -0
- package/dist/server/server.js +67 -0
- package/dist/server_main.d.ts +46 -0
- package/dist/server_main.js +203 -0
- package/dist/store/migrations.d.ts +21 -0
- package/dist/store/migrations.js +159 -0
- package/dist/store/store.d.ts +194 -0
- package/dist/store/store.js +567 -0
- package/package.json +57 -0
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// MCP stdio transport: spawn the server command and speak newline-delimited JSON over its
|
|
2
|
+
// stdin/stdout (the MCP stdio framing). Runs in the RUN PROCESS — the program is the trusted
|
|
3
|
+
// layer, so its inline `command`/`env` are honored as-is; the server's stderr is inherited so
|
|
4
|
+
// its diagnostics land in the run log like any other program output.
|
|
5
|
+
import { spawn } from "node:child_process";
|
|
6
|
+
import { EngineError } from "../errors.js";
|
|
7
|
+
export class StdioTransport {
|
|
8
|
+
serverName;
|
|
9
|
+
child;
|
|
10
|
+
messageCb = null;
|
|
11
|
+
closeCb = null;
|
|
12
|
+
/** Set once the transport failed or was closed — later sends must fail fast, not hang. */
|
|
13
|
+
dead = null;
|
|
14
|
+
closedDeliberately = false;
|
|
15
|
+
stdoutBuffer = "";
|
|
16
|
+
constructor(opts) {
|
|
17
|
+
this.serverName = opts.serverName;
|
|
18
|
+
this.child = spawn(opts.command, [...(opts.args ?? [])], {
|
|
19
|
+
env: { ...process.env, ...opts.env },
|
|
20
|
+
// stderr is inherited: it flows into THIS process's stderr, which the supervisor already
|
|
21
|
+
// captures as run-log program output — MCP server diagnostics need no extra plumbing.
|
|
22
|
+
stdio: ["pipe", "pipe", "inherit"],
|
|
23
|
+
});
|
|
24
|
+
this.child.stdout?.on("data", (chunk) => {
|
|
25
|
+
this.stdoutBuffer += chunk.toString("utf8");
|
|
26
|
+
let newline = this.stdoutBuffer.indexOf("\n");
|
|
27
|
+
while (newline >= 0) {
|
|
28
|
+
const line = this.stdoutBuffer.slice(0, newline).trim();
|
|
29
|
+
this.stdoutBuffer = this.stdoutBuffer.slice(newline + 1);
|
|
30
|
+
if (line.length > 0)
|
|
31
|
+
this.deliverLine(line);
|
|
32
|
+
newline = this.stdoutBuffer.indexOf("\n");
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
this.child.on("error", (err) => {
|
|
36
|
+
// Spawn failure (ENOENT et al.) — the most common misconfiguration; name the command.
|
|
37
|
+
this.die(new EngineError("PROVIDER_ERROR", `MCP server "${this.serverName}": failed to spawn "${opts.command}": ${err.message}.`, "Check that the command exists on this machine's PATH (stdio MCP servers run locally)."));
|
|
38
|
+
});
|
|
39
|
+
this.child.on("exit", (code, signal) => {
|
|
40
|
+
if (this.closedDeliberately)
|
|
41
|
+
return;
|
|
42
|
+
this.die(new EngineError("PROVIDER_ERROR", `MCP server "${this.serverName}" exited unexpectedly ` +
|
|
43
|
+
`(${signal !== null ? `signal ${signal}` : `code ${String(code)}`}).`, "Its stderr is in the run log — check there for the server's own error output."));
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
send(message) {
|
|
47
|
+
if (this.dead !== null)
|
|
48
|
+
return Promise.reject(this.dead);
|
|
49
|
+
const stdin = this.child.stdin;
|
|
50
|
+
if (stdin === null || !stdin.writable) {
|
|
51
|
+
return Promise.reject(new EngineError("PROVIDER_ERROR", `MCP server "${this.serverName}": stdin is closed.`));
|
|
52
|
+
}
|
|
53
|
+
return new Promise((resolve, reject) => {
|
|
54
|
+
stdin.write(`${JSON.stringify(message)}\n`, (err) => {
|
|
55
|
+
if (err !== null && err !== undefined)
|
|
56
|
+
reject(err);
|
|
57
|
+
else
|
|
58
|
+
resolve();
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
onMessage(cb) {
|
|
63
|
+
this.messageCb = cb;
|
|
64
|
+
}
|
|
65
|
+
onClose(cb) {
|
|
66
|
+
this.closeCb = cb;
|
|
67
|
+
// The process may have already died (spawn errors race subscription) — deliver late.
|
|
68
|
+
if (this.dead !== null && !this.closedDeliberately)
|
|
69
|
+
cb(this.dead);
|
|
70
|
+
}
|
|
71
|
+
/** Kill the server process. Deliberate teardown — no error is surfaced for the exit. */
|
|
72
|
+
close() {
|
|
73
|
+
this.closedDeliberately = true;
|
|
74
|
+
this.dead ??= new EngineError("PROVIDER_ERROR", `MCP server "${this.serverName}": connection closed.`);
|
|
75
|
+
this.child.kill("SIGTERM");
|
|
76
|
+
return Promise.resolve();
|
|
77
|
+
}
|
|
78
|
+
deliverLine(line) {
|
|
79
|
+
let message;
|
|
80
|
+
try {
|
|
81
|
+
message = JSON.parse(line);
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
return; // a non-JSON stdout line is server noise, not a protocol failure
|
|
85
|
+
}
|
|
86
|
+
this.messageCb?.(message);
|
|
87
|
+
}
|
|
88
|
+
die(err) {
|
|
89
|
+
if (this.dead !== null)
|
|
90
|
+
return;
|
|
91
|
+
this.dead = err;
|
|
92
|
+
this.closeCb?.(err);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
// The run-process entry point. Spawned by the supervisor with an IPC channel; never run
|
|
2
|
+
// directly. Protocol: wait for `init`, install the SDK host + run inputs, then IMPORT the
|
|
3
|
+
// program bundle — the module body is the program, so importing the file IS running it
|
|
4
|
+
// (MASTER_SPEC §2.1; no entrypoint convention). Report `done`/`failed`, exit. A thrown error
|
|
5
|
+
// anywhere is reported over IPC when possible — the supervisor treats an exit without a
|
|
6
|
+
// report as a crash (which triggers restart-from-the-top, the documented semantics).
|
|
7
|
+
import { pathToFileURL } from "node:url";
|
|
8
|
+
import { installConfig, installHost, installInput, takeDeclaredOutput, } from "@boardwalk-labs/workflow/runtime";
|
|
9
|
+
import { EngineError, toErrorShape } from "../errors.js";
|
|
10
|
+
import { asJsonValue } from "../json_value.js";
|
|
11
|
+
import { createChildHost, errorFromIpc } from "./child_host.js";
|
|
12
|
+
import { parentToChildSchema } from "./ipc.js";
|
|
13
|
+
function send(message) {
|
|
14
|
+
// Why the guard: process.send disappears if the IPC channel closed (supervisor died);
|
|
15
|
+
// at that point the orphan exits via the disconnect handler below — nothing to report to.
|
|
16
|
+
process.send?.(message);
|
|
17
|
+
}
|
|
18
|
+
const pending = new Map();
|
|
19
|
+
let nextCallId = 1;
|
|
20
|
+
let initialized = false;
|
|
21
|
+
process.on("disconnect", () => {
|
|
22
|
+
// Orphaned by an engine crash. Exit so the boot recovery sweep owns the restart; holding on
|
|
23
|
+
// with no supervisor would duplicate work when the engine comes back.
|
|
24
|
+
process.exit(1);
|
|
25
|
+
});
|
|
26
|
+
process.on("message", (raw) => {
|
|
27
|
+
const parsed = parentToChildSchema.safeParse(raw);
|
|
28
|
+
if (!parsed.success)
|
|
29
|
+
return; // Not ours; the supervisor only sends protocol messages.
|
|
30
|
+
const msg = parsed.data;
|
|
31
|
+
if (msg.type === "host_result") {
|
|
32
|
+
const call = pending.get(msg.callId);
|
|
33
|
+
if (call === undefined)
|
|
34
|
+
return;
|
|
35
|
+
pending.delete(msg.callId);
|
|
36
|
+
if (msg.result.ok)
|
|
37
|
+
call.resolve(msg.result.value);
|
|
38
|
+
else
|
|
39
|
+
call.reject(errorFromIpc(msg.result.error));
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
if (initialized)
|
|
43
|
+
return; // A second init is a protocol violation; ignore.
|
|
44
|
+
initialized = true;
|
|
45
|
+
void runProgram(msg.programPath, msg.workspaceDir, msg.skillsDir, msg.input, msg.config);
|
|
46
|
+
});
|
|
47
|
+
async function runProgram(programPath, workspaceDir, skillsDir, input, config) {
|
|
48
|
+
let redactor;
|
|
49
|
+
try {
|
|
50
|
+
process.chdir(workspaceDir);
|
|
51
|
+
const childHost = createChildHost({
|
|
52
|
+
request(method, args) {
|
|
53
|
+
return new Promise((resolve, reject) => {
|
|
54
|
+
const callId = nextCallId++;
|
|
55
|
+
pending.set(callId, { resolve, reject });
|
|
56
|
+
send({ type: "host_call", callId, method, args });
|
|
57
|
+
});
|
|
58
|
+
},
|
|
59
|
+
emit(body, turnId) {
|
|
60
|
+
send({ type: "emit", body, ...(turnId !== undefined ? { turnId } : {}) });
|
|
61
|
+
},
|
|
62
|
+
startTurn(turnId, identity) {
|
|
63
|
+
send({ type: "turn_started", turnId, ...identity });
|
|
64
|
+
},
|
|
65
|
+
reportUsage(modelRef, usage) {
|
|
66
|
+
send({ type: "report_usage", modelRef, usage });
|
|
67
|
+
},
|
|
68
|
+
memoryUsed(dir) {
|
|
69
|
+
send({ type: "memory_used", dir });
|
|
70
|
+
},
|
|
71
|
+
}, { workspaceDir, skillsDir });
|
|
72
|
+
redactor = childHost.redactor;
|
|
73
|
+
installHost(childHost.host);
|
|
74
|
+
installInput(input);
|
|
75
|
+
installConfig(narrowConfig(config));
|
|
76
|
+
// Importing IS running: the module body is the program; top-level await is the norm; the
|
|
77
|
+
// run completes when evaluation finishes and fails when the body throws.
|
|
78
|
+
const programModule = await import(pathToFileURL(programPath).href);
|
|
79
|
+
warnOnLegacyDefaultExport(programModule);
|
|
80
|
+
const declared = takeDeclaredOutput();
|
|
81
|
+
send({
|
|
82
|
+
type: "done",
|
|
83
|
+
output: declared === null ? null : declared.value,
|
|
84
|
+
outputDeclared: declared !== null,
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
catch (err) {
|
|
88
|
+
// Program errors can carry secret values the program legitimately read (secrets.get) —
|
|
89
|
+
// this report persists in the run row and event stream, so it gets the same redaction
|
|
90
|
+
// as everything model-bound. `redactor` may be unset if the failure preceded host setup;
|
|
91
|
+
// nothing secret can have been revealed before that point.
|
|
92
|
+
const scrub = (text) => redactor?.redact(text) ?? text;
|
|
93
|
+
const shape = toErrorShape(err);
|
|
94
|
+
const hint = err instanceof EngineError ? err.hint : undefined;
|
|
95
|
+
// Output declared before the throw still counts (verdict-then-throw): the success path
|
|
96
|
+
// reports it, so the failure path must too, or the verdict is lost. Safe even when the
|
|
97
|
+
// failure preceded host setup — nothing was declared, so this is null/false.
|
|
98
|
+
const declared = takeDeclaredOutput();
|
|
99
|
+
send({
|
|
100
|
+
type: "failed",
|
|
101
|
+
error: {
|
|
102
|
+
...shape,
|
|
103
|
+
message: scrub(shape.message),
|
|
104
|
+
...(hint !== undefined ? { hint: scrub(hint) } : {}),
|
|
105
|
+
},
|
|
106
|
+
output: declared === null ? null : declared.value,
|
|
107
|
+
outputDeclared: declared !== null,
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
finally {
|
|
111
|
+
// Why disconnect-then-exit: process.send is async under the hood; disconnecting flushes
|
|
112
|
+
// the channel so the final message is never lost to an immediate exit.
|
|
113
|
+
process.disconnect();
|
|
114
|
+
process.exit(0);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
/** Per-key narrowing of the deploy-time config crossing IPC (no record-level cast). */
|
|
118
|
+
function narrowConfig(config) {
|
|
119
|
+
const out = {};
|
|
120
|
+
for (const [key, value] of Object.entries(config)) {
|
|
121
|
+
out[key] = asJsonValue(value, `config.${key}`);
|
|
122
|
+
}
|
|
123
|
+
return out;
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* The rescinded draft convention wrapped the program in `export default async function run()`.
|
|
127
|
+
* Such a function is NEVER called (the module body is the program) — warn so an author who
|
|
128
|
+
* wrapped their logic learns why nothing happened. Stderr lands in the run log.
|
|
129
|
+
*/
|
|
130
|
+
function warnOnLegacyDefaultExport(programModule) {
|
|
131
|
+
if (typeof programModule !== "object" || programModule === null)
|
|
132
|
+
return;
|
|
133
|
+
const candidate = Reflect.get(programModule, "default");
|
|
134
|
+
if (typeof candidate === "function") {
|
|
135
|
+
console.error("warning: this workflow exports a default function, which Boardwalk does not call — " +
|
|
136
|
+
"the module body IS the program. Move the function's body to the top level " +
|
|
137
|
+
"(top-level await is supported).");
|
|
138
|
+
}
|
|
139
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { TokenUsage } from "@boardwalk-labs/workflow";
|
|
2
|
+
import type { WorkflowHost } from "@boardwalk-labs/workflow/runtime";
|
|
3
|
+
import { type AgentIdentity } from "../agent/leaf.js";
|
|
4
|
+
import { Redactor } from "../agent/redact.js";
|
|
5
|
+
import type { ToolSetContext } from "../agent/tools.js";
|
|
6
|
+
import { type IpcErrorShape, type HostMethod, type RunEventBody } from "./ipc.js";
|
|
7
|
+
export interface ChildHostIo {
|
|
8
|
+
/** Broker a host call to the supervisor; resolves with its result. */
|
|
9
|
+
request(method: HostMethod, args: Record<string, unknown>): Promise<unknown>;
|
|
10
|
+
/** Emit a run-event body (the supervisor stamps the envelope). turnId scopes leaf frames. */
|
|
11
|
+
emit(body: RunEventBody, turnId?: string): void;
|
|
12
|
+
/** Tell the supervisor to open a new turn block (it emits turn_started naming the leaf). */
|
|
13
|
+
startTurn(turnId: string, identity: AgentIdentity): void;
|
|
14
|
+
/** Report leaf usage to the supervisor — the budget authority. */
|
|
15
|
+
reportUsage(modelRef: string, usage: TokenUsage): void;
|
|
16
|
+
/** Tell the supervisor a memory dir is in use (auto-persisted at successful run end). */
|
|
17
|
+
memoryUsed(dir: string): void;
|
|
18
|
+
}
|
|
19
|
+
/** Rebuild a typed EngineError from its IPC shape so program-visible errors keep code + hint. */
|
|
20
|
+
export declare function errorFromIpc(shape: IpcErrorShape): Error;
|
|
21
|
+
export interface ChildHost {
|
|
22
|
+
host: WorkflowHost;
|
|
23
|
+
/** The run process's one redactor — the child entry scrubs failure reports with it too. */
|
|
24
|
+
redactor: Redactor;
|
|
25
|
+
}
|
|
26
|
+
export declare function createChildHost(io: ChildHostIo, capabilities: ToolSetContext): ChildHost;
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
// The WorkflowHost installed in the run process.
|
|
2
|
+
//
|
|
3
|
+
// Split of responsibilities (SPEC §2.3): anything that only needs the local process happens
|
|
4
|
+
// here (sleep — hold-and-pay is literally just holding this process; phase markers); anything
|
|
5
|
+
// that touches engine state (secrets, durable child runs, artifacts) is brokered to the
|
|
6
|
+
// supervisor over IPC. agent() runs its loop in THIS process too — program-defined tools and
|
|
7
|
+
// MCP connections must execute where the program lives (the trusted layer).
|
|
8
|
+
import { z } from "zod";
|
|
9
|
+
import { runAgentLeaf } from "../agent/leaf.js";
|
|
10
|
+
import { Redactor } from "../agent/redact.js";
|
|
11
|
+
import { EngineError, isEngineErrorCode } from "../errors.js";
|
|
12
|
+
import { mcpTokenResultSchema, resolvedModelSchema, } from "./ipc.js";
|
|
13
|
+
/** Rebuild a typed EngineError from its IPC shape so program-visible errors keep code + hint. */
|
|
14
|
+
export function errorFromIpc(shape) {
|
|
15
|
+
const code = isEngineErrorCode(shape.code) ? shape.code : "INTERNAL";
|
|
16
|
+
return new EngineError(code, shape.message, shape.hint);
|
|
17
|
+
}
|
|
18
|
+
export function createChildHost(io, capabilities) {
|
|
19
|
+
let phaseCount = 0;
|
|
20
|
+
// One counter per run → a stable, run-unique id for each agent() call. The author's optional
|
|
21
|
+
// name rides alongside as the display label; concurrent agents stay distinguishable either way.
|
|
22
|
+
let agentCount = 0;
|
|
23
|
+
// One redactor for the whole run process: every secret value revealed to the program (and
|
|
24
|
+
// every provider key) is scrubbed from everything model-bound, across all agent() calls.
|
|
25
|
+
const redactor = new Redactor();
|
|
26
|
+
const host = {
|
|
27
|
+
setPhase(name, opts) {
|
|
28
|
+
phaseCount += 1;
|
|
29
|
+
io.emit({ kind: "phase", name, id: opts?.id ?? `phase-${String(phaseCount)}` });
|
|
30
|
+
},
|
|
31
|
+
async agent(prompt, opts) {
|
|
32
|
+
agentCount += 1;
|
|
33
|
+
const identity = {
|
|
34
|
+
agentId: `agent-${String(agentCount)}`,
|
|
35
|
+
...(opts?.name !== undefined ? { agentName: opts.name } : {}),
|
|
36
|
+
};
|
|
37
|
+
return await runAgentLeaf(prompt, opts, {
|
|
38
|
+
identity,
|
|
39
|
+
resolve: async (model, provider) => resolvedModelSchema.parse(await io.request("resolve_model", {
|
|
40
|
+
...(model !== undefined ? { model } : {}),
|
|
41
|
+
...(provider !== undefined ? { provider } : {}),
|
|
42
|
+
})),
|
|
43
|
+
startTurn: (turnId) => io.startTurn(turnId, identity),
|
|
44
|
+
emit: (turnId, body) => io.emit(body, turnId),
|
|
45
|
+
reportUsage: (modelRef, usage) => io.reportUsage(modelRef, usage),
|
|
46
|
+
memoryUsed: (dir) => io.memoryUsed(dir),
|
|
47
|
+
// MCP OAuth tokens are engine state: brokered per use, validated like any other
|
|
48
|
+
// supervisor response; refresh tokens and the store never enter this process.
|
|
49
|
+
mcpToken: async (serverUrl, invalidateToken) => mcpTokenResultSchema.parse(await io.request("mcp_token", {
|
|
50
|
+
serverUrl,
|
|
51
|
+
...(invalidateToken !== undefined ? { invalidateToken } : {}),
|
|
52
|
+
})),
|
|
53
|
+
redactor,
|
|
54
|
+
capabilities,
|
|
55
|
+
});
|
|
56
|
+
},
|
|
57
|
+
async callWorkflow(slug, input, opts) {
|
|
58
|
+
return await io.request("call_workflow", {
|
|
59
|
+
slug,
|
|
60
|
+
input,
|
|
61
|
+
...(opts?.idempotencyKey !== undefined ? { idempotencyKey: opts.idempotencyKey } : {}),
|
|
62
|
+
});
|
|
63
|
+
},
|
|
64
|
+
async runWorkflow(slug, input, opts) {
|
|
65
|
+
const value = await io.request("run_workflow", {
|
|
66
|
+
slug,
|
|
67
|
+
input,
|
|
68
|
+
...(opts?.idempotencyKey !== undefined ? { idempotencyKey: opts.idempotencyKey } : {}),
|
|
69
|
+
});
|
|
70
|
+
return runIdSchema.parse(value);
|
|
71
|
+
},
|
|
72
|
+
async sleep(arg) {
|
|
73
|
+
// Hold-and-pay: the process just waits. Locals stay in memory; nothing is checkpointed.
|
|
74
|
+
// Chunked so a multi-week sleep({ until }) doesn't overflow setTimeout's 2^31-1 ms cap
|
|
75
|
+
// (~24.8 days), which would otherwise fire ~immediately — there is no hard duration cap.
|
|
76
|
+
let remaining = sleepMs(arg);
|
|
77
|
+
while (remaining > 0) {
|
|
78
|
+
const slice = Math.min(remaining, MAX_TIMEOUT_MS);
|
|
79
|
+
await new Promise((resolve) => {
|
|
80
|
+
setTimeout(resolve, slice);
|
|
81
|
+
});
|
|
82
|
+
remaining -= slice;
|
|
83
|
+
}
|
|
84
|
+
},
|
|
85
|
+
async getSecret(name) {
|
|
86
|
+
const value = secretValueSchema.parse(await io.request("get_secret", { name }));
|
|
87
|
+
redactor.add(name, value);
|
|
88
|
+
return value;
|
|
89
|
+
},
|
|
90
|
+
async writeArtifact(name, contentType, body, metadata) {
|
|
91
|
+
const bytes = typeof body === "string" ? Buffer.from(body, "utf8") : Buffer.from(body);
|
|
92
|
+
const value = await io.request("write_artifact", {
|
|
93
|
+
name,
|
|
94
|
+
contentType,
|
|
95
|
+
bodyBase64: bytes.toString("base64"),
|
|
96
|
+
...(metadata !== undefined ? { metadata } : {}),
|
|
97
|
+
});
|
|
98
|
+
return artifactRefSchema.parse(value);
|
|
99
|
+
},
|
|
100
|
+
};
|
|
101
|
+
return { host, redactor };
|
|
102
|
+
}
|
|
103
|
+
/** setTimeout's max delay (2^31-1 ms ≈ 24.8 days); longer sleeps are chunked. */
|
|
104
|
+
const MAX_TIMEOUT_MS = 2_147_483_647;
|
|
105
|
+
// Supervisor responses are validated like any other boundary input — the channel being ours
|
|
106
|
+
// doesn't exempt it (CODE_QUALITY §2.1).
|
|
107
|
+
const secretValueSchema = z.string();
|
|
108
|
+
const runIdSchema = z.string().min(1);
|
|
109
|
+
const artifactRefSchema = z.strictObject({
|
|
110
|
+
id: z.string().min(1),
|
|
111
|
+
name: z.string().min(1),
|
|
112
|
+
url: z.string().min(1),
|
|
113
|
+
});
|
|
114
|
+
function sleepMs(arg) {
|
|
115
|
+
if (typeof arg === "number")
|
|
116
|
+
return arg;
|
|
117
|
+
if ("durationMs" in arg)
|
|
118
|
+
return arg.durationMs;
|
|
119
|
+
const until = arg.until instanceof Date ? arg.until.getTime() : Date.parse(arg.until);
|
|
120
|
+
if (Number.isNaN(until)) {
|
|
121
|
+
throw new EngineError("VALIDATION", `sleep({ until }) got an unparseable date.`);
|
|
122
|
+
}
|
|
123
|
+
return until - Date.now();
|
|
124
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { JsonValue } from "@boardwalk-labs/workflow";
|
|
2
|
+
/** Serialize like JSON.stringify but with object keys sorted recursively — equal values ⇒ equal strings. */
|
|
3
|
+
export declare function canonicalJson(value: JsonValue): string;
|
|
4
|
+
/** The default child-call idempotency key: sha256 over (parent run, target slug, input). */
|
|
5
|
+
export declare function defaultIdempotencyKey(parentRunId: string, slug: string, input: JsonValue): string;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// Default idempotency keys for workflows.call / workflows.run.
|
|
2
|
+
//
|
|
3
|
+
// Contract (SDK CallOptions): "a deterministic key over (parent_run_id, target, input)" — a
|
|
4
|
+
// restarted parent recomputes the same key and re-attaches to the child it already spawned
|
|
5
|
+
// instead of spawning a duplicate. Determinism requires canonical JSON: object key order must
|
|
6
|
+
// not change the key. Inputs are narrowed to JsonValue at the IPC boundary before they get
|
|
7
|
+
// here, so this module is fully typed — no unknown, no casts.
|
|
8
|
+
import { createHash } from "node:crypto";
|
|
9
|
+
/** Serialize like JSON.stringify but with object keys sorted recursively — equal values ⇒ equal strings. */
|
|
10
|
+
export function canonicalJson(value) {
|
|
11
|
+
if (Array.isArray(value)) {
|
|
12
|
+
return `[${value.map(canonicalJson).join(",")}]`;
|
|
13
|
+
}
|
|
14
|
+
if (typeof value === "object" && value !== null) {
|
|
15
|
+
const parts = Object.entries(value)
|
|
16
|
+
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
|
|
17
|
+
.map(([key, v]) => `${JSON.stringify(key)}:${canonicalJson(v)}`);
|
|
18
|
+
return `{${parts.join(",")}}`;
|
|
19
|
+
}
|
|
20
|
+
return JSON.stringify(value);
|
|
21
|
+
}
|
|
22
|
+
/** The default child-call idempotency key: sha256 over (parent run, target slug, input). */
|
|
23
|
+
export function defaultIdempotencyKey(parentRunId, slug, input) {
|
|
24
|
+
return createHash("sha256")
|
|
25
|
+
.update(parentRunId)
|
|
26
|
+
.update(" ")
|
|
27
|
+
.update(slug)
|
|
28
|
+
.update(" ")
|
|
29
|
+
.update(canonicalJson(input))
|
|
30
|
+
.digest("hex");
|
|
31
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import type { RunEvent, WorkflowManifest, JsonValue } from "@boardwalk-labs/workflow";
|
|
3
|
+
/** A run event minus its envelope — what the child emits, before the supervisor stamps it. */
|
|
4
|
+
export type RunEventBody = RunEvent extends infer E ? E extends RunEvent ? Omit<E, "runId" | "turnId" | "seq" | "t"> : never : never;
|
|
5
|
+
declare const errorShapeSchema: z.ZodObject<{
|
|
6
|
+
code: z.ZodString;
|
|
7
|
+
message: z.ZodString;
|
|
8
|
+
hint: z.ZodOptional<z.ZodString>;
|
|
9
|
+
}, z.core.$strict>;
|
|
10
|
+
export type IpcErrorShape = z.infer<typeof errorShapeSchema>;
|
|
11
|
+
export interface InitMessage {
|
|
12
|
+
type: "init";
|
|
13
|
+
runId: string;
|
|
14
|
+
/** Absolute path to the bundled program (ESM, `@boardwalk-labs/workflow` external). */
|
|
15
|
+
programPath: string;
|
|
16
|
+
/** The run's isolated working directory (the child chdirs here before importing the program). */
|
|
17
|
+
workspaceDir: string;
|
|
18
|
+
/** Where this workflow's deployed skills live, or null when none were deployed. */
|
|
19
|
+
skillsDir: string | null;
|
|
20
|
+
input: unknown;
|
|
21
|
+
config: Record<string, JsonValue>;
|
|
22
|
+
manifest: WorkflowManifest;
|
|
23
|
+
}
|
|
24
|
+
export interface HostResultMessage {
|
|
25
|
+
type: "host_result";
|
|
26
|
+
callId: number;
|
|
27
|
+
result: {
|
|
28
|
+
ok: true;
|
|
29
|
+
value: unknown;
|
|
30
|
+
} | {
|
|
31
|
+
ok: false;
|
|
32
|
+
error: IpcErrorShape;
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
export type ParentToChild = InitMessage | HostResultMessage;
|
|
36
|
+
export declare const parentToChildSchema: z.ZodUnion<readonly [z.ZodObject<{
|
|
37
|
+
type: z.ZodLiteral<"init">;
|
|
38
|
+
runId: z.ZodString;
|
|
39
|
+
programPath: z.ZodString;
|
|
40
|
+
workspaceDir: z.ZodString;
|
|
41
|
+
skillsDir: z.ZodNullable<z.ZodString>;
|
|
42
|
+
input: z.ZodUnknown;
|
|
43
|
+
config: z.ZodRecord<z.ZodString, z.ZodUnknown>;
|
|
44
|
+
manifest: z.ZodRecord<z.ZodString, z.ZodUnknown>;
|
|
45
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
46
|
+
type: z.ZodLiteral<"host_result">;
|
|
47
|
+
callId: z.ZodNumber;
|
|
48
|
+
result: z.ZodUnion<readonly [z.ZodObject<{
|
|
49
|
+
ok: z.ZodLiteral<true>;
|
|
50
|
+
value: z.ZodUnknown;
|
|
51
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
52
|
+
ok: z.ZodLiteral<false>;
|
|
53
|
+
error: z.ZodObject<{
|
|
54
|
+
code: z.ZodString;
|
|
55
|
+
message: z.ZodString;
|
|
56
|
+
hint: z.ZodOptional<z.ZodString>;
|
|
57
|
+
}, z.core.$strict>;
|
|
58
|
+
}, z.core.$strip>]>;
|
|
59
|
+
}, z.core.$strip>]>;
|
|
60
|
+
/** Host methods the child brokers to the supervisor (everything that touches engine state). */
|
|
61
|
+
export declare const HOST_METHODS: readonly ["get_secret", "call_workflow", "run_workflow", "write_artifact", "resolve_model", "mcp_token"];
|
|
62
|
+
export type HostMethod = (typeof HOST_METHODS)[number];
|
|
63
|
+
export declare const childToParentSchema: z.ZodUnion<readonly [z.ZodObject<{
|
|
64
|
+
type: z.ZodLiteral<"host_call">;
|
|
65
|
+
callId: z.ZodNumber;
|
|
66
|
+
method: z.ZodEnum<{
|
|
67
|
+
get_secret: "get_secret";
|
|
68
|
+
call_workflow: "call_workflow";
|
|
69
|
+
run_workflow: "run_workflow";
|
|
70
|
+
write_artifact: "write_artifact";
|
|
71
|
+
resolve_model: "resolve_model";
|
|
72
|
+
mcp_token: "mcp_token";
|
|
73
|
+
}>;
|
|
74
|
+
args: z.ZodRecord<z.ZodString, z.ZodUnknown>;
|
|
75
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
76
|
+
type: z.ZodLiteral<"emit">;
|
|
77
|
+
body: z.ZodObject<{
|
|
78
|
+
kind: z.ZodString;
|
|
79
|
+
}, z.core.$loose>;
|
|
80
|
+
turnId: z.ZodOptional<z.ZodString>;
|
|
81
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
82
|
+
type: z.ZodLiteral<"turn_started">;
|
|
83
|
+
turnId: z.ZodString;
|
|
84
|
+
agentId: z.ZodString;
|
|
85
|
+
agentName: z.ZodOptional<z.ZodString>;
|
|
86
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
87
|
+
type: z.ZodLiteral<"report_usage">;
|
|
88
|
+
modelRef: z.ZodString;
|
|
89
|
+
usage: z.ZodObject<{
|
|
90
|
+
inputTokens: z.ZodOptional<z.ZodNumber>;
|
|
91
|
+
outputTokens: z.ZodOptional<z.ZodNumber>;
|
|
92
|
+
totalTokens: z.ZodOptional<z.ZodNumber>;
|
|
93
|
+
}, z.core.$strict>;
|
|
94
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
95
|
+
type: z.ZodLiteral<"memory_used">;
|
|
96
|
+
dir: z.ZodString;
|
|
97
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
98
|
+
type: z.ZodLiteral<"done">;
|
|
99
|
+
output: z.ZodUnknown;
|
|
100
|
+
outputDeclared: z.ZodBoolean;
|
|
101
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
102
|
+
type: z.ZodLiteral<"failed">;
|
|
103
|
+
error: z.ZodObject<{
|
|
104
|
+
code: z.ZodString;
|
|
105
|
+
message: z.ZodString;
|
|
106
|
+
hint: z.ZodOptional<z.ZodString>;
|
|
107
|
+
}, z.core.$strict>;
|
|
108
|
+
output: z.ZodUnknown;
|
|
109
|
+
outputDeclared: z.ZodBoolean;
|
|
110
|
+
}, z.core.$strip>]>;
|
|
111
|
+
export type ChildToParent = z.infer<typeof childToParentSchema>;
|
|
112
|
+
export declare const getSecretArgsSchema: z.ZodObject<{
|
|
113
|
+
name: z.ZodString;
|
|
114
|
+
}, z.core.$strict>;
|
|
115
|
+
export declare const callWorkflowArgsSchema: z.ZodObject<{
|
|
116
|
+
slug: z.ZodString;
|
|
117
|
+
input: z.ZodUnknown;
|
|
118
|
+
idempotencyKey: z.ZodOptional<z.ZodString>;
|
|
119
|
+
}, z.core.$strict>;
|
|
120
|
+
export declare const writeArtifactArgsSchema: z.ZodObject<{
|
|
121
|
+
name: z.ZodString;
|
|
122
|
+
contentType: z.ZodString;
|
|
123
|
+
bodyBase64: z.ZodString;
|
|
124
|
+
metadata: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
|
|
125
|
+
}, z.core.$strict>;
|
|
126
|
+
export declare const resolveModelArgsSchema: z.ZodObject<{
|
|
127
|
+
model: z.ZodOptional<z.ZodString>;
|
|
128
|
+
provider: z.ZodOptional<z.ZodString>;
|
|
129
|
+
}, z.core.$strict>;
|
|
130
|
+
/** The supervisor's resolve_model response, re-validated child-side before use. */
|
|
131
|
+
export declare const resolvedModelSchema: z.ZodObject<{
|
|
132
|
+
provider: z.ZodString;
|
|
133
|
+
model: z.ZodString;
|
|
134
|
+
protocol: z.ZodEnum<{
|
|
135
|
+
anthropic: "anthropic";
|
|
136
|
+
openai: "openai";
|
|
137
|
+
}>;
|
|
138
|
+
baseUrl: z.ZodString;
|
|
139
|
+
apiKey: z.ZodNullable<z.ZodString>;
|
|
140
|
+
headers: z.ZodRecord<z.ZodString, z.ZodString>;
|
|
141
|
+
secretHeaderNames: z.ZodArray<z.ZodString>;
|
|
142
|
+
}, z.core.$strict>;
|
|
143
|
+
/**
|
|
144
|
+
* mcp_token: the child asks the engine for an OAuth bearer token for an MCP server (token
|
|
145
|
+
* state is PARENT-owned — the run process never sees refresh tokens or the store).
|
|
146
|
+
* `invalidateToken` names a token the server just rejected, so the supervisor refreshes
|
|
147
|
+
* instead of handing the same dead value back.
|
|
148
|
+
*/
|
|
149
|
+
export declare const mcpTokenArgsSchema: z.ZodObject<{
|
|
150
|
+
serverUrl: z.ZodString;
|
|
151
|
+
invalidateToken: z.ZodOptional<z.ZodString>;
|
|
152
|
+
}, z.core.$strict>;
|
|
153
|
+
/** The supervisor's mcp_token response. null accessToken ⇒ interaction would be required —
|
|
154
|
+
* the hint names the `engine.authorizeMcpServer(...)` call that fixes it. */
|
|
155
|
+
export declare const mcpTokenResultSchema: z.ZodObject<{
|
|
156
|
+
accessToken: z.ZodNullable<z.ZodString>;
|
|
157
|
+
hint: z.ZodOptional<z.ZodString>;
|
|
158
|
+
}, z.core.$strict>;
|
|
159
|
+
export {};
|