@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
@@ -26,7 +26,7 @@
26
26
  //
27
27
  // Documented v0 deferral (a clear seam): durable events — a no-op AgentEventStore (live fan-out
28
28
  // via the broker only) until durable event storage lands.
29
- import { createLogger, newId, AppError, ErrorCode, DEFAULT_LEASE_MS } from "./support/index.js";
29
+ import { createLogger, configureLogging, newId, AppError, ErrorCode, DEFAULT_LEASE_MS, } from "./support/index.js";
30
30
  import { LspService } from "@boardwalk-labs/engine/core";
31
31
  import { BudgetMeter } from "./agent/budget.js";
32
32
  import { WorkerRunEventEmitter } from "./run_event_emitter.js";
@@ -34,17 +34,19 @@ import { BUDGET_GUARDRAIL_RATE } from "./agent/model_rates.js";
34
34
  import { SecretRedactor } from "./agent/secret_redactor.js";
35
35
  import { RecordingSecretResolver } from "./recording_secret_resolver.js";
36
36
  import { EngineLeafExecutor } from "./leaf_executor.js";
37
- import { BudgetGate } from "./budget_gate.js";
37
+ import { BudgetGate, ComputeBreachWatcher } from "./budget_gate.js";
38
38
  import { parseByoProviders } from "./direct_inference.js";
39
39
  import { applyIdentityToEnv, connectIdentityRelayFd, relayFdFromEnv, workerDiagnostics, } from "./identity_relay.js";
40
40
  import { FreezeCoordinator } from "./freeze_coordinator.js";
41
41
  import { reseedUserspaceCsprng } from "./uniqueness_reseed.js";
42
42
  import { resetHttpConnectionPool } from "./http_pool_reset.js";
43
43
  import { WorkerWorkflowHost } from "./workflow_host.js";
44
+ import { buildHostCapabilities } from "./host_capabilities.js";
45
+ import { runShell } from "./shell_exec.js";
44
46
  import { BrowserSessionManager } from "./browser_session.js";
45
47
  import { loadGuestBrowserConfig, makeGuestBrowserBackend, connectSessionMcp, } from "./browser_session_backend.js";
46
48
  import { ScreenCapture } from "./screen_capture.js";
47
- import { loadCaptureConfig, makeCaptureBackend } from "./screen_capture_backend.js";
49
+ import { loadCaptureConfig, makeCaptureBackend, } from "./screen_capture_backend.js";
48
50
  import { runProgramWorker, } from "./program_worker.js";
49
51
  import { RunnerControlClient } from "./runner_control_client.js";
50
52
  import { BrokerChildDispatcher } from "./broker_child_dispatcher.js";
@@ -131,9 +133,10 @@ export function assembleWorkerDeps(runtime) {
131
133
  // monotonic with no separate program band. Frames publish as `{cursor, event}` rows via the
132
134
  // broker telemetry path. The claim wrapper below bumps it past a previous session's frames.
133
135
  // Optional on-screen mirror: append the (already-redacted) event stream to a local file an xterm in
134
- // the ambient desktop tails, so the live-view/recording shows the run working. Opt-in via the env the
135
- // desktop guest image sets (BOARDWALK_RUN_LOG_FILE); absent everywhere else, so no cost off-desktop.
136
- const runLogFilePath = process.env.BOARDWALK_RUN_LOG_FILE?.trim();
136
+ // the ambient desktop tails, so the live-view/recording shows the run working. The path is injected
137
+ // (resolved by `main` from the trusted platform BOOT env), so env-reading stays out of this pure
138
+ // wiring and a run's author env can't repoint the mirror. Absent off the desktop tier ⇒ no sink.
139
+ const runLogFilePath = runtime.runLogFilePath?.trim();
137
140
  const runLogLocalSink = runLogFilePath !== undefined && runLogFilePath !== ""
138
141
  ? makeRunLogFileSink(runLogFilePath)
139
142
  : undefined;
@@ -164,10 +167,6 @@ export function assembleWorkerDeps(runtime) {
164
167
  // the bill — the platform meters the actual per-leaf usage.
165
168
  rate: BUDGET_GUARDRAIL_RATE,
166
169
  startedAt: Date.now(),
167
- // deadline_seconds is WALL-CLOCK from the run's ORIGINAL start (incl. suspended idle), so a run
168
- // resumed past its deadline trips on its first cap check. Falls back to now on the first session
169
- // (startedAt is being set this session anyway).
170
- deadlineStartedAt: run.startedAt ?? Date.now(),
171
170
  });
