@boardwalk-labs/runner 0.2.16 → 0.3.0-alpha.1

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.
Files changed (39) hide show
  1. package/README.md +1 -1
  2. package/dist/bin.js +5 -0
  3. package/dist/runtime/agent/budget.d.ts +43 -24
  4. package/dist/runtime/agent/budget.js +108 -57
  5. package/dist/runtime/agent/events.d.ts +1 -1
  6. package/dist/runtime/broker_child_dispatcher.d.ts +5 -2
  7. package/dist/runtime/broker_child_dispatcher.js +24 -6
  8. package/dist/runtime/budget_gate.d.ts +58 -21
  9. package/dist/runtime/budget_gate.js +227 -46
  10. package/dist/runtime/host_capabilities.d.ts +4 -0
  11. package/dist/runtime/host_capabilities.js +25 -0
  12. package/dist/runtime/host_server.d.ts +137 -0
  13. package/dist/runtime/host_server.js +562 -0
  14. package/dist/runtime/identity_relay.js +19 -9
  15. package/dist/runtime/index.d.ts +38 -0
  16. package/dist/runtime/index.js +109 -35
  17. package/dist/runtime/leaf_executor.d.ts +7 -4
  18. package/dist/runtime/leaf_executor.js +11 -6
  19. package/dist/runtime/program_runner.d.ts +62 -42
  20. package/dist/runtime/program_runner.js +156 -101
  21. package/dist/runtime/program_worker.d.ts +22 -11
  22. package/dist/runtime/program_worker.js +26 -48
  23. package/dist/runtime/python_program.d.ts +68 -0
  24. package/dist/runtime/python_program.js +270 -0
  25. package/dist/runtime/run_context.d.ts +13 -0
  26. package/dist/runtime/run_context.js +77 -0
  27. package/dist/runtime/runner_control_client.d.ts +23 -0
  28. package/dist/runtime/runner_control_client.js +69 -147
  29. package/dist/runtime/screen_capture_backend.d.ts +3 -0
  30. package/dist/runtime/screen_capture_backend.js +17 -2
  31. package/dist/runtime/shell_exec.d.ts +17 -0
  32. package/dist/runtime/shell_exec.js +144 -0
  33. package/dist/runtime/support/index.d.ts +18 -0
  34. package/dist/runtime/support/index.js +38 -6
  35. package/dist/runtime/wire/inference_proxy.js +1 -1
  36. package/dist/runtime/wire/run.d.ts +5 -2
  37. package/dist/runtime/workflow_host.d.ts +53 -7
  38. package/dist/runtime/workflow_host.js +82 -42
  39. package/package.json +4 -3
