@boardwalk-labs/runner 0.2.17 → 0.3.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/README.md +1 -1
- package/dist/runtime/agent/budget.d.ts +43 -24
- package/dist/runtime/agent/budget.js +108 -57
- package/dist/runtime/agent/events.d.ts +1 -1
- package/dist/runtime/broker_child_dispatcher.d.ts +5 -2
- package/dist/runtime/broker_child_dispatcher.js +24 -6
- package/dist/runtime/budget_gate.d.ts +58 -21
- package/dist/runtime/budget_gate.js +227 -46
- package/dist/runtime/host_capabilities.d.ts +4 -0
- package/dist/runtime/host_capabilities.js +25 -0
- package/dist/runtime/host_server.d.ts +137 -0
- package/dist/runtime/host_server.js +562 -0
- package/dist/runtime/index.js +45 -12
- package/dist/runtime/leaf_executor.d.ts +7 -4
- package/dist/runtime/leaf_executor.js +11 -6
- package/dist/runtime/program_runner.d.ts +62 -42
- package/dist/runtime/program_runner.js +156 -101
- package/dist/runtime/program_worker.d.ts +22 -11
- package/dist/runtime/program_worker.js +26 -48
- package/dist/runtime/python_program.d.ts +68 -0
- package/dist/runtime/python_program.js +270 -0
- package/dist/runtime/run_context.d.ts +13 -0
- package/dist/runtime/run_context.js +77 -0
- package/dist/runtime/runner_control_client.d.ts +23 -0
- package/dist/runtime/runner_control_client.js +69 -147
- package/dist/runtime/shell_exec.d.ts +17 -0
- package/dist/runtime/shell_exec.js +144 -0
- package/dist/runtime/support/index.d.ts +6 -0
- package/dist/runtime/support/index.js +14 -0
- package/dist/runtime/wire/inference_proxy.js +1 -1
- package/dist/runtime/wire/run.d.ts +5 -2
- package/dist/runtime/workflow_host.d.ts +53 -7
- package/dist/runtime/workflow_host.js +82 -42
- package/package.json +4 -3
|
@@ -1,55 +1,64 @@
|
|
|
1
|
-
// WorkflowProgramRunner — executes a workflow program (the
|
|
1
|
+
// WorkflowProgramRunner — executes a workflow program (the workflow-format redesign, P3).
|
|
2
2
|
//
|
|
3
3
|
// A run is the execution of a built program ARTIFACT: the worker is handed the VERIFIED tarball
|
|
4
4
|
// (its sha256 already checked against the pinned digest by the orchestrator) plus the entry module
|
|
5
|
-
// name. This module is the mechanism
|
|
6
|
-
// `@boardwalk-labs/workflow` singleton, extracts the artifact into a unique temp dir under the
|
|
7
|
-
// program root, chdirs to the workspace, and dynamic-imports the entry so the program's body runs. The
|
|
8
|
-
// program's `import { agent, sleep, … } from "@boardwalk-labs/workflow"` resolves to the SAME package
|
|
9
|
-
// instance the host was installed on (one instance per process), so the hooks reach our adapter.
|
|
5
|
+
// name. This module is the mechanism, the runner side of the host protocol (P3.1):
|
|
10
6
|
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
7
|
+
// 1. Start the {@link WorkflowHostServer} on a Unix socket and export its path as
|
|
8
|
+
// `BOARDWALK_HOST_SOCK` — the one env key the platform owns for the program's lifetime.
|
|
9
|
+
// 2. Extract the artifact into a unique temp dir under the program root, chdir to the workspace.
|
|
10
|
+
// 3. LOADER: connect the SDK's protocol client (the same `@boardwalk-labs/workflow` instance the
|
|
11
|
+
// program will import — `ensureSdkLink` guarantees it), `bootstrap()` → `{input, context}`
|
|
12
|
+
// (the client applies the schema-guided revival pass: a `date-time` field arrives as a
|
|
13
|
+
// `Date`), dynamic-import the entry, and call its DEFAULT-EXPORT `run(input, context)` —
|
|
14
|
+
// positional, Lambda-style; a `run()` declaring fewer params is fine.
|
|
15
|
+
// 4. Report the return via `reportReturn` — the server validates it against the stored
|
|
16
|
+
// `output_schema` (mismatch ⇒ the run fails VALIDATION_FAILED) and `void` ⇒ `null`.
|
|
15
17
|
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
// - `programRoot` — where the artifact extracts (`<programRoot>/.bw-runs/<runId>-<uuid>`). Must be
|
|
20
|
-
// OUTSIDE the workspace, or the bundle rides into every snapshot and accumulates across runs.
|
|
21
|
-
// The extracted tree needs no node_modules-reachable ancestor: `ensureSdkLink` (below) links the SDK
|
|
22
|
-
// into the exec dir, so the bare `@boardwalk-labs/workflow` import resolves from any root.
|
|
18
|
+
// Importing is no longer running: the entry's module body only DEFINES `run`; execution is the
|
|
19
|
+
// explicit call. The old module-body top-to-bottom execution path (ambient `input`, `output()`,
|
|
20
|
+
// the installHost singleton) is DELETED with the redesign — there is no other program shape.
|
|
23
21
|
//
|
|
24
|
-
//
|
|
25
|
-
//
|
|
26
|
-
//
|
|
22
|
+
// The artifact is already BUILT JS (the CLI bundles packages; the api-server type-strips single
|
|
23
|
+
// files) — the worker NEVER transpiles and NEVER installs. `@boardwalk-labs/workflow` is left
|
|
24
|
+
// external in the bundle and `ensureSdkLink` links THIS runtime's copy into the exec dir, so the
|
|
25
|
+
// program's capability imports share the loader's connected client (one module instance).
|
|
26
|
+
//
|
|
27
|
+
// Two places, both explicit, neither derived from `process.cwd()` (docs/WORKSPACE_PERSISTENCE.md
|
|
28
|
+
// I1/I2):
|
|
29
|
+
// - `workspaceRoot` — the run's `/workspace`. The working directory + HOME for AUTHOR code.
|
|
30
|
+
// - `programRoot` — where the artifact extracts (`<programRoot>/.bw-runs/<runId>-<uuid>`),
|
|
31
|
+
// OUTSIDE the workspace so the bundle never rides into a snapshot.
|
|
32
|
+
//
|
|
33
|
+
// Durability: `run()` executes once, in-process. Waiting seams (sleep / humanInput /
|
|
34
|
+
// workflows.call) hold or freeze inside the capability layer; a crash restarts the run from the
|
|
35
|
+
// top (handled by the worker/scheduler-sweep, not here).
|
|
27
36
|
import { mkdir, writeFile, rm, symlink, lstat } from "node:fs/promises";
|
|
28
37
|
import { dirname, join, isAbsolute, relative, resolve, sep } from "node:path";
|
|
29
38
|
import { createRequire } from "node:module";
|
|
30
39
|
import { pathToFileURL } from "node:url";
|
|
31
40
|
import { randomUUID } from "node:crypto";
|
|
32
|
-
import {
|
|
33
|
-
import {
|
|
41
|
+
import { HOST_SOCK_ENV, HostError, connectHost, resetHost, } from "@boardwalk-labs/workflow/runtime";
|
|
42
|
+
import { OUTPUT_MISMATCH_HINT, WorkflowHostServer } from "./host_server.js";
|
|
43
|
+
import { PYTHON_SOURCE_DIR, invokePythonProgram, isPythonEntry } from "./python_program.js";
|
|
44
|
+
import { AppError, ErrorCode, createLogger, errorCodeOf } from "./support/index.js";
|
|
34
45
|
const log = createLogger("ProgramRunner");
|
|
35
46
|
/** Subdirectory (under the work root) that holds transient extracted program trees. */
|
|
36
47
|
const RUN_DIR = ".bw-runs";
|
|
37
48
|
/**
|
|
38
49
|
* Link THIS runtime's `@boardwalk-labs/workflow` into the exec dir's `node_modules` so the program's
|
|
39
50
|
* bare import resolves from ANY program root — this link, not an ancestor `node_modules`, is what
|
|
40
|
-
* makes resolution work.
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
* resolves it to the REAL path, so the program gets the same module instance the host adapter was
|
|
45
|
-
* installed on (the singleton contract). `junction` covers Windows without elevation; a failed link
|
|
46
|
-
* is only logged, since a resolvable ancestor may still exist.
|
|
51
|
+
* makes resolution work. A symlink — not a copy — is load-bearing: Node resolves it to the REAL
|
|
52
|
+
* path, so the program gets the same module instance the loader's protocol client was installed
|
|
53
|
+
* on (the active-host singleton in the SDK's host_client). `junction` covers Windows without
|
|
54
|
+
* elevation; a failed link is only logged, since a resolvable ancestor may still exist.
|
|
47
55
|
*/
|
|
48
56
|
export async function ensureSdkLink(execDir) {
|
|
49
57
|
// A program tarball that ships its own `node_modules/@boardwalk-labs/workflow` would shadow the
|
|
50
|
-
// runtime's copy — the
|
|
51
|
-
//
|
|
52
|
-
// REAL entry is a vendored copy; a symlink is our own link
|
|
58
|
+
// runtime's copy — the loader's connected client lives on OUR instance, so the program's hooks
|
|
59
|
+
// would open a SECOND lazy connection at best and fail at worst. Fail loudly instead of
|
|
60
|
+
// link-then-EEXIST-swallow. Only a REAL entry is a vendored copy; a symlink is our own link
|
|
61
|
+
// (idempotent re-invocation), left be.
|
|
53
62
|
const linkPath = join(execDir, "node_modules", "@boardwalk-labs", "workflow");
|
|
54
63
|
const existing = await lstat(linkPath).catch(() => null);
|
|
55
64
|
if (existing !== null) {
|
|
@@ -86,13 +95,25 @@ export function resolveEntryPath(dir, entry) {
|
|
|
86
95
|
}
|
|
87
96
|
return resolved;
|
|
88
97
|
}
|
|
98
|
+
/**
|
|
99
|
+
* Resolve a program entry by LANGUAGE, binding the ratified artifact layout: a TS/JS entry is a
|
|
100
|
+
* BUILT module at the artifact root (`index.mjs` — the CLI bundles, the api-server type-strips);
|
|
101
|
+
* a `.py` entry is a SOURCE path under `.bw-src/` (`main.py` ⇒ `<dir>/.bw-src/main.py` — Python
|
|
102
|
+
* has no bundle step, the shipped source is what runs). A leading `./` on the stored entry is
|
|
103
|
+
* tolerated (`join` collapses it). Both lanes keep the containment guard; for Python the guard's
|
|
104
|
+
* base is the `.bw-src` tree itself, so an escaping entry (`../evil.py`) is rejected even when
|
|
105
|
+
* it would still land inside the extract dir.
|
|
106
|
+
*/
|
|
107
|
+
export function resolveProgramEntryPath(dir, entry) {
|
|
108
|
+
return isPythonEntry(entry)
|
|
109
|
+
? resolveEntryPath(join(dir, PYTHON_SOURCE_DIR), entry)
|
|
110
|
+
: resolveEntryPath(dir, entry);
|
|
111
|
+
}
|
|
89
112
|
/** Scratch filename for the in-flight artifact tarball inside a run's dir. */
|
|
90
113
|
const ARTIFACT_FILE = "__program.tgz";
|
|
91
114
|
/**
|
|
92
|
-
* Enforce I2: the extracted program must not live inside the run's workspace.
|
|
93
|
-
*
|
|
94
|
-
* workspace on exactly the lanes whose cwd was already correct. Compares RESOLVED paths, and treats
|
|
95
|
-
* "the workspace itself" as inside.
|
|
115
|
+
* Enforce I2: the extracted program must not live inside the run's workspace. Compares RESOLVED
|
|
116
|
+
* paths, and treats "the workspace itself" as inside.
|
|
96
117
|
*/
|
|
97
118
|
export function assertProgramRootOutsideWorkspace(programRoot, workspaceRoot) {
|
|
98
119
|
const rel = relative(resolve(workspaceRoot), resolve(programRoot));
|
|
@@ -103,44 +124,43 @@ export function assertProgramRootOutsideWorkspace(programRoot, workspaceRoot) {
|
|
|
103
124
|
/** An engine `EngineError` carries a one-line actionable `hint` alongside its message. It reaches us
|
|
104
125
|
* as a thrown value across the SDK/engine package boundary, so DUCK-TYPE it rather than `instanceof`
|
|
105
126
|
* (a dual-package copy of the class would defeat the check) — any thrown error carrying a non-empty
|
|
106
|
-
* string `hint` is surfaced.
|
|
107
|
-
*
|
|
108
|
-
*
|
|
127
|
+
* string `hint` is surfaced. A capability error that crossed the protocol carries its hint on
|
|
128
|
+
* `HostError.data.hint` (see host_server's protocolErrorOf), so that lane is read too — without it
|
|
129
|
+
* a hosted author would lose every engine hint to the wire. */
|
|
109
130
|
function errorHint(err) {
|
|
110
131
|
if (typeof err !== "object" || err === null)
|
|
111
132
|
return undefined;
|
|
112
133
|
const hint = err.hint;
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
const
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
*
|
|
123
|
-
* Two guards, because a thrown value is author-controlled: the SCREAMING_SNAKE shape keeps prose (or a
|
|
124
|
-
* stray object field) out of a field the UI renders as a code, and the CALLER redacts the result — an
|
|
125
|
-
* uppercase-alnum secret would satisfy the pattern, so the shape check is hygiene, not the boundary.
|
|
126
|
-
*/
|
|
127
|
-
function errorCode(err) {
|
|
128
|
-
if (typeof err !== "object" || err === null)
|
|
129
|
-
return undefined;
|
|
130
|
-
const code = err.code;
|
|
131
|
-
return typeof code === "string" && ERROR_CODE_RE.test(code) ? code : undefined;
|
|
134
|
+
if (typeof hint === "string" && hint !== "")
|
|
135
|
+
return hint;
|
|
136
|
+
const data = err.data;
|
|
137
|
+
if (typeof data === "object" && data !== null) {
|
|
138
|
+
const dataHint = data.hint;
|
|
139
|
+
if (typeof dataHint === "string" && dataHint !== "")
|
|
140
|
+
return dataHint;
|
|
141
|
+
}
|
|
142
|
+
return undefined;
|
|
132
143
|
}
|
|
133
144
|
/**
|
|
134
|
-
* Run a workflow program to completion
|
|
135
|
-
*
|
|
136
|
-
*
|
|
145
|
+
* Run a workflow program to completion: start the host-protocol server, extract the VERIFIED
|
|
146
|
+
* artifact, drive the loader (`bootstrap` → import entry → `run(input, context)` →
|
|
147
|
+
* `reportReturn`), and return the terminal result. Always tears the server + temp tree down.
|
|
137
148
|
*/
|
|
138
149
|
export async function runWorkflowProgram(args, deps) {
|
|
139
150
|
const dir = join(deps.programRoot, RUN_DIR, `${args.runId}-${randomUUID()}`);
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
151
|
+
const server = new WorkflowHostServer({
|
|
152
|
+
capabilities: deps.capabilities,
|
|
153
|
+
bootstrap: {
|
|
154
|
+
// Boundary cast: the input arrived as JSON (the trigger payload / run row), so it is
|
|
155
|
+
// wire-safe by construction.
|
|
156
|
+
input: (args.input ?? null),
|
|
157
|
+
inputSchema: args.inputSchema,
|
|
158
|
+
context: args.context,
|
|
159
|
+
},
|
|
160
|
+
outputSchema: args.outputSchema,
|
|
161
|
+
signal: deps.signal,
|
|
162
|
+
sockDir: deps.sockDir,
|
|
163
|
+
});
|
|
144
164
|
try {
|
|
145
165
|
assertProgramRootOutsideWorkspace(deps.programRoot, deps.workspaceRoot);
|
|
146
166
|
await mkdir(dir, { recursive: true });
|
|
@@ -152,28 +172,37 @@ export async function runWorkflowProgram(args, deps) {
|
|
|
152
172
|
await rm(tgzPath, { force: true });
|
|
153
173
|
// The bundled tree is now on disk — let the worker point the agent() leaf at `<dir>/skills/*.md`.
|
|
154
174
|
deps.onExtracted?.(dir);
|
|
155
|
-
//
|
|
156
|
-
//
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
//
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
//
|
|
163
|
-
//
|
|
164
|
-
|
|
175
|
+
// Language dispatch by entry extension (P5.5): a `.py` entry runs as a SUBPROCESS speaking
|
|
176
|
+
// the same host protocol (`python -m boardwalk._loader <entry>`, provided by the image's
|
|
177
|
+
// Python SDK — no JS SDK link to make); everything else keeps the in-process TS loader.
|
|
178
|
+
const python = isPythonEntry(args.entry);
|
|
179
|
+
// Make the bare `@boardwalk-labs/workflow` import resolve from ANY work root.
|
|
180
|
+
if (!python)
|
|
181
|
+
await ensureSdkLink(dir);
|
|
182
|
+
// Re-validate the entry here even though the control plane checked it at deploy (defense-in-depth
|
|
183
|
+
// for a self-hosted runner pointed at an arbitrary control plane).
|
|
184
|
+
const entryPath = resolveProgramEntryPath(dir, args.entry);
|
|
185
|
+
const sockPath = await server.listen();
|
|
186
|
+
// The ONE platform-owned env key a program keeps: how its SDK (and any subprocess speaking
|
|
187
|
+
// the protocol) finds the host — the documented discovery contract. Removed in finally.
|
|
188
|
+
process.env[HOST_SOCK_ENV] = sockPath;
|
|
189
|
+
// Invoke run(input, context) to its natural completion / failure. A waiting seam freezes with
|
|
190
|
+
// the VM (snapshot substrate) or holds the process — either way the await continues.
|
|
191
|
+
return python
|
|
192
|
+
? await invokePythonProgram(entryPath, dir, sockPath, server, deps)
|
|
193
|
+
: await invokeProgram(entryPath, sockPath, server, deps);
|
|
165
194
|
}
|
|
166
195
|
catch (err) {
|
|
167
196
|
const redactText = deps.redactText ?? ((s) => s);
|
|
168
197
|
// Redact BEFORE both sinks: the message can carry a secret the program resolved then threw.
|
|
169
198
|
const message = redactText(err instanceof Error ? err.message : String(err));
|
|
170
|
-
// The hint is author-facing guidance
|
|
171
|
-
//
|
|
199
|
+
// The hint is author-facing guidance; redact it too — it is built from the same untrusted
|
|
200
|
+
// inputs as the message and could echo a resolved secret.
|
|
172
201
|
const rawHint = errorHint(err);
|
|
173
202
|
const hint = rawHint === undefined ? undefined : redactText(rawHint);
|
|
174
203
|
// The error's own code when it has one ("VALIDATION"), else the class name ("Error"). Redacted
|
|
175
204
|
// like the rest: it is read off an author-controlled throw, not trusted to be a literal.
|
|
176
|
-
const rawCode =
|
|
205
|
+
const rawCode = errorCodeOf(err);
|
|
177
206
|
const code = rawCode !== undefined
|
|
178
207
|
? redactText(rawCode)
|
|
179
208
|
: err instanceof Error
|
|
@@ -191,7 +220,11 @@ export async function runWorkflowProgram(args, deps) {
|
|
|
191
220
|
};
|
|
192
221
|
}
|
|
193
222
|
finally {
|
|
194
|
-
|
|
223
|
+
Reflect.deleteProperty(process.env, HOST_SOCK_ENV);
|
|
224
|
+
// Clear the SDK's active-host singleton so a later run (tests; the local path) reconnects
|
|
225
|
+
// fresh rather than reusing a client whose server is gone.
|
|
226
|
+
resetHost();
|
|
227
|
+
await server.close();
|
|
195
228
|
await rm(dir, { recursive: true, force: true }).catch((rmErr) => {
|
|
196
229
|
log.warn("program_cleanup_failed", {
|
|
197
230
|
runId: args.runId,
|
|
@@ -201,19 +234,15 @@ export async function runWorkflowProgram(args, deps) {
|
|
|
201
234
|
}
|
|
202
235
|
}
|
|
203
236
|
/**
|
|
204
|
-
*
|
|
205
|
-
*
|
|
206
|
-
*
|
|
207
|
-
* vitest/vite from statically analyzing the runtime URL.
|
|
237
|
+
* The LOADER (P3.1): connect the protocol client, `bootstrap()`, import the entry, call its
|
|
238
|
+
* default-export `run(input, context)`, and report the return. Resolves to a `completed` result;
|
|
239
|
+
* a failure THROWS and is curated by the caller.
|
|
208
240
|
*
|
|
209
|
-
* The chdir to the workspace (I1) happens HERE, at the boundary where author code starts
|
|
210
|
-
*
|
|
211
|
-
*
|
|
212
|
-
* Node resolves file-relative (the entry is imported by absolute URL, the SDK via `ensureSdkLink`),
|
|
213
|
-
* never cwd-relative. The engine's local path has run exactly this shape since dev-on-engine
|
|
214
|
-
* (`boardwalk/src/run/child.ts`), and the self-hosted daemon spawns with the same cwd.
|
|
241
|
+
* The chdir to the workspace (I1) happens HERE, at the boundary where author code starts.
|
|
242
|
+
* Module resolution is unaffected: Node resolves file-relative (the entry is imported by
|
|
243
|
+
* absolute URL, the SDK via `ensureSdkLink`), never cwd-relative.
|
|
215
244
|
*/
|
|
216
|
-
async function
|
|
245
|
+
async function invokeProgram(entryPath, sockPath, server, deps) {
|
|
217
246
|
const callerCwd = process.cwd();
|
|
218
247
|
try {
|
|
219
248
|
process.chdir(deps.workspaceRoot);
|
|
@@ -222,20 +251,46 @@ async function runProgramBody(entryPath, deps) {
|
|
|
222
251
|
// Fail loud. Running author code from an arbitrary cwd is what silently threw writes away.
|
|
223
252
|
throw new AppError(ErrorCode.VALIDATION_FAILED, `The run's workspace "${deps.workspaceRoot}" is not usable as the working directory: ${err instanceof Error ? err.message : String(err)}`);
|
|
224
253
|
}
|
|
254
|
+
let client = null;
|
|
225
255
|
try {
|
|
226
|
-
|
|
227
|
-
//
|
|
228
|
-
//
|
|
229
|
-
|
|
230
|
-
const
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
256
|
+
// Connect eagerly and install as the SDK's active host: the program's capability imports
|
|
257
|
+
// (same module instance, via ensureSdkLink) then share this client instead of lazily opening
|
|
258
|
+
// a second connection.
|
|
259
|
+
client = await connectHost({ sockPath });
|
|
260
|
+
const { input, context } = await client.bootstrap();
|
|
261
|
+
// A unique dir per run gives a fresh URL so the module cache never returns an already-loaded
|
|
262
|
+
// program; `@vite-ignore` keeps vitest/vite from statically analyzing the runtime URL.
|
|
263
|
+
const mod = await import(/* @vite-ignore */ pathToFileURL(entryPath).href);
|
|
264
|
+
const runFn = mod.default;
|
|
265
|
+
if (typeof runFn !== "function") {
|
|
266
|
+
throw Object.assign(new Error("The workflow entry has no `run` function default export."), {
|
|
267
|
+
code: "VALIDATION",
|
|
268
|
+
hint: "Export the entry as `export default async function run(input, context) { … }`.",
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
// Positional, Lambda-style: input = param 0, context = param 1; a run() declaring fewer
|
|
272
|
+
// params simply ignores the rest.
|
|
273
|
+
const value = await runFn(input, context);
|
|
274
|
+
try {
|
|
275
|
+
await client.reportReturn(value);
|
|
276
|
+
}
|
|
277
|
+
catch (err) {
|
|
278
|
+
if (err instanceof HostError && err.code === "VALIDATION_FAILED") {
|
|
279
|
+
// Curate the schema mismatch: message = what's wrong (the server's detail), hint = what
|
|
280
|
+
// to do. The failure code rides the HostError's own code.
|
|
281
|
+
throw Object.assign(err, { hint: OUTPUT_MISMATCH_HINT });
|
|
282
|
+
}
|
|
283
|
+
throw err;
|
|
284
|
+
}
|
|
285
|
+
const output = server.reportedReturn();
|
|
286
|
+
if (output !== null)
|
|
287
|
+
deps.onOutput?.(output);
|
|
288
|
+
return { kind: "completed", output };
|
|
234
289
|
}
|
|
235
290
|
finally {
|
|
236
|
-
|
|
237
|
-
//
|
|
238
|
-
//
|
|
291
|
+
client?.close();
|
|
292
|
+
// One run per process in production, so this matters for tests + the local path — but a
|
|
293
|
+
// function that permanently moves its caller's cwd is a trap either way. Best-effort.
|
|
239
294
|
try {
|
|
240
295
|
process.chdir(callerCwd);
|
|
241
296
|
}
|
|
@@ -1,13 +1,19 @@
|
|
|
1
1
|
import type { Run } from "./wire/run.js";
|
|
2
2
|
import { type WorkflowManifest } from "./wire/manifest.js";
|
|
3
|
-
import type {
|
|
3
|
+
import type { HostCapabilities } from "./host_server.js";
|
|
4
|
+
import { type ClaimContextExtras } from "./run_context.js";
|
|
4
5
|
import type { SecretRedactor } from "./agent/secret_redactor.js";
|
|
5
6
|
import { type LogStream } from "./program_log_capture.js";
|
|
6
7
|
/** Default 5-minute lease (matches the engine spec). */
|
|
7
8
|
export declare const DEFAULT_LEASE_MS: number;
|
|
8
|
-
/** Race-safe claim surface —
|
|
9
|
+
/** Race-safe claim surface — the broker claim adapter satisfies it. Returns the claimed run row
|
|
10
|
+
* plus the claim payload's context siblings (`workflowVersion` int, `environment {id,name}|null`
|
|
11
|
+
* — P3.7), or null when the claim was lost. */
|
|
9
12
|
export interface RunClaimer {
|
|
10
|
-
claimForWorker(runId: string, workerId: string, leaseUntil: number, nowMs: number): Promise<
|
|
13
|
+
claimForWorker(runId: string, workerId: string, leaseUntil: number, nowMs: number): Promise<{
|
|
14
|
+
run: Run;
|
|
15
|
+
context: ClaimContextExtras;
|
|
16
|
+
} | null>;
|
|
11
17
|
}
|
|
12
18
|
/** The pinned program's download reference (the worker fetches + verifies + extracts it). */
|
|
13
19
|
export interface ProgramRef {
|
|
@@ -63,15 +69,14 @@ export interface LspLifecycleHandle {
|
|
|
63
69
|
export interface RunOutputHandle {
|
|
64
70
|
output(value: unknown): void;
|
|
65
71
|
}
|
|
66
|
-
/** Builds the per-run
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
* handle
|
|
72
|
-
* terminal (the host's `sleep` also persists, wired inside buildHost). */
|
|
72
|
+
/** Builds the per-run capability seam (leaf + sleep + children + secrets + shell + usage + auth)
|
|
73
|
+
* for a claimed run — what the host-protocol server dispatches onto. Receives the run's
|
|
74
|
+
* cooperative-cancellation `signal` so every hook honors it (credit exhaustion / cancel).
|
|
75
|
+
* Returns the run's `SecretRedactor` alongside so the orchestrator can scrub a terminal
|
|
76
|
+
* error with the SAME instance every resolved secret was recorded into, and an optional
|
|
77
|
+
* `workspace` handle the orchestrator hydrates at start + persists at terminal. */
|
|
73
78
|
export type ProgramHostBuilder = (run: Run, manifest: WorkflowManifest, signal: AbortSignal) => Promise<{
|
|
74
|
-
|
|
79
|
+
capabilities: HostCapabilities;
|
|
75
80
|
redactor: SecretRedactor;
|
|
76
81
|
workspace?: WorkspaceHandle;
|
|
77
82
|
phases?: PhaseLifecycleHandle;
|
|
@@ -98,6 +103,12 @@ export type ProgramHostBuilder = (run: Run, manifest: WorkflowManifest, signal:
|
|
|
98
103
|
start(): Promise<void>;
|
|
99
104
|
stopAndFlush(): Promise<void>;
|
|
100
105
|
};
|
|
106
|
+
/** The compute-budget breach watcher (budget_gate.ts): detects a `max_compute_seconds` breach
|
|
107
|
+
* between capability seams. The orchestrator stops it on EVERY terminal path (its interval is
|
|
108
|
+
* unref'd, so this is hygiene, not liveness). Absent on paths without a budget gate. */
|
|
109
|
+
budgetWatch?: {
|
|
110
|
+
stop(): void;
|
|
111
|
+
};
|
|
101
112
|
}>;
|
|
102
113
|
/** Handle to a running per-session loop (metering or credit watch); `stop()` ends + drains it. */
|
|
103
114
|
export interface RunSessionHandle {
|
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
// v0 deferral (a clear seam): run-level outcome validation (needs program output capture).
|
|
23
23
|
import { createLogger } from "./support/index.js";
|
|
24
24
|
import { workflowManifestSchema } from "./wire/manifest.js";
|
|
25
|
+
import { buildContextData } from "./run_context.js";
|
|
25
26
|
// Import from the pure file (NOT the domain/workflow barrel, which pulls in `typescript`) so the
|
|
26
27
|
// worker bundle stays free of the TS compiler — the worker never transpiles.
|
|
27
28
|
import { verifyArtifactDigest } from "./wire/artifact_verify.js";
|
|
@@ -36,20 +37,23 @@ export async function runProgramWorker(runId, deps) {
|
|
|
36
37
|
const leaseMs = deps.leaseMs ?? DEFAULT_LEASE_MS;
|
|
37
38
|
// Runtime-billing baseline: when THIS worker session began.
|
|
38
39
|
const sessionStartMs = now();
|
|
39
|
-
const
|
|
40
|
-
if (
|
|
40
|
+
const claim = await deps.runs.claimForWorker(runId, deps.workerId, sessionStartMs + leaseMs, sessionStartMs);
|
|
41
|
+
if (claim === null) {
|
|
41
42
|
log.info("worker_claim_lost", { runId, workerId: deps.workerId });
|
|
42
43
|
return { kind: "claim_lost" };
|
|
43
44
|
}
|
|
44
45
|
log.info("worker_claimed", { runId, workerId: deps.workerId });
|
|
46
|
+
const claimed = claim.run;
|
|
47
|
+
/** Pre-flight/setup failure: no program ran (and none will) — finalize failed and exit. */
|
|
48
|
+
const failEarly = async (reason, error, logEvent, logFields) => {
|
|
49
|
+
await deps.finalizer.finalize(runId, "failed", { error });
|
|
50
|
+
log.error(logEvent, { runId, ...logFields });
|
|
51
|
+
return { kind: "failed", reason };
|
|
52
|
+
};
|
|
45
53
|
const loaded = await loadVersion(deps, claimed);
|
|
46
54
|
if (loaded === null) {
|
|
47
55
|
// Pre-flight integrity failure — no program ran, so no charge.
|
|
48
|
-
|
|
49
|
-
error: { code: "INTERNAL_ERROR", message: "Run version missing, or manifest invalid" },
|
|
50
|
-
});
|
|
51
|
-
log.error("worker_version_invalid", { runId, workflowVersionId: claimed.workflowVersionId });
|
|
52
|
-
return { kind: "failed", reason: "version_invalid" };
|
|
56
|
+
return failEarly("version_invalid", { code: "INTERNAL_ERROR", message: "Run version missing, or manifest invalid" }, "worker_version_invalid", { workflowVersionId: claimed.workflowVersionId });
|
|
53
57
|
}
|
|
54
58
|
// Fetch + verify the program ARTIFACT before any work (pre-flight; an integrity failure = no charge,
|
|
55
59
|
// no run). The bytes are the EXACT artifact the manifest was derived from at deploy; verifying the
|
|
@@ -59,38 +63,10 @@ export async function runProgramWorker(runId, deps) {
|
|
|
59
63
|
tarball = await deps.fetchProgram(loaded.program.downloadUrl);
|
|
60
64
|
}
|
|
61
65
|
catch (err) {
|
|
62
|
-
|
|
63
|
-
error: { code: "PROGRAM_FETCH_FAILED", message: "Could not download the program artifact" },
|
|
64
|
-
});
|
|
65
|
-
log.error("worker_program_fetch_failed", {
|
|
66
|
-
runId,
|
|
67
|
-
error: err instanceof Error ? err.message : String(err),
|
|
68
|
-
});
|
|
69
|
-
return { kind: "failed", reason: "program_fetch_failed" };
|
|
66
|
+
return failEarly("program_fetch_failed", { code: "PROGRAM_FETCH_FAILED", message: "Could not download the program artifact" }, "worker_program_fetch_failed", { error: err instanceof Error ? err.message : String(err) });
|
|
70
67
|
}
|
|
71
68
|
if (!verifyArtifactDigest(tarball, loaded.program.digest)) {
|
|
72
|
-
|
|
73
|
-
error: { code: "PROGRAM_INTEGRITY", message: "Program artifact digest mismatch" },
|
|
74
|
-
});
|
|
75
|
-
log.error("worker_program_integrity", { runId, workflowVersionId: claimed.workflowVersionId });
|
|
76
|
-
return { kind: "failed", reason: "program_integrity" };
|
|
77
|
-
}
|
|
78
|
-
// deadline_seconds: a WALL-CLOCK cap from the run's ORIGINAL start (incl. suspended idle). Enforced
|
|
79
|
-
// HERE, before running — so a run RESUMED past its deadline (e.g. it slept/awaited longer than the
|
|
80
|
-
// cap) fails even when its program has NO agent() turn (the BudgetMeter's per-turn check would never
|
|
81
|
-
// fire for those). Orthogonal to max_duration_seconds (active compute, the BudgetMeter's job).
|
|
82
|
-
const deadlineSeconds = loaded.manifest.budget?.deadline_seconds;
|
|
83
|
-
if (deadlineSeconds !== undefined &&
|
|
84
|
-
claimed.startedAt !== null &&
|
|
85
|
-
Date.now() - claimed.startedAt > deadlineSeconds * 1000) {
|
|
86
|
-
await deps.finalizer.finalize(runId, "failed", {
|
|
87
|
-
error: {
|
|
88
|
-
code: "BUDGET_EXCEEDED",
|
|
89
|
-
message: `Run exceeded budget.deadline_seconds (${deadlineSeconds.toString()}s wall-clock) and was terminated.`,
|
|
90
|
-
},
|
|
91
|
-
});
|
|
92
|
-
log.info("worker_deadline_exceeded", { runId, deadlineSeconds });
|
|
93
|
-
return { kind: "failed", reason: "deadline_exceeded" };
|
|
69
|
+
return failEarly("program_integrity", { code: "PROGRAM_INTEGRITY", message: "Program artifact digest mismatch" }, "worker_program_integrity", { workflowVersionId: claimed.workflowVersionId });
|
|
94
70
|
}
|
|
95
71
|
// Cooperative-cancellation signal for the run. The credit watcher (and, later, user-initiated
|
|
96
72
|
// cancel) aborts it; the WorkflowHost honors it at every hook so the program unwinds.
|
|
@@ -104,15 +80,9 @@ export async function runProgramWorker(runId, deps) {
|
|
|
104
80
|
}
|
|
105
81
|
catch (err) {
|
|
106
82
|
const message = err instanceof Error ? err.message : String(err);
|
|
107
|
-
|
|
108
|
-
error: { code: "HOST_BUILD_FAILED", message },
|
|
109
|
-
});
|
|
110
|
-
log.error("worker_host_build_failed", { runId, error: message });
|
|
111
|
-
return { kind: "failed", reason: "host_build_failed" };
|
|
83
|
+
return failEarly("host_build_failed", { code: "HOST_BUILD_FAILED", message }, "worker_host_build_failed", { error: message });
|
|
112
84
|
}
|
|
113
|
-
const {
|
|
114
|
-
const browserSessions = built.browserSessions;
|
|
115
|
-
const capture = built.capture;
|
|
85
|
+
const { capabilities, redactor, workspace, phases, activity, setProgramDir, lsp, browserSessions, capture, budgetWatch, } = built;
|
|
116
86
|
// Guarantee the /workspace sandbox dir exists for EVERY run (persist or not) so a program can write
|
|
117
87
|
// to /workspace without a defensive mkdir. Runs before hydrate (whose extract targets the dir).
|
|
118
88
|
// Best-effort — the image pre-creates the dir; a failure here would resurface at the program's write.
|
|
@@ -184,16 +154,21 @@ export async function runProgramWorker(runId, deps) {
|
|
|
184
154
|
tarball,
|
|
185
155
|
entry: loaded.program.entry,
|
|
186
156
|
input: claimed.input ?? claimed.triggerPayload,
|
|
187
|
-
|
|
157
|
+
// The stored derived I/O schemas ride the manifest (P4): the input schema crosses on
|
|
158
|
+
// `bootstrap` (the SDK's revival pass), the output schema gates `report_return`.
|
|
159
|
+
inputSchema: loaded.manifest.input_schema ?? null,
|
|
160
|
+
outputSchema: loaded.manifest.output_schema ?? null,
|
|
161
|
+
context: buildContextData(claimed, deps.workspaceRoot, claim.context),
|
|
188
162
|
},
|
|
189
163
|
// redactText scrubs a thrown error's message before it is logged + finalized into run output.
|
|
190
|
-
// onOutput emits the `output` activity entry into the run's log when the program
|
|
164
|
+
// onOutput emits the `output` activity entry into the run's log when the program returned one.
|
|
191
165
|
{
|
|
192
|
-
|
|
166
|
+
capabilities,
|
|
193
167
|
workspaceRoot: deps.workspaceRoot,
|
|
194
168
|
programRoot: deps.programRoot,
|
|
195
169
|
redactText: (text) => redactor.redactText(text),
|
|
196
170
|
extract: deps.extractArchive,
|
|
171
|
+
signal: controller.signal,
|
|
197
172
|
...(setProgramDir !== undefined ? { onExtracted: setProgramDir } : {}),
|
|
198
173
|
...(activity !== undefined
|
|
199
174
|
? {
|
|
@@ -216,6 +191,9 @@ export async function runProgramWorker(runId, deps) {
|
|
|
216
191
|
await lease.stop();
|
|
217
192
|
if (runtimeFlush !== undefined)
|
|
218
193
|
await runtimeFlush.stop();
|
|
194
|
+
// Stop the compute-budget breach watcher (sync, never throws).
|
|
195
|
+
if (budgetWatch !== undefined)
|
|
196
|
+
budgetWatch.stop();
|
|
219
197
|
// Shut down the run's language server(s) on EVERY terminal path (success/failure/throw) so no
|
|
220
198
|
// language-server process leaks. `close()` is idempotent + never throws, so this best-effort call
|
|
221
199
|
// is safe (it runs before the workspace snapshot, which is the slowest step).
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { type WorkflowHostServer } from "./host_server.js";
|
|
2
|
+
import type { ProgramResult, ProgramRunnerDeps } from "./program_runner.js";
|
|
3
|
+
/** Default interpreter, resolved on PATH — the base image bakes one CPython (P5.3). */
|
|
4
|
+
export declare const DEFAULT_PYTHON_INTERPRETER = "python3";
|
|
5
|
+
/** Where the ratified Python artifact layout keeps the author's SOURCE tree. A stored Python
|
|
6
|
+
* entry (`main.py`) is relative to THIS dir — Python has no bundle step, so the shipped source
|
|
7
|
+
* is what runs (`<extractDir>/.bw-src/main.py`), never a root module like the TS `index.mjs`. */
|
|
8
|
+
export declare const PYTHON_SOURCE_DIR = ".bw-src";
|
|
9
|
+
/** Where the ratified layout keeps the uv-materialized frozen dependency tree (build-time
|
|
10
|
+
* `uv lock` → `export --frozen` → `pip install --target`; never installed on the hot path). */
|
|
11
|
+
export declare const PYTHON_SITE_PACKAGES_DIR: string;
|
|
12
|
+
/**
|
|
13
|
+
* The platform-owned `PYTHONPATH` for a Python program run:
|
|
14
|
+
*
|
|
15
|
+
* <programDir>/.bw-src <sep> <programDir>/.bw-machine/site-packages
|
|
16
|
+
*
|
|
17
|
+
* ORDER (deliberate): the author's sources BEFORE the frozen deps, so an author module wins a
|
|
18
|
+
* name collision with a dependency. That mirrors CPython's own convention — the interpreter puts
|
|
19
|
+
* a script's directory ahead of site-packages — extended to the whole tree, which needs an
|
|
20
|
+
* explicit entry because the loader is launched `-m` style (sys.path[0] is the workspace cwd,
|
|
21
|
+
* not `.bw-src`) yet sibling imports (`import helper`) must resolve from the source tree. The
|
|
22
|
+
* file an author can SEE in the Code tab beating an invisible dep is the predictable reading.
|
|
23
|
+
*
|
|
24
|
+
* The value REPLACES any PYTHONPATH inherited from the worker's env: for a Python run the
|
|
25
|
+
* module path is platform-owned (the guest image provides python3 with the `boardwalk` loader
|
|
26
|
+
* package importable at site level; nothing from the worker's own environment may steer author
|
|
27
|
+
* import resolution). A no-dep package ships no site-packages dir at all — Python silently
|
|
28
|
+
* skips a nonexistent sys.path entry, so the value is set unconditionally, never stat-gated.
|
|
29
|
+
*/
|
|
30
|
+
export declare function pythonModulePath(programDir: string): string;
|
|
31
|
+
/** The loader module the guest image's `boardwalk` package provides (`python -m <module> <entry>`). */
|
|
32
|
+
export declare const PYTHON_LOADER_MODULE = "boardwalk._loader";
|
|
33
|
+
/** How long after SIGTERM an aborted child gets before SIGKILL. */
|
|
34
|
+
export declare const DEFAULT_PYTHON_KILL_GRACE_MS = 5000;
|
|
35
|
+
/** How many trailing stderr lines are kept for failure curation (the traceback tail). */
|
|
36
|
+
export declare const STDERR_TAIL_LINES = 20;
|
|
37
|
+
/** The language dispatch decision (P5.5): `.py` takes the subprocess path; everything else
|
|
38
|
+
* (`.ts`/`.js`/`.mjs`) keeps the in-process TS loader. Case-insensitive on the extension. */
|
|
39
|
+
export declare function isPythonEntry(entry: string): boolean;
|
|
40
|
+
/** Split a piped stream into complete lines (LF or CRLF). `flush()` emits a trailing partial
|
|
41
|
+
* line (a crash mid-line must not swallow the last words of the traceback). */
|
|
42
|
+
export declare function lineSplitter(onLine: (line: string) => void): {
|
|
43
|
+
push: (chunk: string) => void;
|
|
44
|
+
flush: () => void;
|
|
45
|
+
};
|
|
46
|
+
/** What the child process ended as, for failure curation. */
|
|
47
|
+
export interface PythonExitFacts {
|
|
48
|
+
exitCode: number | null;
|
|
49
|
+
signal: NodeJS.Signals | null;
|
|
50
|
+
/** The last {@link STDERR_TAIL_LINES} stderr lines, oldest first. */
|
|
51
|
+
stderrTail: readonly string[];
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Curate a child that ended WITHOUT completing the loader contract (no `report_return`) into a
|
|
55
|
+
* throwable the runner's failure path understands (`{code, message, hint}`, duck-typed). The
|
|
56
|
+
* caller redacts — nothing here needs to.
|
|
57
|
+
*/
|
|
58
|
+
export declare function curatePythonFailure(facts: PythonExitFacts): Error;
|
|
59
|
+
/** Fail CLOSED on a spawn failure: a missing interpreter gets an UNSUPPORTED-class error naming
|
|
60
|
+
* the image expectation instead of a bare ENOENT; anything else passes through untouched. */
|
|
61
|
+
export declare function curateSpawnFailure(interpreter: string, err: NodeJS.ErrnoException): Error;
|
|
62
|
+
/**
|
|
63
|
+
* Run a Python workflow program to completion: spawn the loader subprocess against the already
|
|
64
|
+
* -listening host server, stream its output into the program log capture, and read the terminal
|
|
65
|
+
* state off the server once the child exits. Resolves `completed` when `report_return` landed;
|
|
66
|
+
* THROWS on failure (the caller curates + redacts, same as the TS path).
|
|
67
|
+
*/
|
|
68
|
+
export declare function invokePythonProgram(entryPath: string, programDir: string, sockPath: string, server: WorkflowHostServer, deps: ProgramRunnerDeps): Promise<ProgramResult>;
|