@boardwalk-labs/runner 0.2.15 → 0.2.17

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 CHANGED
@@ -31,7 +31,9 @@ boardwalk-runner start --url https://api.boardwalk.sh --pool default
31
31
  ```
32
32
 
33
33
  `start` polls for runs targeting `runs_on: { kind: "self-hosted" }` and executes one at a
34
- time; run more daemons (or machines) for concurrency. `Ctrl-C` drains: the current run
34
+ time; run more daemons (or machines) for concurrency. Each run executes in a container by default
35
+ (Docker or Podman required); pass `--host` to run it directly on the machine instead. `Ctrl-C`
36
+ drains: the current run
35
37
  finishes, nothing new is claimed. Useful flags: `--once` (execute one run, then exit),
36
38
  `--verbose` (debug-level daemon logs), `--debug` (also debug-logs inside each run process),
37
39
  `--work-dir`, `--identity-dir`. Behind a corporate proxy, launch with `NODE_USE_ENV_PROXY=1`
@@ -58,7 +60,7 @@ runs every non-browser workflow exactly as before.
58
60
 
59
61
  ## Security model
60
62
 
61
- This part of the contract is settled, even though the client isn't built yet:
63
+ This part of the contract is settled:
62
64
 
63
65
  - The runner **never receives broad credentials**: a single-purpose registration token to join,
64
66
  a standing runner token that can only poll/claim/heartbeat, and a short-lived **run-scoped**
@@ -87,7 +89,7 @@ pnpm lint && pnpm typecheck && pnpm build
87
89
  - [`sdk`](https://github.com/boardwalk-labs/sdk) — `@boardwalk-labs/workflow`, the TypeScript API a workflow program imports
88
90
  - [`cli`](https://github.com/boardwalk-labs/cli) — `boardwalk`: scaffold, validate, run locally, deploy
89
91
  - [`examples`](https://github.com/boardwalk-labs/examples) — copyable workflow templates (`boardwalk init --template`)
90
- - [`plugins`](https://github.com/boardwalk-labs/plugins) — skills + MCP server for Claude Code, Codex, Cursor, OpenClaw, OpenCode
92
+ - [`plugins`](https://github.com/boardwalk-labs/plugins) — coding-agent skills (Claude Code, Codex, Cursor, OpenClaw, OpenCode) + a control-plane MCP server
91
93
  - [`runner-images`](https://github.com/boardwalk-labs/runner-images) — reproducible base images hosted runners execute in
92
94
 
93
95
  Hosted platform and docs: [boardwalk.sh](https://boardwalk.sh).
package/dist/bin.js CHANGED
@@ -156,6 +156,10 @@ const USAGE = `boardwalk-runner <register|start|deregister> [flags]
156
156
  async function mainCli() {
157
157
  // --verbose: debug-level daemon logs (poll cycles, heartbeats). --debug: the same, PLUS the
158
158
  // spawned run processes log debug too (the env below is forwarded to children by realSpawn).
159
+ // Sanctioned OPERATOR write of the runner log env, BEFORE any worker boots — the worker's
160
+ // `configureLogging` snapshots it from the trusted boot env, so this operator flag wins while a
161
+ // run's author `meta.env` (applied later, over the relay) does not.
162
+ /* eslint-disable no-restricted-syntax -- operator sets the runner log env pre-boot; not author-reachable */
159
163
  if (hasFlag("debug")) {
160
164
  process.env.BOARDWALK_RUNNER_LOG_LEVEL = "debug";
161
165
  process.env.BOARDWALK_RUNNER_DEBUG = "1";
@@ -163,6 +167,7 @@ async function mainCli() {
163
167
  else if (hasFlag("verbose")) {
164
168
  process.env.BOARDWALK_RUNNER_LOG_LEVEL = "debug";
165
169
  }
170
+ /* eslint-enable no-restricted-syntax */
166
171
  const cmd = process.argv[2];
167
172
  if (cmd === "register")
168
173
  return await cmdRegister();
@@ -75,18 +75,28 @@ export function applyIdentityToEnv(identity, env) {
75
75
  for (const [key, value] of Object.entries(identity.env ?? {})) {
76
76
  env[key] = value;
77
77
  }
78
+ // The platform transport keys OWN their names: assert the trusted value, or DELETE the key when the
79
+ // identity omits it — so an author's `meta.env` (applied just above) can NEVER survive on a platform
80
+ // key. RUN_ID / control-plane URL / run token are always present; the api token, task size, and BYO
81
+ // registry are optional, so they set-or-clear (a conditional-only overwrite would leave an
82
+ // author-supplied `BOARDWALK_TASK_CPU_UNITS` / `BOARDWALK_API_KEY` / `BOARDWALK_BYO_PROVIDERS` in
83
+ // place for a run whose identity happens not to carry that field — e.g. an org with no BYO providers).
78
84
  env.RUN_ID = identity.run_id;
79
85
  env.BOARDWALK_CONTROL_PLANE_URL = identity.control_plane_url;
80
86
  env.BOARDWALK_RUN_TOKEN = identity.run_token;
81
- if (identity.api_token !== undefined && identity.api_token.length > 0) {
82
- env.BOARDWALK_API_KEY = identity.api_token;
83
- }
84
- if (identity.task_cpu_units !== undefined) {
85
- env.BOARDWALK_TASK_CPU_UNITS = String(identity.task_cpu_units);
86
- }
87
- if (identity.byo_providers !== undefined) {
88
- env.BOARDWALK_BYO_PROVIDERS = JSON.stringify(identity.byo_providers);
89
- }
87
+ setOrClear(env, "BOARDWALK_API_KEY", identity.api_token !== undefined && identity.api_token.length > 0
88
+ ? identity.api_token
89
+ : undefined);
90
+ setOrClear(env, "BOARDWALK_TASK_CPU_UNITS", identity.task_cpu_units !== undefined ? String(identity.task_cpu_units) : undefined);
91
+ setOrClear(env, "BOARDWALK_BYO_PROVIDERS", identity.byo_providers !== undefined ? JSON.stringify(identity.byo_providers) : undefined);
92
+ }
93
+ /** Assert a trusted value on a platform transport key, or DELETE the key when absent — so an
94
+ * author-overlaid value on that reserved-by-transport name never survives into the run env. */
95
+ function setOrClear(env, key, value) {
96
+ if (value !== undefined)
97
+ env[key] = value;
98
+ else
99
+ Reflect.deleteProperty(env, key);
90
100
  }
91
101
  /** Collect the worker_ready diagnostics. Version lookups are best-effort: the runner's own
92
102
  * package.json sits two levels above this module in both the src and published dist
@@ -3,7 +3,9 @@ import type { Run } from "./wire/run.js";
3
3
  import { type IdentityRelay } from "./identity_relay.js";
4
4
  import type { ByoInferenceProvider } from "../contract.js";
5
5
  import { type BrowserBackend } from "./browser_session.js";
6
+ import { type GuestBrowserConfig } from "./browser_session_backend.js";
6
7
  import { type CaptureBackend } from "./screen_capture.js";
8
+ import { type CaptureConfig } from "./screen_capture_backend.js";
7
9
  import { type ProgramWorkerDeps } from "./program_worker.js";
8
10
  /** Constructed primitives the pure assembly consumes (real ones in main(), fakes in tests). The
9
11
  * runner holds NO platform credential — only the per-run control-plane handle (run token + broker
@@ -54,6 +56,10 @@ export interface WorkerRuntime {
54
56
  /** Screen-capture backend (session recording + live-view frames), present ONLY when the runner IMAGE
55
57
  * ships the desktop stack (ffmpeg + an X display) and recording isn't disabled. Absent ⇒ no capture. */
56
58
  captureBackend?: CaptureBackend;
59
+ /** Path for the on-screen run-log mirror an xterm in the ambient desktop tails (BOARDWALK_RUN_LOG_FILE,
60
+ * set by the desktop guest image). Resolved by `main` from the trusted platform BOOT env so a run's
61
+ * author `meta.env` can't repoint it; absent off the desktop tier ⇒ no local sink. */
62
+ runLogFilePath?: string;
57
63
  }
58
64
  /** Durable events are deferred (durable event storage) — live fan-out via the broker only. */
59
65
  /** Synthesize the run's AuthContext. The run was already authorized at trigger time; the
@@ -109,4 +115,36 @@ export declare function publicApiOrigin(controlPlaneBaseUrl: string): string;
109
115
  * `env` (called once on `process.env` at the very top of `main`, before any run code can read it).
110
116
  */
111
117
  export declare function capturePlatformContext(env: NodeJS.ProcessEnv): PlatformContext;
118
+ /**
119
+ * The platform-owned config the worker reads for ITSELF: the browser/desktop tier, screen capture, and
120
+ * the run's sandbox roots / worker id / run-log mirror. All resolved from the passed env — which `main`
121
+ * captures from the trusted BOOT env (image-baked `/etc/bwimage.env`) BEFORE the identity relay overlays
122
+ * the run's author env onto process.env. Consumers use these TYPED fields, never process.env, so a
123
+ * workflow author's `meta.env` can't shadow platform behavior while the author still owns process.env
124
+ * outright (docs/RUN_ENV_AND_CREDS.md). Distinct from {@link capturePlatformContext}, which resolves the
125
+ * per-run CREDENTIALS the relay injects (read post-overlay, then scrubbed).
126
+ */
127
+ export interface PlatformConfig {
128
+ /** Browser/desktop tier backend config, or null when the image ships no browser stack. */
129
+ browser: GuestBrowserConfig | null;
130
+ /** Screen-capture config, or null when the image ships no desktop stack / recording is off. */
131
+ capture: CaptureConfig | null;
132
+ /** Stable worker id (WORKER_ID); absent ⇒ the caller derives one from the run id. */
133
+ workerId?: string;
134
+ /** Sandbox workspace root (WORKSPACE_ROOT), default `/workspace`. */
135
+ workspaceRoot: string;
136
+ /** Program-extraction root (PROGRAM_ROOT), default `<tmpdir>/bw-programs`. */
137
+ programRoot: string;
138
+ /** Self-hosted durable-workspace scope (PERSIST_SCOPE_DIR); set by the daemon only. */
139
+ persistScopeDir?: string;
140
+ /** Ambient-desktop run-log mirror path (BOARDWALK_RUN_LOG_FILE); set by the desktop image only. */
141
+ runLogFilePath?: string;
142
+ }
143
+ /**
144
+ * Resolve {@link PlatformConfig} from the trusted BOOT env. This is the ONE place env is read for the
145
+ * worker's own platform config — every other module consumes the typed result — so platform behavior
146
+ * never depends on the author-mutable process.env. Pure (unit-tested). MUST be called on the boot-env
147
+ * snapshot taken BEFORE the identity relay overlays the author env (see `main`).
148
+ */
149
+ export declare function capturePlatformConfig(bootEnv: NodeJS.ProcessEnv): PlatformConfig;
112
150
  export declare function main(): Promise<void>;
@@ -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";
@@ -44,7 +44,7 @@ import { WorkerWorkflowHost } from "./workflow_host.js";
44
44
  import { BrowserSessionManager } from "./browser_session.js";
45
45
  import { loadGuestBrowserConfig, makeGuestBrowserBackend, connectSessionMcp, } from "./browser_session_backend.js";
46
46
  import { ScreenCapture } from "./screen_capture.js";
47
- import { loadCaptureConfig, makeCaptureBackend } from "./screen_capture_backend.js";
47
+ import { loadCaptureConfig, makeCaptureBackend, } from "./screen_capture_backend.js";
48
48
  import { runProgramWorker, } from "./program_worker.js";
49
49
  import { RunnerControlClient } from "./runner_control_client.js";
50
50
  import { BrokerChildDispatcher } from "./broker_child_dispatcher.js";
@@ -131,9 +131,10 @@ export function assembleWorkerDeps(runtime) {
131
131
  // monotonic with no separate program band. Frames publish as `{cursor, event}` rows via the
132
132
  // broker telemetry path. The claim wrapper below bumps it past a previous session's frames.
133
133
  // 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();
134
+ // the ambient desktop tails, so the live-view/recording shows the run working. The path is injected
135
+ // (resolved by `main` from the trusted platform BOOT env), so env-reading stays out of this pure
136
+ // wiring and a run's author env can't repoint the mirror. Absent off the desktop tier ⇒ no sink.
137
+ const runLogFilePath = runtime.runLogFilePath?.trim();
137
138
  const runLogLocalSink = runLogFilePath !== undefined && runLogFilePath !== ""
138
139
  ? makeRunLogFileSink(runLogFilePath)
139
140
  : undefined;
@@ -660,7 +661,44 @@ export function capturePlatformContext(env) {
660
661
  Reflect.deleteProperty(env, key);
661
662
  return { runId, controlPlane, vcpus };
662
663
  }
664
+ /**
665
+ * Resolve {@link PlatformConfig} from the trusted BOOT env. This is the ONE place env is read for the
666
+ * worker's own platform config — every other module consumes the typed result — so platform behavior
667
+ * never depends on the author-mutable process.env. Pure (unit-tested). MUST be called on the boot-env
668
+ * snapshot taken BEFORE the identity relay overlays the author env (see `main`).
669
+ */
670
+ export function capturePlatformConfig(bootEnv) {
671
+ return {
672
+ browser: loadGuestBrowserConfig(bootEnv),
673
+ capture: loadCaptureConfig(bootEnv),
674
+ ...(bootEnv.WORKER_ID !== undefined ? { workerId: bootEnv.WORKER_ID } : {}),
675
+ workspaceRoot: bootEnv.WORKSPACE_ROOT ?? "/workspace",
676
+ // Never inside the workspace, and never `process.cwd()` (WORKSPACE_PERSISTENCE.md I2). `tmpdir()`
677
+ // honors TMPDIR, which the self-hosted daemon points at the per-run dir — so concurrent daemons on
678
+ // one machine don't collide, and the hosted lanes get the VM's own /tmp.
679
+ programRoot: bootEnv.PROGRAM_ROOT ?? join(tmpdir(), "bw-programs"),
680
+ ...(bootEnv.PERSIST_SCOPE_DIR !== undefined
681
+ ? { persistScopeDir: bootEnv.PERSIST_SCOPE_DIR }
682
+ : {}),
683
+ ...(bootEnv.BOARDWALK_RUN_LOG_FILE !== undefined
684
+ ? { runLogFilePath: bootEnv.BOARDWALK_RUN_LOG_FILE }
685
+ : {}),
686
+ };
687
+ }
663
688
  export async function main() {
689
+ // Snapshot the platform-owned BOOT env (image-baked `/etc/bwimage.env`, plus anything the
690
+ // launcher/daemon set) BEFORE the identity relay overlays the run's AUTHOR env onto process.env below,
691
+ // and resolve the worker's OWN config from it up front. The worker reads platform behavior only from
692
+ // these trusted values — never the author-mutable process.env — so a workflow's `meta.env` can't
693
+ // shadow it (`BOARDWALK_RECORDING_ENABLED=0`, `BOARDWALK_BROWSER_TIER=0`, `BOARDWALK_RUNNER_LOG_LEVEL`,
694
+ // repointing `WORKSPACE_ROOT`, ...). The author still owns process.env outright — no reserved keys
695
+ // (docs/RUN_ENV_AND_CREDS.md); an eslint rule bans `process.env.BOARDWALK_*` reads to keep it that way.
696
+ // Per-run values the relay ITSELF delivers (run token, api token, task size, BYO providers) are a
697
+ // separate channel: `applyIdentityToEnv` set-or-clears them over the author env, so they stay trusted
698
+ // in process.env. On env-boot substrates (Fargate/self-hosted, no relay) this equals the boot env.
699
+ const platformBootEnv = { ...process.env };
700
+ configureLogging(platformBootEnv);
701
+ const platformConfig = capturePlatformConfig(platformBootEnv);
664
702
  // Relay-mode bootstrap (the snapshot-based microVM substrate): when the guest init handed
665
703
  // us an identity relay fd, park here — warm, generic, pre-identity; this await is what the
666
704
  // base snapshot freezes — until the run's identity arrives, then map it onto process.env
@@ -690,27 +728,30 @@ export async function main() {
690
728
  // API token (the run env/credential rules). WORKER_ID / WORKSPACE_ROOT are non-secret infra knobs.
691
729
  const platform = capturePlatformContext(process.env);
692
730
  const runId = platform.runId;
731
+ // BYO providers are a per-run value the relay delivers, NOT image-baked config — read post-overlay.
732
+ // `applyIdentityToEnv` set-or-clears this key over the author env, so the value here is the org's
733
+ // registry (or empty), never an author's `meta.env`. (Sanctioned platform-key read; the general ban
734
+ // on `process.env.BOARDWALK_*` is what routes image config through `platformBootEnv` above.)
735
+ // eslint-disable-next-line no-restricted-syntax -- relay-asserted per-run key, trusted post-overlay
693
736
  const byoProviders = parseByoProviders(process.env.BOARDWALK_BYO_PROVIDERS);
694
737
  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;
738
+ // Construct the image-tier backends from the typed platform config (env already read once, above).
739
+ // Browser tier: present only when the image declares the browser stack (BOARDWALK_BROWSER_TIER=1 + a
740
+ // Chrome path); absent ⇒ `computer.openBrowser()` fails clearly. Screen capture: present only when the
741
+ // image ships the desktop stack (ffmpeg + a display) and recording isn't disabled.
742
+ const browserBackend = platformConfig.browser !== null ? makeGuestBrowserBackend(platformConfig.browser) : undefined;
743
+ const captureBackend = platformConfig.capture !== null ? makeCaptureBackend(platformConfig.capture) : undefined;
704
744
  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 }
745
+ // Worker-self config from the typed platform config (trusted boot env), never process.env so a
746
+ // run's `meta.env` can't repoint its own workspace/program roots, worker id, or run-log mirror.
747
+ workerId: platformConfig.workerId ?? `worker-${runId}`,
748
+ workspaceRoot: platformConfig.workspaceRoot,
749
+ programRoot: platformConfig.programRoot,
750
+ ...(platformConfig.persistScopeDir !== undefined
751
+ ? { persistScopeDir: platformConfig.persistScopeDir }
752
+ : {}),
753
+ ...(platformConfig.runLogFilePath !== undefined
754
+ ? { runLogFilePath: platformConfig.runLogFilePath }
714
755
  : {}),
715
756
  runId,
716
757
  controlPlane: platform.controlPlane,
@@ -83,6 +83,11 @@ export class EngineLeafExecutor {
83
83
  // io (children get their own via `forChild`), and the loop is strictly streamModel→reportUsage per
84
84
  // turn, so a single slot never races. Null ⇒ no upstream cost for the next turn (BYO / unavailable).
85
85
  let pendingCostMicros = null;
86
+ // The turn currently streaming, tracked so `streamModel` can attribute a `turn_reset` to it when
87
+ // the broker restarts a dropped model call mid-stream. Set on `startTurn` and every `emit` (the
88
+ // engine stamps both with the turn's id); within a leaf, turns are strictly sequential, so this is
89
+ // never stale for the turn actually streaming.
90
+ let currentTurnId = undefined;
86
91
  return {
87
92
  identity,
88
93
  redactor,
@@ -113,16 +118,24 @@ export class EngineLeafExecutor {
113
118
  throwIfAborted(signal);
114
119
  return this.streamModel(req, providerIo, signal, redactor, (costMicros) => {
115
120
  pendingCostMicros = (pendingCostMicros ?? 0) + costMicros;
121
+ },
122
+ // The broker restarted a dropped model call after content had already streamed: void the
123
+ // turn's emitted text/reasoning so a viewer re-renders from the restart, not the concatenation.
124
+ () => {
125
+ if (currentTurnId !== undefined)
126
+ sink.emit({ kind: "turn_reset" }, currentTurnId);
116
127
  });
117
128
  },
118
129
  // turn_started rides a NEW stride block (the supervisor opens it); subsequent leaf events ride
119
130
  // the same block. The shared emitter owns the run-global cursor.
120
131
  startTurn: (turnId) => {
132
+ currentTurnId = turnId;
121
133
  sink.beginTurn(turnId, { kind: "turn_started", ...identity });
122
134
  },
123
135
  // Engine LeafEventBody → the platform's v1 RunEventBody. The platform already adopted the SDK
124
136
  // v1 wire format, and the engine emits the same kinds, so this is a near-identity mapping.
125
137
  emit: (turnId, body) => {
138
+ currentTurnId = turnId;
126
139
  sink.emit(toRunEventBody(body, identity), turnId);
127
140
  },
128
141
  // Usage flows to the budget authority after EVERY model call. Feed the run-level meter and, if
@@ -202,7 +215,7 @@ export class EngineLeafExecutor {
202
215
  /** POST one model turn to the broker's `/inference` and adapt its NDJSON stream into the engine's
203
216
  * ModelTurnResult: each `delta` frame drives providerIo.onDelta; the terminal `result` frame is
204
217
  * the turn. An `error` frame throws (the broker already classified it). An abort mid-stream throws. */
205
- async streamModel(req, providerIo, signal, redactor, onCost) {
218
+ async streamModel(req, providerIo, signal, redactor, onCost, onReset) {
206
219
  // Runner-direct BYO (D7): the org's own endpoint + key, called with the same engine adapters
207
220
  // the broker uses. No platform cost (BYO is never metered) — onCost stays untouched.
208
221
  // A direct turn needs an explicit model (an omitted model means the managed auto lane,
@@ -240,6 +253,12 @@ export class EngineLeafExecutor {
240
253
  else if (frame.kind === "reasoning") {
241
254
  providerIo.onReasoningDelta?.(frame.text);
242
255
  }
256
+ else if (frame.kind === "reset") {
257
+ // The broker recovered a transient mid-stream drop and is restarting the turn: everything
258
+ // relayed above is void. Signal the viewer to discard the turn's streamed text/reasoning; the
259
+ // authoritative turn still comes from the single terminal `result` frame below.
260
+ onReset?.();
261
+ }
243
262
  else if (frame.kind === "result") {
244
263
  // contextTokens (when the broker knows the served model's window) lets the engine's loop
245
264
  // size compaction against the real window instead of a conservative default.
@@ -16,4 +16,7 @@ export interface CaptureConfig {
16
16
  }
17
17
  /** Read the capture config from env, or null when the desktop stack is absent or recording is off. */
18
18
  export declare function loadCaptureConfig(env: NodeJS.ProcessEnv): CaptureConfig | null;
19
+ /** ffmpeg args: one x11grab input, two outputs (change-driven segmented MP4 + a single overwritten
20
+ * JPEG). Exported for unit testing. */
21
+ export declare function ffmpegArgs(cfg: CaptureConfig, dir: string): string[];
19
22
  export declare function makeCaptureBackend(cfg: CaptureConfig): CaptureBackend;
@@ -2,6 +2,11 @@
2
2
  // the X display `:0` with two outputs — rolling MP4 segments (recording) and a single low-fps JPEG that
3
3
  // is continuously overwritten (the live-view frame). See docs/SCREEN_CAPTURE.md §4.
4
4
  //
5
+ // The recording is CHANGE-DRIVEN (mpdecimate + VFR): it stays always-on but only encodes frames that
6
+ // differ from the previous one, so an idle/headless desktop encodes ~nothing (a blank desktop collapses
7
+ // to ~1 frame) while a changing screen records in full. This keeps the always-on desktop cheap enough
8
+ // to run on every VM without a "record only when watched" gate.
9
+ //
5
10
  // Only runs where the runner IMAGE ships the desktop stack (Xvfb + ffmpeg), gated by
6
11
  // BOARDWALK_BROWSER_TIER=1 (the same "desktop present" signal the browser tier uses) + a
7
12
  // BOARDWALK_RECORDING_ENABLED kill switch (default on). Off on Fargate / self-hosted images with no
@@ -42,8 +47,9 @@ export function loadCaptureConfig(env) {
42
47
  wantedPollIntervalMs: intFromEnv(env.BOARDWALK_LIVEVIEW_WANTED_POLL_MS, 3000),
43
48
  };
44
49
  }
45
- /** ffmpeg args: one x11grab input, two outputs (segmented MP4 + a single overwritten JPEG). */
46
- function ffmpegArgs(cfg, dir) {
50
+ /** ffmpeg args: one x11grab input, two outputs (change-driven segmented MP4 + a single overwritten
51
+ * JPEG). Exported for unit testing. */
52
+ export function ffmpegArgs(cfg, dir) {
47
53
  const liveFps = Math.max(1, Math.round(1000 / cfg.liveFrameIntervalMs));
48
54
  return [
49
55
  "-y",
@@ -58,8 +64,17 @@ function ffmpegArgs(cfg, dir) {
58
64
  "-i",
59
65
  cfg.display,
60
66
  // Recording: H.264 MP4 segments, each a standalone playable file (docs/SCREEN_CAPTURE.md §4.2).
67
+ // Change-driven: `mpdecimate` drops near-duplicate frames and `-fps_mode vfr` lets the muxer honor
68
+ // those drops, so a static/idle desktop encodes ~nothing (collapses to ~1 frame) while a changing
69
+ // screen still records in full. Always-on — cost tracks on-screen activity, not a flat framerate.
70
+ // (`-preset ultrafast` would cut per-frame CPU further on busy screens but inflates file size; kept
71
+ // `veryfast` so the change is a pure win with no storage/bandwidth regression.)
61
72
  "-map",
62
73
  "0:v",
74
+ "-vf",
75
+ "mpdecimate",
76
+ "-fps_mode",
77
+ "vfr",
63
78
  "-c:v",
64
79
  "libx264",
65
80
  "-preset",
@@ -37,6 +37,18 @@ export interface Logger {
37
37
  warn(message: string, fields?: LogFields): void;
38
38
  error(message: string, fields?: LogFields): void;
39
39
  }
40
+ /**
41
+ * Freeze the runner's log level from a TRUSTED env — the platform boot env, snapshotted before the
42
+ * identity relay overlays a run's author env onto process.env (see `main`). Call once at bootstrap.
43
+ *
44
+ * Until it's called — in tests, and in the CLI/daemon before boot — {@link activeLevel} falls back to
45
+ * reading process.env live, so an operator's `--verbose`/`--debug` (which sets the env BEFORE boot,
46
+ * bin.ts) still applies. After it's called, a workflow author's `meta.env` (overlaid onto process.env
47
+ * at run time) can no longer raise the runner's own log verbosity. An author-facing verbosity control
48
+ * belongs in the manifest `meta` (e.g. a `logVerbosity` field), delivered over the trusted control
49
+ * plane — never an env knob.
50
+ */
51
+ export declare function configureLogging(env: NodeJS.ProcessEnv): void;
40
52
  export declare function createLogger(module: string): Logger;
41
53
  export type OrgRole = "owner" | "admin" | "member" | "viewer";
42
54
  export type AuthSource = "session_jwt" | "oauth_jwt" | "api_key" | "workflow";
@@ -71,15 +71,33 @@ export function newId() {
71
71
  return randomUUID();
72
72
  }
73
73
  const LEVEL_ORDER = { DEBUG: 10, INFO: 20, WARN: 30, ERROR: 40 };
74
- /** Active log level: BOARDWALK_RUNNER_LOG_LEVEL (debug|info|warn|error), default info.
75
- * BOARDWALK_RUNNER_DEBUG=1 is a legacy alias for debug. Read per call so a CLI flag that
76
- * sets the env (e.g. `--verbose` / `--debug`) applies without logger re-creation. */
77
- function activeLevel() {
78
- if (process.env.BOARDWALK_RUNNER_DEBUG === "1")
74
+ /** Numeric log level from an env: BOARDWALK_RUNNER_LOG_LEVEL (debug|info|warn|error), default info;
75
+ * BOARDWALK_RUNNER_DEBUG=1 is a legacy alias for debug. Reads the passed `env`, not process.env. */
76
+ function levelFromEnv(env) {
77
+ if (env.BOARDWALK_RUNNER_DEBUG === "1")
79
78
  return LEVEL_ORDER.DEBUG ?? 10;
80
- const name = process.env.BOARDWALK_RUNNER_LOG_LEVEL?.toUpperCase() ?? "INFO";
79
+ const name = env.BOARDWALK_RUNNER_LOG_LEVEL?.toUpperCase() ?? "INFO";
81
80
  return LEVEL_ORDER[name] ?? 20;
82
81
  }
82
+ /** The level frozen at worker bootstrap from the TRUSTED boot env, or null until then. */
83
+ let configuredLevel = null;
84
+ /**
85
+ * Freeze the runner's log level from a TRUSTED env — the platform boot env, snapshotted before the
86
+ * identity relay overlays a run's author env onto process.env (see `main`). Call once at bootstrap.
87
+ *
88
+ * Until it's called — in tests, and in the CLI/daemon before boot — {@link activeLevel} falls back to
89
+ * reading process.env live, so an operator's `--verbose`/`--debug` (which sets the env BEFORE boot,
90
+ * bin.ts) still applies. After it's called, a workflow author's `meta.env` (overlaid onto process.env
91
+ * at run time) can no longer raise the runner's own log verbosity. An author-facing verbosity control
92
+ * belongs in the manifest `meta` (e.g. a `logVerbosity` field), delivered over the trusted control
93
+ * plane — never an env knob.
94
+ */
95
+ export function configureLogging(env) {
96
+ configuredLevel = levelFromEnv(env);
97
+ }
98
+ function activeLevel() {
99
+ return configuredLevel ?? levelFromEnv(process.env);
100
+ }
83
101
  function emit(level, module, message, fields) {
84
102
  if ((LEVEL_ORDER[level] ?? 20) < activeLevel())
85
103
  return;
@@ -32,13 +32,17 @@ export interface ProxyError {
32
32
  }
33
33
  /** A parsed response frame. `ping` is a no-payload heartbeat the broker emits during a long model
34
34
  * turn to keep the connection producing bytes (so idle/body timeouts don't sever it); the worker
35
- * ignores it. */
35
+ * ignores it. `reset` voids every delta/reasoning relayed so far for the in-progress turn — the
36
+ * broker restarts a turn after a transient mid-stream drop; the worker signals the viewer to discard
37
+ * the turn's streamed output (the authoritative turn still arrives in the single `result`). */
36
38
  export type InferenceFrame = {
37
39
  kind: "delta";
38
40
  text: string;
39
41
  } | {
40
42
  kind: "reasoning";
41
43
  text: string;
44
+ } | {
45
+ kind: "reset";
42
46
  } | {
43
47
  kind: "result";
44
48
  turn: ChatTurn;
@@ -70,6 +74,10 @@ export declare function serializeReasoningFrame(text: string): string;
70
74
  export declare function serializeResultFrame(turn: ChatTurn, modelRef: string, costMicros?: number, contextTokens?: number): string;
71
75
  /** Serialize a terminal error as a single NDJSON line. */
72
76
  export declare function serializeErrorFrame(error: ProxyError): string;
77
+ /** Serialize a `reset` frame (no payload): the broker restarted a turn after a transient mid-stream
78
+ * drop, so every delta/reasoning relayed so far is void. The broker is the producer; this mirror
79
+ * exists so runner tests can construct the frame. */
80
+ export declare function serializeResetFrame(): string;
73
81
  /** Serialize a heartbeat (no payload) as a single NDJSON line. The broker emits these on an interval
74
82
  * during a long model turn so the worker↔broker connection keeps producing bytes — neither side's
75
83
  * idle/body timeout fires while the model is generating but not yet streaming text. */
@@ -100,6 +100,12 @@ export function serializeResultFrame(turn, modelRef, costMicros = 0, contextToke
100
100
  export function serializeErrorFrame(error) {
101
101
  return `${JSON.stringify({ t: "error", error })}\n`;
102
102
  }
103
+ /** Serialize a `reset` frame (no payload): the broker restarted a turn after a transient mid-stream
104
+ * drop, so every delta/reasoning relayed so far is void. The broker is the producer; this mirror
105
+ * exists so runner tests can construct the frame. */
106
+ export function serializeResetFrame() {
107
+ return `${JSON.stringify({ t: "reset" })}\n`;
108
+ }
103
109
  /** Serialize a heartbeat (no payload) as a single NDJSON line. The broker emits these on an interval
104
110
  * during a long model turn so the worker↔broker connection keeps producing bytes — neither side's
105
111
  * idle/body timeout fires while the model is generating but not yet streaming text. */
@@ -133,6 +139,8 @@ export function parseInferenceFrame(line) {
133
139
  };
134
140
  case "error":
135
141
  return { kind: "error", error: toProxyError(rec.error) };
142
+ case "reset":
143
+ return { kind: "reset" };
136
144
  case "ping":
137
145
  return { kind: "ping" };
138
146
  default:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@boardwalk-labs/runner",
3
- "version": "0.2.15",
3
+ "version": "0.2.17",
4
4
  "description": "Boardwalk self-hosted runner: execute cloud-scheduled runs on your own machines. Currently: the canonical runner contract types.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -54,7 +54,7 @@
54
54
  },
55
55
  "dependencies": {
56
56
  "@boardwalk-labs/engine": "0.2.12",
57
- "@boardwalk-labs/workflow": "0.2.5",
57
+ "@boardwalk-labs/workflow": "0.2.6",
58
58
  "@modelcontextprotocol/sdk": "^1.29.0",
59
59
  "node-gyp-build": "^4.8.4",
60
60
  "tar": "^7.5.16",