@@ -1,55 +1,64 @@
1
- // WorkflowProgramRunner — executes a workflow program (the JS-body model, the workflow runtime design).
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: it installs the host adapter + trigger payload onto the
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
- // The artifact is already BUILT JS (the CLI esbuild-bundles packages; the api-server type-strips
12
- // single files) so the worker NEVER transpiles and NEVER installs. It only extracts + imports.
13
- // `@boardwalk-labs/workflow` is left external in the bundle, so the imported program resolves it to the SDK
14
- // package present in the worker image (giving up its own copy would break the dual-adapter).
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
- // Two places, both explicit, neither derived from `process.cwd()` (docs/WORKSPACE_PERSISTENCE.md I1/I2):
17
- // - `workspaceRoot` the run's `/workspace`. The working directory + HOME for AUTHOR code, so a
18
- // relative write is the correct write and lands in what `workspace.persist` archives.
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
- // Durability: the body runs once, in-process. `sleep`/`workflows.call` hold in-process via the host
25
- // (no checkpoint, no exit). A crash mid-run restarts the run from the top (handled by the
26
- // worker/scheduler-sweep, not here). Output capture is deferred (v0 returns null).
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 { installHost, installInput, installConfig, takeDeclaredOutput, resetRuntime, } from "@boardwalk-labs/workflow/runtime";
33
- import { AppError, ErrorCode, createLogger } from "./support/index.js";
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. (Worth stating plainly: a stale comment claiming the extraction dir had to
41
- * sit under `/app` so the import could walk up to `/app/node_modules` outlived this function by
42
- * several versions and sent a later reader chasing a dependency that no longer exists. The live
43
- * fleet has no `/app` at all and resolves fine.) A symlink — not a copy is load-bearing: Node
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 host adapter is installed on OUR instance, so the program's hooks would
51
- // silently throw "no host installed". Fail loudly instead of link-then-EEXIST-swallow. Only a
52
- // REAL entry is a vendored copy; a symlink is our own link (idempotent re-invocation), left be.
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. Incidental separation
93
- * is what broke before — the extraction root used to be `process.cwd()`, so it landed inside the
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. Without this the hint is dropped here, and on a hosted run there is no
107
- * other place it could survive: the runner reports a failure as `{ code, message, hint? }` and the
108
- * broker persists exactly that. */
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
- return typeof hint === "string" && hint !== "" ? hint : undefined;
114
- }
115
- /** A machine-readable error code shaped like one: SCREAMING_SNAKE, as an engine `EngineError.code`
116
- * (`VALIDATION`, `PROVIDER_ERROR`, …) and a Node syscall error (`ENOENT`) both are. */
117
- const ERROR_CODE_RE = /^[A-Z][A-Z0-9_]{0,63}$/;
118
- /**
119
- * The SEMANTIC code of a thrown error, preferred over its class name. An engine `EngineError` carries
120
- * `code: "VALIDATION"`; reporting `err.name` instead surfaced the useless `"EngineError"` on every
121
- * hosted failure (and `"Error"` for anything else). Duck-typed for the same reason as {@link errorHint}.
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. Installs the host + input, extracts the VERIFIED artifact +
135
- * dynamic-imports its entry (which runs the body), and returns the terminal result. Always tears the
136
- * runtime state down and removes the temp tree afterward.
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
- installHost(deps.host);
141
- installInput(args.input);
142
- // The run row's config is arbitrary JSON (jsonb); it IS valid JSON, so narrow to the SDK's JsonValue.
143
- installConfig(args.config);
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
- // Make the bare `@boardwalk-labs/workflow` import resolve from ANY work root hosted images
156
- // provide it via an ancestor node_modules, but a self-hosted daemon's workspace has none.
157
- await ensureSdkLink(dir);
158
- // Re-validate the entry here even though the control plane checked it at deploy: a self-hosted
159
- // runner may point at an arbitrary control plane, so refuse an entry that could escape the
160
- // extraction dir (absolute path or `..` segment) before importing it.
161
- const entryPath = resolveEntryPath(dir, args.entry);
162
- // Run the program body to its natural completion / failure. A waiting seam freezes with the
163
- // VM (snapshot substrate) or holds the process either way the body's own await continues.
164
- return await runProgramBody(entryPath, deps);
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 ("write `builtins: [...]`"); redact it too — it is built from
171
- // the same untrusted inputs as the message and could echo a resolved secret.
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 = errorCode(err);
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
- resetRuntime();
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
- * Import (= run) the program entry and capture its declared output. Resolves to a `completed`
205
- * result; a program failure THROWS and is handled by the caller. A unique dir per run gives a
206
- * fresh URL so the module cache never returns an already-run program; `@vite-ignore` keeps
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, and only
210
- * after the artifact is extracted from {@link ProgramRunnerDeps.programRoot} so the program's
211
- * files land in the workspace while the bundle stays out of it. Module resolution is unaffected:
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 runProgramBody(entryPath, deps) {
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
- await import(/* @vite-ignore */ pathToFileURL(entryPath).href);
227
- // The program declares its result via `output(value)` (top-level code can't `return`); null when it
228
- // never called it. This becomes the run's persisted output + a `workflows.call` parent's value, and
229
- // (when actually declared) an `output` entry in the run's activity log.
230
- const declared = takeDeclaredOutput();
231
- if (declared !== null)
232
- deps.onOutput?.(declared.value);
233
- return { kind: "completed", output: declared !== null ? declared.value : null };
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
- // One run per process in production, so this matters for tests + the local/dev path — but a
237
- // function that permanently moves its caller's cwd is a trap either way. Best-effort: the caller's
238
- // dir may itself be gone, and that must not mask the run's real outcome.
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 { WorkflowHost } from "@boardwalk-labs/workflow/runtime";
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 — RunRepository satisfies it. */
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<Run | null>;
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 host (leaf + sleep + children + secrets) for a claimed run. Receives the run's
67
- * cooperative-cancellation `signal` so every host hook honors it (credit exhaustion / cancel).
68
- * Returns the run's `SecretRedactor` alongside the host so the orchestrator can scrub a terminal
69
- * error with the SAME instance every resolved secret was recorded into; `readUsage` — a
70
- * sampler over the run-level BudgetMeter the token flusher meters from; and an optional `workspace`
71
- * handle (when the workflow opted into persistence) the orchestrator hydrates at start + persists at
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
- host: WorkflowHost;
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 claimed = await deps.runs.claimForWorker(runId, deps.workerId, sessionStartMs + leaseMs, sessionStartMs);
40
- if (claimed === null) {
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
- await deps.finalizer.finalize(runId, "failed", {
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
- await deps.finalizer.finalize(runId, "failed", {
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
- await deps.finalizer.finalize(runId, "failed", {
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
- await deps.finalizer.finalize(runId, "failed", {
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 { host, redactor, workspace, phases, activity, setProgramDir, lsp } = built;
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
- config: claimed.config ?? {},
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 declared one.
164
+ // onOutput emits the `output` activity entry into the run's log when the program returned one.
191
165
  {
192
- host,
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>;