172
171
  // Per-session id for per-turn metering identifiers (`<runId>:<sessionId>:<leafIndex>:<turnSeq>`) —
173
172
  // a fresh value per worker session keeps a resumed run's events from colliding with a prior
@@ -238,10 +237,11 @@ export function assembleWorkerDeps(runtime) {
238
237
  // it). Workspace-rooted at the same `/workspace` the leaf + tools use. Closed on the run's teardown
239
238
  // (returned below as `lsp` → program_worker's finally) so no language-server process leaks.
240
239
  const lspService = new LspService({ workspaceDir: runtime.workspaceRoot });
241
- // Budget gate (docs/SUSPEND_POLICY.md Decision 3): a `max_usd` breach PARKS the run for approval
242
- // instead of failing it. The gate needs the HOST (to park) but the host is constructed below with
243
- // `leaf` a genuine cycle, broken with a late-bound ref: `clear()` only ever runs mid-run, long
244
- // after both exist. Wired to `budgetHost` immediately after the host is built.
240
+ // Budget gate (docs/SUSPEND_POLICY.md Decision 3): a breach of ANY budget cap (`max_usd` /
241
+ // `max_tokens` / `max_compute_seconds`) PARKS the run for approval instead of failing it. The
242
+ // gate needs the HOST (to park) but the host is constructed below with `leaf` a genuine
243
+ // cycle, broken with a late-bound ref: `clear()` only ever runs mid-run, long after both exist.
244
+ // Wired to `budgetHost` immediately after the host is built.
245
245
  let budgetHost = null;
246
246
  const budgetGate = new BudgetGate(budget, {
247
247
  budgetClearance: (gate) => {
@@ -252,6 +252,13 @@ export function assembleWorkerDeps(runtime) {
252
252
  return budgetHost.budgetClearance(gate);
253
253
  },
254
254
  });
255
+ // Compute burns continuously (unlike usd/tokens, which only move at model calls), so a
256
+ // `max_compute_seconds` breach between seams is DETECTED on this timer and logged; the park
257
+ // itself lands at the run's next capability seam (see the watcher's doc block for why a
258
+ // timer-initiated park needs fleet-host coordination that doesn't exist yet). Stopped by the
259
+ // orchestrator on every terminal path via the returned `budgetWatch` handle.
260
+ const computeWatch = new ComputeBreachWatcher(budget, { runId: run.id });
261
+ computeWatch.start();
255
262
  const leaf = new EngineLeafExecutor({
256
263
  inference: broker,
257
264
  // Direct BYO (D7): the claim's registry + the run's RECORDING resolver, so a provider key
@@ -403,9 +410,20 @@ export function assembleWorkerDeps(runtime) {
403
410
  // (absent ⇒ the host throws "not available on this runner image").
404
411
  ...(browserSessions !== undefined ? { browserSessions } : {}),
405
412
  phases: phaseTracker,
413
+ // The protocol's `shell` capability: host-side execution in the run's workspace (the old
414
+ // in-process execSync moved behind the wire with the redesign).
415
+ shell: (cmd, opts) => runShell(cmd, opts, { workspaceRoot: runtime.workspaceRoot, signal }),
416
+ // Live budget state for `usage.get` — read off the run-level meter (run-cumulative).
417
+ usage: () => budget.usageSnapshot(),
418
+ // Budget clearance at the host's blocking/spending seams (sleep/shell/workflows.call): the
419
+ // park points for a compute breach that lands between model calls.
420
+ budgetGate,
406
421
  });
407
422
  // Close the budget gate's cycle: the leaf (built above) parks THROUGH the host (built just now).
408
423
  budgetHost = host;
424
+ // The protocol server's dispatch seam — a thin adapter over the host (workflows.call gains
425
+ // its output_schema slot, null until the broker delivers the callee's schema).
426
+ const capabilities = buildHostCapabilities(host);
409
427
  // Late-bind the coordinator's per-run hooks now that the run-scoped objects exist.
410
428
  if (freeze !== undefined) {
411
429
  const coordinator = freeze;
@@ -451,6 +469,11 @@ export function assembleWorkerDeps(runtime) {
451
469
  redactor.record(wake.api_token);
452
470
  }
453
471
  activeFlusher?.excludeIdle(wake.wall_clock_ms - freezeWallMs);
472
+ // The budget meter excludes the same frozen window: `max_compute_seconds` is ACTIVE
473
+ // on-CPU compute by definition (it "EXCLUDES sleep/wait" — the manifest schema's own
474
+ // words), so a sleep/humanInput/child-wait freeze must not count toward the cap any
475
+ // more than it counts toward billing.
476
+ budget.excludeIdle(wake.wall_clock_ms - freezeWallMs);
454
477
  // Only now that the frozen window is excluded may the flush timer run again.
455
478
  activeFlusher?.resume();
456
479
  // Resume capture in a fresh segment epoch (a suspend/resume boundary is a segment boundary).
@@ -461,7 +484,7 @@ export function assembleWorkerDeps(runtime) {
461
484
  // Hand the redactor back too: the worker scrubs a terminal error's message with it;
462
485
  // workspace (when opted in) hydrates at start + persists at terminal.
463
486
  return Promise.resolve({
464
- host,
487
+ capabilities,
465
488
  redactor,
466
489
  phases: phaseTracker,
467
490
  // The orchestrator closes the per-run LSP on every terminal path (close() is idempotent + never
@@ -478,6 +501,9 @@ export function assembleWorkerDeps(runtime) {
478
501
  setProgramDir: (dir) => {
479
502
  programDir = dir;
480
503
  },
504
+ // The orchestrator stops the compute-breach watcher on every terminal path (the interval is
505
+ // unref'd, so this is hygiene, not liveness).
506
+ budgetWatch: { stop: () => computeWatch.stop() },
481
507
  // Always present now: hydrate must run BEFORE the program, but whether anything compounds
482
508
  // isn't known until the run's agent() calls have registered their memory dirs. A run that
483
509
  // selects nothing persists nothing and pays for nothing (WorkspaceStore.persist returns early).
@@ -507,7 +533,15 @@ export function assembleWorkerDeps(runtime) {
507
533
  // lifecycle transition the claim IS — the wire's `running` frame.
508
534
  runEvents.resumeAfter(claimed.lastEventCursor);
509
535
  runEvents.emit({ kind: "run_status", status: "running" });
510
- return claimed.run;
536
+ return {
537
+ run: claimed.run,
538
+ context: {
539
+ ...(claimed.workflowVersion !== undefined
540
+ ? { workflowVersion: claimed.workflowVersion }
541
+ : {}),
542
+ ...(claimed.environment !== undefined ? { environment: claimed.environment } : {}),
543
+ },
544
+ };
511
545
  },
512
546
  },
513
547
  versions: {
@@ -527,7 +561,7 @@ export function assembleWorkerDeps(runtime) {
527
561
  // Extract the verified artifact tarball via system `tar` (same impl as workspace snapshots).
528
562
  extractArchive: (tgzPath, destDir) => new TarWorkspaceArchiver().extract(tgzPath, destDir),
529
563
  // Guarantee /workspace exists before the program runs (override-safe; the image also pre-creates
530
- // it). Lets authors write to /workspace without a defensive mkdir — see WorkflowMeta.workspace.
564
+ // it). Lets authors write to /workspace without a defensive mkdir — see the descriptor's `workspace` field.
531
565
  ensureWorkspace: async () => {
532
566
  await mkdir(runtime.workspaceRoot, { recursive: true });
533
567
  },
@@ -660,7 +694,44 @@ export function capturePlatformContext(env) {
660
694
  Reflect.deleteProperty(env, key);
661
695
  return { runId, controlPlane, vcpus };
662
696
  }
697
+ /**
698
+ * Resolve {@link PlatformConfig} from the trusted BOOT env. This is the ONE place env is read for the
699
+ * worker's own platform config — every other module consumes the typed result — so platform behavior
700
+ * never depends on the author-mutable process.env. Pure (unit-tested). MUST be called on the boot-env
701
+ * snapshot taken BEFORE the identity relay overlays the author env (see `main`).
702
+ */
703
+ export function capturePlatformConfig(bootEnv) {
704
+ return {
705
+ browser: loadGuestBrowserConfig(bootEnv),
706
+ capture: loadCaptureConfig(bootEnv),
707
+ ...(bootEnv.WORKER_ID !== undefined ? { workerId: bootEnv.WORKER_ID } : {}),
708
+ workspaceRoot: bootEnv.WORKSPACE_ROOT ?? "/workspace",
709
+ // Never inside the workspace, and never `process.cwd()` (WORKSPACE_PERSISTENCE.md I2). `tmpdir()`
710
+ // honors TMPDIR, which the self-hosted daemon points at the per-run dir — so concurrent daemons on
711
+ // one machine don't collide, and the hosted lanes get the VM's own /tmp.
712
+ programRoot: bootEnv.PROGRAM_ROOT ?? join(tmpdir(), "bw-programs"),
713
+ ...(bootEnv.PERSIST_SCOPE_DIR !== undefined
714
+ ? { persistScopeDir: bootEnv.PERSIST_SCOPE_DIR }
715
+ : {}),
716
+ ...(bootEnv.BOARDWALK_RUN_LOG_FILE !== undefined
717
+ ? { runLogFilePath: bootEnv.BOARDWALK_RUN_LOG_FILE }
718
+ : {}),
719
+ };
720
+ }
663
721
  export async function main() {
722
+ // Snapshot the platform-owned BOOT env (image-baked `/etc/bwimage.env`, plus anything the
723
+ // launcher/daemon set) BEFORE the identity relay overlays the run's AUTHOR env onto process.env below,
724
+ // and resolve the worker's OWN config from it up front. The worker reads platform behavior only from
725
+ // these trusted values — never the author-mutable process.env — so a workflow's `meta.env` can't
726
+ // shadow it (`BOARDWALK_RECORDING_ENABLED=0`, `BOARDWALK_BROWSER_TIER=0`, `BOARDWALK_RUNNER_LOG_LEVEL`,
727
+ // repointing `WORKSPACE_ROOT`, ...). The author still owns process.env outright — no reserved keys
728
+ // (docs/RUN_ENV_AND_CREDS.md); an eslint rule bans `process.env.BOARDWALK_*` reads to keep it that way.
729
+ // Per-run values the relay ITSELF delivers (run token, api token, task size, BYO providers) are a
730
+ // separate channel: `applyIdentityToEnv` set-or-clears them over the author env, so they stay trusted
731
+ // in process.env. On env-boot substrates (Fargate/self-hosted, no relay) this equals the boot env.
732
+ const platformBootEnv = { ...process.env };
733
+ configureLogging(platformBootEnv);
734
+ const platformConfig = capturePlatformConfig(platformBootEnv);
664
735
  // Relay-mode bootstrap (the snapshot-based microVM substrate): when the guest init handed
665
736
  // us an identity relay fd, park here — warm, generic, pre-identity; this await is what the
666
737
  // base snapshot freezes — until the run's identity arrives, then map it onto process.env
@@ -690,27 +761,30 @@ export async function main() {
690
761
  // API token (the run env/credential rules). WORKER_ID / WORKSPACE_ROOT are non-secret infra knobs.
691
762
  const platform = capturePlatformContext(process.env);
692
763
  const runId = platform.runId;
764
+ // BYO providers are a per-run value the relay delivers, NOT image-baked config — read post-overlay.
765
+ // `applyIdentityToEnv` set-or-clears this key over the author env, so the value here is the org's
766
+ // registry (or empty), never an author's `meta.env`. (Sanctioned platform-key read; the general ban
767
+ // on `process.env.BOARDWALK_*` is what routes image config through `platformBootEnv` above.)
768
+ // eslint-disable-next-line no-restricted-syntax -- relay-asserted per-run key, trusted post-overlay
693
769
  const byoProviders = parseByoProviders(process.env.BOARDWALK_BYO_PROVIDERS);
694
770
  Reflect.deleteProperty(process.env, "BOARDWALK_BYO_PROVIDERS");
695
- // Browser tier: only when the runner IMAGE declares the browser stack (BOARDWALK_BROWSER_TIER=1 +
696
- // a Chrome path). The backend is run-independent (buildHost builds the per-run manager over it);
697
- // absent ⇒ `computer.openBrowser()` fails clearly. Read here so env-reading stays out of the pure wiring.
698
- const guestBrowserConfig = loadGuestBrowserConfig(process.env);
699
- const browserBackend = guestBrowserConfig !== null ? makeGuestBrowserBackend(guestBrowserConfig) : undefined;
700
- // Screen capture (recording + live-view): present only when the image ships the desktop stack
701
- // (ffmpeg + a display) and recording isn't disabled. Read here so env-reading stays out of the wiring.
702
- const captureConfig = loadCaptureConfig(process.env);
703
- const captureBackend = captureConfig !== null ? makeCaptureBackend(captureConfig) : undefined;
771
+ // Construct the image-tier backends from the typed platform config (env already read once, above).
772
+ // Browser tier: present only when the image declares the browser stack (BOARDWALK_BROWSER_TIER=1 + a
773
+ // Chrome path); absent ⇒ `computer.openBrowser()` fails clearly. Screen capture: present only when the
774
+ // image ships the desktop stack (ffmpeg + a display) and recording isn't disabled.
775
+ const browserBackend = platformConfig.browser !== null ? makeGuestBrowserBackend(platformConfig.browser) : undefined;
776
+ const captureBackend = platformConfig.capture !== null ? makeCaptureBackend(platformConfig.capture) : undefined;
704
777
  const deps = assembleWorkerDeps({
705
- workerId: process.env.WORKER_ID ?? `worker-${runId}`,
706
- workspaceRoot: process.env.WORKSPACE_ROOT ?? "/workspace",
707
- // Never inside the workspace, and never `process.cwd()` (WORKSPACE_PERSISTENCE.md I2). `tmpdir()`
708
- // honors TMPDIR, which the self-hosted daemon points at the per-run dir — so concurrent daemons on
709
- // one machine don't collide, and the hosted lanes get the VM's own /tmp.
710
- programRoot: process.env.PROGRAM_ROOT ?? join(tmpdir(), "bw-programs"),
711
- // Set by the self-hosted daemon only (both isolation lanes); hosted images never set it.
712
- ...(process.env.PERSIST_SCOPE_DIR !== undefined
713
- ? { persistScopeDir: process.env.PERSIST_SCOPE_DIR }
778
+ // Worker-self config from the typed platform config (trusted boot env), never process.env so a
779
+ // run's `meta.env` can't repoint its own workspace/program roots, worker id, or run-log mirror.
780
+ workerId: platformConfig.workerId ?? `worker-${runId}`,
781
+ workspaceRoot: platformConfig.workspaceRoot,
782
+ programRoot: platformConfig.programRoot,
783
+ ...(platformConfig.persistScopeDir !== undefined
784
+ ? { persistScopeDir: platformConfig.persistScopeDir }
785
+ : {}),
786
+ ...(platformConfig.runLogFilePath !== undefined
787
+ ? { runLogFilePath: platformConfig.runLogFilePath }
714
788
  : {}),
715
789
  runId,
716
790
  controlPlane: platform.controlPlane,
@@ -31,14 +31,17 @@ export interface EngineLeafExecutorDeps {
31
31
  * breach so the loop terminates mid-flight (the budget authority). */
32
32
  budget: BudgetMeter;
33
33
  /**
34
- * Budget clearance, awaited before EVERY model call (docs/SUSPEND_POLICY.md Decision 3). When the
35
- * run's `max_usd` cap is breached this PARKS the run at a gate — the engine just sees a model call
36
- * that took a long time, because the VM froze and resumed underneath it and resolves once a
37
- * responder raises the cap. Rejects with `BudgetGateCancelled` if they decline.
34
+ * Budget clearance, awaited before EVERY model call (docs/SUSPEND_POLICY.md Decision 3). When ANY
35
+ * of the run's budget caps (`max_usd` / `max_tokens` / `max_compute_seconds`) is breached this
36
+ * PARKS the run at a gate the engine just sees a model call that took a long time, because the
37
+ * VM froze and resumed underneath it and resolves once a responder raises the breached cap.
38
+ * Rejects with `BudgetGateCancelled` if they decline.
38
39
  *
39
40
  * Why here and not at the `reportUsage` breach check below: that callback is synchronous and fires
40
41
  * mid-turn, and per SUSPEND_POLICY Decision 1 a partial turn is never discarded. The model-call
41
42
  * boundary is the first safe place to park, which bounds overrun at one in-flight turn per leaf.
43
+ * (The workflow host adds further park points at its `sleep`/`shell`/`workflows.call` seams for
44
+ * the continuously-burning compute cap.)
42
45
  *
43
46
  * Absent ⇒ the legacy behavior: a breach fails the run at `reportUsage`.
44
47
  */
@@ -3,8 +3,8 @@
3
3
  // In the JS-body model the run is the workflow PROGRAM; the agent loop is no longer "the run" — it
4
4
  // is an ephemeral leaf the program invokes via `agent(prompt, opts)`. This is the LeafExecutor the
5
5
  // worker's WorkflowHost delegates `agent()` to. It runs ONE leaf to completion via the engine's
6
- // `runAgentLeaf` (`@boardwalk-labs/engine/core`) — the SAME loop `boardwalk dev` and the self-hosted
7
- // server run — supplying a broker-backed `LeafIo`: the model call routes through the Runner Control
6
+ // `runAgentLeaf` (`@boardwalk-labs/engine/core`) — the SAME loop the self-hosted
7
+ // server runs — supplying a broker-backed `LeafIo`: the model call routes through the Runner Control
8
8
  // API (the worker holds no model creds), events flow onto the run's v1 event stream, usage meters
9
9
  // per-leaf + per-model through the broker, and secrets are redacted out of all model-bound content.
10
10
  //
@@ -138,9 +138,14 @@ export class EngineLeafExecutor {
138
138
  currentTurnId = turnId;
139
139
  sink.emit(toRunEventBody(body, identity), turnId);
140
140
  },
141
- // Usage flows to the budget authority after EVERY model call. Feed the run-level meter and, if
142
- // a cap is now breached, THROW the engine loop propagates it and the run fails BUDGET_EXCEEDED
143
- // before another model call is dispatched. Also fire per-leaf, per-model metering to the broker.
141
+ // Usage flows to the budget authority after EVERY model call. Feed the run-level meter; what a
142
+ // breach then does depends on whether the budget gate is wired. With a gate (production): do
143
+ // NOT throw the in-flight turn completes (its tools run; SUSPEND_POLICY Decision 1 never
144
+ // discards a partial turn) and the run PARKS at its next park point (the next `streamModel`
145
+ // clearance, or a host capability seam). Without a gate (legacy/test paths): THROW — the
146
+ // engine loop propagates it and the run fails BUDGET_EXCEEDED before another model call is
147
+ // dispatched. On the gate path the breach turn ALSO meters to the broker below — its tokens
148
+ // were genuinely spent and must be billed (the old always-throw path silently skipped that).
144
149
  reportUsage: (modelRefForUsage, usage) => {
145
150
  const delta = toUsageDelta(usage);
146
151
  // Cap on the turn's REAL upstream cost when the broker reported one (the result frame's
@@ -151,7 +156,7 @@ export class EngineLeafExecutor {
151
156
  pendingCostMicros = null;
152
157
  this.deps.budget.addUsage(delta, realCostUsd);
153
158
  const breach = this.deps.budget.capBreachReason();
154
- if (breach !== null) {
159
+ if (breach !== null && this.deps.budgetGate === undefined) {
155
160
  throw new EngineError("BUDGET_EXCEEDED", `agent() leaf exceeded the run budget cap (${breach})`);
156
161
  }
157
162
  this.deps.meterUsage?.({
@@ -1,14 +1,12 @@
1
- import type { WorkflowHost } from "@boardwalk-labs/workflow/runtime";
1
+ import { type ContextData } from "@boardwalk-labs/workflow/runtime";
2
+ import { type HostCapabilities } from "./host_server.js";
2
3
  /**
3
4
  * Link THIS runtime's `@boardwalk-labs/workflow` into the exec dir's `node_modules` so the program's
4
5
  * bare import resolves from ANY program root — this link, not an ancestor `node_modules`, is what
5
- * makes resolution work. (Worth stating plainly: a stale comment claiming the extraction dir had to
6
- * sit under `/app` so the import could walk up to `/app/node_modules` outlived this function by
7
- * several versions and sent a later reader chasing a dependency that no longer exists. The live
8
- * fleet has no `/app` at all and resolves fine.) A symlink — not a copy is load-bearing: Node
9
- * resolves it to the REAL path, so the program gets the same module instance the host adapter was
10
- * installed on (the singleton contract). `junction` covers Windows without elevation; a failed link
11
- * is only logged, since a resolvable ancestor may still exist.
6
+ * makes resolution work. A symlink not a copy is load-bearing: Node resolves it to the REAL
7
+ * path, so the program gets the same module instance the loader's protocol client was installed
8
+ * on (the active-host singleton in the SDK's host_client). `junction` covers Windows without
9
+ * elevation; a failed link is only logged, since a resolvable ancestor may still exist.
12
10
  */
13
11
  export declare function ensureSdkLink(execDir: string): Promise<void>;
14
12
  /** Resolve the program's entry module inside the extraction dir, refusing any path that escapes it.
@@ -17,10 +15,18 @@ export declare function ensureSdkLink(execDir: string): Promise<void>;
17
15
  * outside `dir` throws rather than importing code from elsewhere on the machine. */
18
16
  export declare function resolveEntryPath(dir: string, entry: string): string;
19
17
  /**
20
- * Enforce I2: the extracted program must not live inside the run's workspace. Incidental separation
21
- * is what broke before — the extraction root used to be `process.cwd()`, so it landed inside the
22
- * workspace on exactly the lanes whose cwd was already correct. Compares RESOLVED paths, and treats
23
- * "the workspace itself" as inside.
18
+ * Resolve a program entry by LANGUAGE, binding the ratified artifact layout: a TS/JS entry is a
19
+ * BUILT module at the artifact root (`index.mjs` the CLI bundles, the api-server type-strips);
20
+ * a `.py` entry is a SOURCE path under `.bw-src/` (`main.py` `<dir>/.bw-src/main.py` Python
21
+ * has no bundle step, the shipped source is what runs). A leading `./` on the stored entry is
22
+ * tolerated (`join` collapses it). Both lanes keep the containment guard; for Python the guard's
23
+ * base is the `.bw-src` tree itself, so an escaping entry (`../evil.py`) is rejected even when
24
+ * it would still land inside the extract dir.
25
+ */
26
+ export declare function resolveProgramEntryPath(dir: string, entry: string): string;
27
+ /**
28
+ * Enforce I2: the extracted program must not live inside the run's workspace. Compares RESOLVED
29
+ * paths, and treats "the workspace itself" as inside.
24
30
  */
25
31
  export declare function assertProgramRootOutsideWorkspace(programRoot: string, workspaceRoot: string): void;
26
32
  export interface RunProgramArgs {
@@ -28,17 +34,24 @@ export interface RunProgramArgs {
28
34
  runId: string;
29
35
  /** The VERIFIED program artifact tarball (sha256 already checked against the pinned digest). */
30
36
  tarball: Uint8Array;
31
- /** Entry module to import after extraction (a safe relative POSIX path, e.g. `index.mjs`). */
37
+ /** Entry module to import after extraction (a safe relative POSIX path). TS/JS: the BUILT
38
+ * module at the artifact root (`index.mjs`). Python: the SOURCE path relative to the
39
+ * artifact's `.bw-src/` tree (`main.py` ⇒ `.bw-src/main.py` on disk). */
32
40
  entry: string;
33
- /** Trigger payload exposed to the program as `import { input } from "@boardwalk-labs/workflow"`. */
41
+ /** The run's RAW JSON input (trigger payload / inline input). The revival pass is CLIENT-side. */
34
42
  input: unknown;
35
- /** Experiment config exposed as `import { config } from "@boardwalk-labs/workflow"` ({} for non-eval).
36
- * Arbitrary JSON from the run row; narrowed to the SDK's JsonValue at the installConfig boundary. */
37
- config: Record<string, unknown>;
43
+ /** The stored derived input schema (`null` for an untyped workflow) carried on `bootstrap`
44
+ * so the SDK revives rich fields (`date-time` `Date`, base64 `Uint8Array`, …). */
45
+ inputSchema: Record<string, unknown> | null;
46
+ /** The stored derived output schema (`null` ⇒ the return persists unvalidated). */
47
+ outputSchema: Record<string, unknown> | null;
48
+ /** The context DATA for `bootstrap` (P3.3) — the client builds the live `Context` from it. */
49
+ context: ContextData;
38
50
  }
39
51
  export interface ProgramRunnerDeps {
40
- /** The host adapter the program's hooks delegate to (agent leaf, sleep hold, child calls, secrets). */
41
- host: WorkflowHost;
52
+ /** The capability seam the host server dispatches onto (agent leaf, sleep hold, child calls,
53
+ * secrets, shell, usage, auth, browser, phase). */
54
+ capabilities: HostCapabilities;
42
55
  /**
43
56
  * The run's `/workspace` — the working directory AND `HOME` for author code (I1). Must already
44
57
  * exist (the orchestrator's `ensureWorkspace` guarantees it); a missing workspace fails the run
@@ -46,42 +59,49 @@ export interface ProgramRunnerDeps {
46
59
  */
47
60
  workspaceRoot: string;
48
61
  /**
49
- * Root the program artifact extracts under. MUST be outside {@link workspaceRoot} (I2) — enforced,
50
- * because a bundle inside the workspace is tarred into every pre-sleep snapshot and, since each
51
- * run's dir name is unique, accumulates there forever.
62
+ * Root the program artifact extracts under. MUST be outside {@link workspaceRoot} (I2) —
63
+ * enforced, because a bundle inside the workspace is tarred into every pre-sleep snapshot.
52
64
  */
53
65
  programRoot: string;
54
66
  /**
55
67
  * Extract a gzipped tar file into a directory (created already). System `tar` in production
56
- * (matches WorkspaceArchiver); injected in tests. The artifact's relative layout is preserved so
57
- * on-disk assets (markdown skills, templates) sit where the program expects them.
68
+ * (matches WorkspaceArchiver); injected in tests.
58
69
  */
59
70
  extract: (tgzPath: string, destDir: string) => Promise<void>;
60
71
  /**
61
- * Called once with the extracted program directory, right after the artifact is unpacked (before the
62
- * program body runs). The worker uses it to point the `agent()` leaf at the run's bundled files
63
- * (`<dir>/skills/<name>.md`). Optional — the local/test path may omit it.
72
+ * Called once with the extracted program directory, right after the artifact is unpacked
73
+ * (before `run()` is invoked). The worker uses it to point the `agent()` leaf at the run's
74
+ * bundled files (`<dir>/skills/<name>.md`).
64
75
  */
65
76
  onExtracted?: (programDir: string) => void;
66
77
  /**
67
- * Scrubs known secret values out of a string (the run's `SecretRedactor.redactText`). Applied to a
68
- * top-level throw's message before it is logged AND before it is returned to the worker — a program
69
- * that resolves a secret and then throws it in an error message must NOT land that secret raw in the
70
- * logs or the finalized run output. Defaults to identity (tests/local).
78
+ * Scrubs known secret values out of a string (the run's `SecretRedactor.redactText`). Applied
79
+ * to a thrown error's message/code/hint before logging + finalize. Defaults to identity.
71
80
  */
72
81
  redactText?: (text: string) => string;
73
82
  /**
74
- * Called once with the program's declared output IFF the program called `output(value)` (so an
75
- * explicit `output(null)` still fires, but a program that never declared one does NOT). The worker
76
- * wires this to emit an `output` activity entry into the run's event log. Best-effort: it must not
77
- * throw (a telemetry hiccup can't change the run's result). Absent ⇒ no output entry.
83
+ * Called once with the run's reported return IFF it is non-null (a void return sends null,
84
+ * which is not an author-declared output). The worker wires this to emit an `output` activity
85
+ * entry into the run's event log. Best-effort: it must not throw.
78
86
  */
79
87
  onOutput?: (value: unknown) => void;
88
+ /**
89
+ * The run's cooperative-cancellation signal. The host server pushes the `cancel` notification
90
+ * to the program when it fires (the SDK aborts `context.signal`); the capability layer already
91
+ * honors it server-side at every hook.
92
+ */
93
+ signal?: AbortSignal | undefined;
94
+ /** Override the socket directory (tests). Default `os.tmpdir()` (short paths — sun_path cap). */
95
+ sockDir?: string | undefined;
96
+ /** Interpreter a `.py` entry is launched with (P5.5). Default `python3`, resolved on PATH —
97
+ * the base image bakes one CPython (P5.3). May be an absolute path. */
98
+ pythonInterpreter?: string | undefined;
99
+ /** How long after SIGTERM an aborted Python child gets before SIGKILL. Default 5s. */
100
+ pythonKillGraceMs?: number | undefined;
80
101
  }
81
- /** Terminal result of running a workflow program. `output` is what the program declared via
82
- * `output(value)` (null when it never did); for a failure it's null and `error` is set. A waiting
83
- * seam (sleep / humanInput / workflows.call) never surfaces here — it freezes with the VM or
84
- * holds the process, and the body simply continues when the wait is over. */
102
+ /** Terminal result of running a workflow program. `output` is the validated value `run()`
103
+ * returned (`null` for void); for a failure it's null and `error` is set. A waiting seam never
104
+ * surfaces here — it freezes with the VM or holds the process, and `run()` simply continues. */
85
105
  export type ProgramResult = {
86
106
  kind: "completed";
87
107
  output: unknown;
@@ -95,8 +115,8 @@ export type ProgramResult = {
95
115
  };
96
116
  };
97
117
  /**
98
- * Run a workflow program to completion. Installs the host + input, extracts the VERIFIED artifact +
99
- * dynamic-imports its entry (which runs the body), and returns the terminal result. Always tears the
100
- * runtime state down and removes the temp tree afterward.
118
+ * Run a workflow program to completion: start the host-protocol server, extract the VERIFIED
119
+ * artifact, drive the loader (`bootstrap` import entry `run(input, context)`
120
+ * `reportReturn`), and return the terminal result. Always tears the server + temp tree down.
101
121
  */
102
122
  export declare function runWorkflowProgram(args: RunProgramArgs, deps: ProgramRunnerDeps): Promise<ProgramResult>;