@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 +5 -3
- package/dist/bin.js +5 -0
- package/dist/runtime/identity_relay.js +19 -9
- package/dist/runtime/index.d.ts +38 -0
- package/dist/runtime/index.js +64 -23
- package/dist/runtime/leaf_executor.js +20 -1
- package/dist/runtime/screen_capture_backend.d.ts +3 -0
- package/dist/runtime/screen_capture_backend.js +17 -2
- package/dist/runtime/support/index.d.ts +12 -0
- package/dist/runtime/support/index.js +24 -6
- package/dist/runtime/wire/inference_proxy.d.ts +9 -1
- package/dist/runtime/wire/inference_proxy.js +8 -0
- package/package.json +2 -2
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.
|
|
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
|
|
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
|
|
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
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
package/dist/runtime/index.d.ts
CHANGED
|
@@ -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>;
|
package/dist/runtime/index.js
CHANGED
|
@@ -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.
|
|
135
|
-
//
|
|
136
|
-
|
|
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
|
-
//
|
|
696
|
-
//
|
|
697
|
-
// absent ⇒ `computer.openBrowser()` fails clearly.
|
|
698
|
-
|
|
699
|
-
const browserBackend =
|
|
700
|
-
|
|
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
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
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
|
|
46
|
-
|
|
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
|
-
/**
|
|
75
|
-
* BOARDWALK_RUNNER_DEBUG=1 is a legacy alias for debug.
|
|
76
|
-
|
|
77
|
-
|
|
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 =
|
|
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.
|
|
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.
|
|
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",
|