@boardwalk-labs/runner 0.1.21 → 0.2.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.
- package/README.md +11 -0
- package/dist/contract.d.ts +2 -0
- package/dist/contract.js +14 -0
- package/dist/daemon/container.d.ts +3 -2
- package/dist/daemon/container.js +15 -2
- package/dist/daemon/daemon.d.ts +1 -0
- package/dist/daemon/daemon.js +16 -1
- package/dist/runtime/agent/budget.d.ts +14 -1
- package/dist/runtime/agent/budget.js +23 -0
- package/dist/runtime/broker_child_dispatcher.d.ts +0 -2
- package/dist/runtime/broker_child_dispatcher.js +0 -7
- package/dist/runtime/budget_gate.d.ts +51 -0
- package/dist/runtime/budget_gate.js +114 -0
- package/dist/runtime/freeze_coordinator.js +8 -0
- package/dist/runtime/index.d.ts +15 -2
- package/dist/runtime/index.js +80 -57
- package/dist/runtime/leaf_executor.d.ts +20 -0
- package/dist/runtime/leaf_executor.js +13 -4
- package/dist/runtime/local_workspace_store.d.ts +22 -0
- package/dist/runtime/local_workspace_store.js +103 -0
- package/dist/runtime/program_runner.d.ts +32 -28
- package/dist/runtime/program_runner.js +75 -44
- package/dist/runtime/program_worker.d.ts +5 -18
- package/dist/runtime/program_worker.js +3 -20
- package/dist/runtime/runner_control_client.d.ts +2 -22
- package/dist/runtime/runner_control_client.js +2 -49
- package/dist/runtime/suspension.d.ts +20 -109
- package/dist/runtime/suspension.js +18 -111
- package/dist/runtime/wire/human_input.d.ts +1 -1
- package/dist/runtime/wire/human_input.js +2 -2
- package/dist/runtime/workflow_host.d.ts +65 -62
- package/dist/runtime/workflow_host.js +124 -199
- package/dist/runtime/workspace_store.d.ts +31 -3
- package/dist/runtime/workspace_store.js +43 -4
- package/package.json +3 -3
package/dist/runtime/index.js
CHANGED
|
@@ -34,6 +34,7 @@ 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
38
|
import { parseByoProviders } from "./direct_inference.js";
|
|
38
39
|
import { applyIdentityToEnv, connectIdentityRelayFd, relayFdFromEnv, workerDiagnostics, } from "./identity_relay.js";
|
|
39
40
|
import { FreezeCoordinator } from "./freeze_coordinator.js";
|
|
@@ -56,10 +57,12 @@ import { CreditWatcher } from "./credit_watcher.js";
|
|
|
56
57
|
import { CancelWatcher } from "./cancel_watcher.js";
|
|
57
58
|
import { LeaseRenewer } from "./lease_renewer.js";
|
|
58
59
|
import { RuntimeFlusher } from "./runtime_flusher.js";
|
|
59
|
-
import { WorkspaceStore, TarWorkspaceArchiver, NodeWorkspaceFs } from "./workspace_store.js";
|
|
60
|
+
import { WorkspaceStore, TarWorkspaceArchiver, NodeWorkspaceFs, resolvePersistSelection, } from "./workspace_store.js";
|
|
61
|
+
import { LocalWorkspaceStore } from "./local_workspace_store.js";
|
|
60
62
|
import { PhaseTracker } from "./phase_tracker.js";
|
|
61
63
|
import { mkdir } from "node:fs/promises";
|
|
62
64
|
import { join } from "node:path";
|
|
65
|
+
import { tmpdir } from "node:os";
|
|
63
66
|
const log = createLogger("worker_entrypoint");
|
|
64
67
|
/** Durable events are deferred (durable event storage) — live fan-out via the broker only. */
|
|
65
68
|
/** Synthesize the run's AuthContext. The run was already authorized at trigger time; the
|
|
@@ -99,10 +102,6 @@ export function assembleWorkerDeps(runtime) {
|
|
|
99
102
|
runId: runtime.runId,
|
|
100
103
|
});
|
|
101
104
|
log.info("runner_control_enabled", { runId: runtime.runId });
|
|
102
|
-
// The replay frontier (the highest journaled seq at claim) drives SILENT REPLAY (the durable-suspension design):
|
|
103
|
-
// a resumed run suppresses observability for seams it already ran. Captured at claim, read by
|
|
104
|
-
// buildHost (which runs right after claim) when constructing the per-run host.
|
|
105
|
-
let replayFrontier = 0;
|
|
106
105
|
// Snapshot-substrate suspension (relay-mode boot only): one coordinator for the process, wired to
|
|
107
106
|
// the relay's suspend/wake channel now; its per-run hooks (token swap, meter rebase, workspace
|
|
108
107
|
// persist) late-bind in buildHost/startRuntimeFlush once those objects exist. The circular
|
|
@@ -239,6 +238,20 @@ export function assembleWorkerDeps(runtime) {
|
|
|
239
238
|
// it). Workspace-rooted at the same `/workspace` the leaf + tools use. Closed on the run's teardown
|
|
240
239
|
// (returned below as `lsp` → program_worker's finally) so no language-server process leaks.
|
|
241
240
|
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.
|
|
245
|
+
let budgetHost = null;
|
|
246
|
+
const budgetGate = new BudgetGate(budget, {
|
|
247
|
+
budgetClearance: (gate) => {
|
|
248
|
+
if (budgetHost === null) {
|
|
249
|
+
// Unreachable in a real run; a loud error beats parking against a null host.
|
|
250
|
+
return Promise.reject(new Error("budget gate reached before the workflow host was wired"));
|
|
251
|
+
}
|
|
252
|
+
return budgetHost.budgetClearance(gate);
|
|
253
|
+
},
|
|
254
|
+
});
|
|
242
255
|
const leaf = new EngineLeafExecutor({
|
|
243
256
|
inference: broker,
|
|
244
257
|
// Direct BYO (D7): the claim's registry + the run's RECORDING resolver, so a provider key
|
|
@@ -252,10 +265,14 @@ export function assembleWorkerDeps(runtime) {
|
|
|
252
265
|
}
|
|
253
266
|
: {}),
|
|
254
267
|
budget,
|
|
268
|
+
budgetGate,
|
|
255
269
|
redactor,
|
|
256
270
|
toolHost,
|
|
257
271
|
lspService,
|
|
258
272
|
workspaceRoot: runtime.workspaceRoot,
|
|
273
|
+
// Register every memory dir the run uses, so the workspace store persists it with no manifest
|
|
274
|
+
// declaration (WORKSPACE_PERSISTENCE.md §3). Read back by the store's `selection` thunk.
|
|
275
|
+
onMemoryUsed: (dir) => memoryDirs.add(dir),
|
|
259
276
|
// Resolve this run's deployed `skills/` dir for `agent({ skills })` — the engine reads
|
|
260
277
|
// `<skillsDir>/<name>.md`, so it's the `skills` subdir of the extracted program tree (filled
|
|
261
278
|
// once the artifact extracts). Null until then / when no program dir is known.
|
|
@@ -293,24 +310,35 @@ export function assembleWorkerDeps(runtime) {
|
|
|
293
310
|
// comes from the org's connection vault via the Runner Control API — never stored on the worker.
|
|
294
311
|
brokerMcpToken: (serverUrl, invalidateToken) => broker.mcpToken(serverUrl, invalidateToken),
|
|
295
312
|
});
|
|
296
|
-
// Per-workflow persistent /workspace
|
|
297
|
-
//
|
|
298
|
-
//
|
|
299
|
-
|
|
300
|
-
|
|
313
|
+
// Per-workflow persistent /workspace (docs/WORKSPACE_PERSISTENCE.md §3). Constructed on EVERY
|
|
314
|
+
// run, NOT gated on the manifest: `agent({ memory })` persists with no declaration at all, and a
|
|
315
|
+
// memory dir is only known once the run has made the call — so a construction-time manifest gate
|
|
316
|
+
// (the old `persist === true`) silently dropped BOTH the list form and every memory dir. What to
|
|
317
|
+
// write is decided at persist time by `resolvePersistSelection`; a run that selects nothing does
|
|
318
|
+
// no fs or broker work. The BROKER still gates eligibility (hosted-only; self-hosted gets null
|
|
319
|
+
// URLs), and snapshots are keyed per workflow + environment and scoped by the run token — so even
|
|
320
|
+
// the untrusted in-process program can't reach another tenant's, or another environment's, data.
|
|
321
|
+
const memoryDirs = new Set();
|
|
322
|
+
const selection = () => resolvePersistSelection(manifest.workspace?.persist, memoryDirs);
|
|
323
|
+
// TWO stores, ONE contract (WORKSPACE_PERSISTENCE.md I3): `runs_on` decides WHERE the bytes live,
|
|
324
|
+
// never WHETHER persistence happens. A runner with its OWN durable root (a self-hosted daemon,
|
|
325
|
+
// which sets PERSIST_ROOT) keeps state on the customer's disk and never touches our S3 — the
|
|
326
|
+
// broker returns null URLs for self-hosted runs by design. Hosted runners have no local disk that
|
|
327
|
+
// outlives the VM, so they push a tarball through the broker. Before this, "don't upload it" was
|
|
328
|
+
// implemented as "don't persist it", so self-hosted runs silently forgot everything.
|
|
329
|
+
const workspaceStore = runtime.persistScopeDir !== undefined
|
|
330
|
+
? new LocalWorkspaceStore({
|
|
331
|
+
scopeDir: runtime.persistScopeDir,
|
|
332
|
+
workspaceRoot: runtime.workspaceRoot,
|
|
333
|
+
selection,
|
|
334
|
+
})
|
|
335
|
+
: new WorkspaceStore({
|
|
301
336
|
broker,
|
|
302
337
|
archiver: new TarWorkspaceArchiver(),
|
|
303
338
|
fs: new NodeWorkspaceFs(),
|
|
304
339
|
workspaceRoot: runtime.workspaceRoot,
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
// Durable suspension (the durable-suspension design): the host raises a suspend OUT OF BAND (onSuspend +
|
|
308
|
-
// a never-resolving seam), so a program's own try/catch can't swallow it; the program runner
|
|
309
|
-
// races `suspendSignal` against the body. One deferred per run — the first seam to suspend wins.
|
|
310
|
-
let resolveSuspend = () => undefined;
|
|
311
|
-
const suspendSignal = new Promise((resolve) => {
|
|
312
|
-
resolveSuspend = resolve;
|
|
313
|
-
});
|
|
340
|
+
selection,
|
|
341
|
+
});
|
|
314
342
|
// The run's identity + on-demand public-API bearer, surfaced to the program via `import { runtime }`.
|
|
315
343
|
// ids come from the claimed run; the bearer is the captured (scrubbed-from-env) api token, already
|
|
316
344
|
// recorded in this run's redactor above — so threading it into an MCP header keeps it out of LLM
|
|
@@ -341,13 +369,6 @@ export function assembleWorkerDeps(runtime) {
|
|
|
341
369
|
runtime: runtimeContext,
|
|
342
370
|
// workflows.call → broker /children (resolve + callable_by gate server-side), bound to THIS run.
|
|
343
371
|
children: new BrokerChildDispatcher({ client: broker }),
|
|
344
|
-
// The durable-seam journal (memoization + replay) over the broker; the replay frontier drives
|
|
345
|
-
// silent replay; onSuspend resolves the deferred the program runner races.
|
|
346
|
-
journal: broker.journalSeam(),
|
|
347
|
-
replayFrontier,
|
|
348
|
-
onSuspend: (signal) => {
|
|
349
|
-
resolveSuspend(signal);
|
|
350
|
-
},
|
|
351
372
|
// The program's secrets.get(name) → the run's fail-closed RECORDING resolver.
|
|
352
373
|
secrets: { get: (name) => secretResolver.resolve({ name }) },
|
|
353
374
|
// events.emit → broker /events (fan-out server-side).
|
|
@@ -366,31 +387,25 @@ export function assembleWorkerDeps(runtime) {
|
|
|
366
387
|
signal,
|
|
367
388
|
// Snapshot the workspace before a long sleep (crash-during-hold recovery). The byte count
|
|
368
389
|
// is only logged by the store itself.
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
}
|
|
375
|
-
: {}),
|
|
376
|
-
// Snapshot-substrate suspension: seams freeze in place under the quiescence gate instead of
|
|
377
|
-
// raising onSuspend (which stays wired as the transitional/local path's fallback).
|
|
390
|
+
onBeforeSleep: async () => {
|
|
391
|
+
await workspaceStore.persist();
|
|
392
|
+
},
|
|
393
|
+
// Snapshot-substrate suspension: seams freeze in place under the quiescence gate. Absent
|
|
394
|
+
// (a self-hosted daemon / the Fargate break-glass), waiting seams HOLD the live process.
|
|
378
395
|
...(freeze !== undefined ? { freeze } : {}),
|
|
379
|
-
//
|
|
380
|
-
// freeze substrate,
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
poll: (seq) => broker.pollInputAnswers(seq),
|
|
386
|
-
},
|
|
387
|
-
}
|
|
388
|
-
: {}),
|
|
396
|
+
// Held HITL gates, backed by the broker's inputs endpoints: register-without-release on the
|
|
397
|
+
// freeze substrate, and the WHOLE gate mechanism (register + poll) on the hold path.
|
|
398
|
+
heldInput: {
|
|
399
|
+
register: (seq, gate) => broker.registerInput(seq, gate),
|
|
400
|
+
poll: (seq) => broker.pollInputAnswers(seq),
|
|
401
|
+
},
|
|
389
402
|
// Browser tier: `computer.openBrowser()` + `agent({ session })` resolve through this manager
|
|
390
403
|
// (absent ⇒ the host throws "not available on this runner image").
|
|
391
404
|
...(browserSessions !== undefined ? { browserSessions } : {}),
|
|
392
405
|
phases: phaseTracker,
|
|
393
406
|
});
|
|
407
|
+
// Close the budget gate's cycle: the leaf (built above) parks THROUGH the host (built just now).
|
|
408
|
+
budgetHost = host;
|
|
394
409
|
// Late-bind the coordinator's per-run hooks now that the run-scoped objects exist.
|
|
395
410
|
if (freeze !== undefined) {
|
|
396
411
|
const coordinator = freeze;
|
|
@@ -402,7 +417,7 @@ export function assembleWorkerDeps(runtime) {
|
|
|
402
417
|
onBeforeFreeze: async () => {
|
|
403
418
|
freezeWallMs = Date.now();
|
|
404
419
|
await activeFlusher?.flushNow();
|
|
405
|
-
await workspaceStore
|
|
420
|
+
await workspaceStore.persist();
|
|
406
421
|
// The recorder never spans a snapshot: finalize + upload the in-flight segment before the
|
|
407
422
|
// freeze (SCREEN_CAPTURE §4.3). Bounded internally, so a slow upload delays, not blocks, it.
|
|
408
423
|
await capture?.stopAndFlush();
|
|
@@ -463,9 +478,10 @@ export function assembleWorkerDeps(runtime) {
|
|
|
463
478
|
setProgramDir: (dir) => {
|
|
464
479
|
programDir = dir;
|
|
465
480
|
},
|
|
466
|
-
//
|
|
467
|
-
|
|
468
|
-
|
|
481
|
+
// Always present now: hydrate must run BEFORE the program, but whether anything compounds
|
|
482
|
+
// isn't known until the run's agent() calls have registered their memory dirs. A run that
|
|
483
|
+
// selects nothing persists nothing and pays for nothing (WorkspaceStore.persist returns early).
|
|
484
|
+
workspace: workspaceStore,
|
|
469
485
|
// The orchestrator reaps every still-open browser session on terminal (kill Chromium + its
|
|
470
486
|
// Playwright MCP) so no browser process leaks past the run.
|
|
471
487
|
...(browserSessions !== undefined ? { browserSessions } : {}),
|
|
@@ -474,6 +490,13 @@ export function assembleWorkerDeps(runtime) {
|
|
|
474
490
|
});
|
|
475
491
|
};
|
|
476
492
|
return {
|
|
493
|
+
// The two filesystem coordinates, both explicit (docs/WORKSPACE_PERSISTENCE.md I1/I2). The
|
|
494
|
+
// workspace is cwd + HOME for author code; the program extracts OUTSIDE it, so no snapshot ever
|
|
495
|
+
// captures the bundle. Neither is `process.cwd()` — deriving them from it is exactly how the
|
|
496
|
+
// hosted lanes drifted (the fleet boots bwinit as PID 1 with cwd `/`; Fargate's image left cwd
|
|
497
|
+
// at `/app`), and how a bundle would land inside a persisted workspace on the lanes that hadn't.
|
|
498
|
+
workspaceRoot: runtime.workspaceRoot,
|
|
499
|
+
programRoot: runtime.programRoot,
|
|
477
500
|
runs: {
|
|
478
501
|
// The broker owns the lease duration server-side; convert the absolute lease back to seconds.
|
|
479
502
|
claimForWorker: async (_runId, workerId, leaseUntil, nowMs) => {
|
|
@@ -483,8 +506,6 @@ export function assembleWorkerDeps(runtime) {
|
|
|
483
506
|
// Order this session's frames after a previous (crashed) session's, then announce the
|
|
484
507
|
// lifecycle transition the claim IS — the wire's `running` frame.
|
|
485
508
|
runEvents.resumeAfter(claimed.lastEventCursor);
|
|
486
|
-
// The replay frontier for a resumed run (0 on a fresh run); buildHost reads it next.
|
|
487
|
-
replayFrontier = claimed.lastJournalSeq;
|
|
488
509
|
runEvents.emit({ kind: "run_status", status: "running" });
|
|
489
510
|
return claimed.run;
|
|
490
511
|
},
|
|
@@ -513,12 +534,6 @@ export function assembleWorkerDeps(runtime) {
|
|
|
513
534
|
finalizer: {
|
|
514
535
|
finalize: (_id, status, output) => broker.finalize(status, output, runtime.workerId),
|
|
515
536
|
},
|
|
516
|
-
// Durable suspension (the durable-suspension design): persist the wake condition through the broker (journal
|
|
517
|
-
// entry + a HITL request row, or the wake time for a long sleep) and release the lease — no
|
|
518
|
-
// finalize. A wake (an answer or a timer) re-dispatches the run.
|
|
519
|
-
suspender: {
|
|
520
|
-
suspend: (signal, workerId) => broker.suspend(signal, workerId),
|
|
521
|
-
},
|
|
522
537
|
// Runtime metering: flush runtime as periodic deltas (+ a terminal tail) through the broker,
|
|
523
538
|
// idempotent per flush, so a long/perpetual run bills as it burns and the credit watcher sees it —
|
|
524
539
|
// not a single charge at terminal (which a never-terminating run never reached). A fresh per-session
|
|
@@ -689,6 +704,14 @@ export async function main() {
|
|
|
689
704
|
const deps = assembleWorkerDeps({
|
|
690
705
|
workerId: process.env.WORKER_ID ?? `worker-${runId}`,
|
|
691
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 }
|
|
714
|
+
: {}),
|
|
692
715
|
runId,
|
|
693
716
|
controlPlane: platform.controlPlane,
|
|
694
717
|
vcpus: platform.vcpus,
|
|
@@ -30,6 +30,21 @@ export interface EngineLeafExecutorDeps {
|
|
|
30
30
|
* reports usage after EVERY model call via `reportUsage`; we feed it here and throw on a cap
|
|
31
31
|
* breach so the loop terminates mid-flight (the budget authority). */
|
|
32
32
|
budget: BudgetMeter;
|
|
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.
|
|
38
|
+
*
|
|
39
|
+
* Why here and not at the `reportUsage` breach check below: that callback is synchronous and fires
|
|
40
|
+
* mid-turn, and per SUSPEND_POLICY Decision 1 a partial turn is never discarded. The model-call
|
|
41
|
+
* boundary is the first safe place to park, which bounds overrun at one in-flight turn per leaf.
|
|
42
|
+
*
|
|
43
|
+
* Absent ⇒ the legacy behavior: a breach fails the run at `reportUsage`.
|
|
44
|
+
*/
|
|
45
|
+
budgetGate?: {
|
|
46
|
+
clear(): Promise<void>;
|
|
47
|
+
};
|
|
33
48
|
/** RUN-level redactor (shared with the secret resolver): every resolved secret value is recorded
|
|
34
49
|
* here. We seed a fresh engine `Redactor` from it per leaf so the loop scrubs known values out of
|
|
35
50
|
* the prompt, tool args/results, and transcript before they reach the model. */
|
|
@@ -39,6 +54,11 @@ export interface EngineLeafExecutorDeps {
|
|
|
39
54
|
makeEventSink: EventSinkFactory;
|
|
40
55
|
/** The run's persistent `/workspace` root — memory dirs (`agent({ memory })`) are relative to it. */
|
|
41
56
|
workspaceRoot: string;
|
|
57
|
+
/** Register a memory dir the run actually used, so the workspace store persists it (§3 of
|
|
58
|
+
* docs/WORKSPACE_PERSISTENCE.md). Memory is undeclared by design, so this callback is the ONLY
|
|
59
|
+
* signal that the dir must compound — without it a `agent({ memory })`-only workflow silently
|
|
60
|
+
* persists nothing, which is exactly what shipped. Optional: absent ⇒ validation only. */
|
|
61
|
+
onMemoryUsed?: (dir: string) => void;
|
|
42
62
|
/** Resolves the directory holding this run's bundled files (the extracted program tree, where a
|
|
43
63
|
* skill lives at `skills/<name>.md`). Known only once the artifact is extracted (mid-run), so it's
|
|
44
64
|
* a thunk. Null / omitted ⇒ a leaf that names `skills` fails loud. */
|
|
@@ -104,6 +104,12 @@ export class EngineLeafExecutor {
|
|
|
104
104
|
// model server-side, invokes the matching adapter, and streams text back; we surface each delta
|
|
105
105
|
// through providerIo.onDelta and return the terminal turn. An aborted run rejects in-flight.
|
|
106
106
|
streamModel: async (req, providerIo) => {
|
|
107
|
+
throwIfAborted(signal);
|
|
108
|
+
// Budget clearance BEFORE the spend, not after: if the cap is already breached this parks
|
|
109
|
+
// the run (freeing the host on the snapshot fleet) until someone approves more. No-op on the
|
|
110
|
+
// overwhelming majority of calls. Re-check abort afterwards — a park can span a long wait,
|
|
111
|
+
// and the run may have been cancelled while frozen.
|
|
112
|
+
await this.deps.budgetGate?.clear();
|
|
107
113
|
throwIfAborted(signal);
|
|
108
114
|
return this.streamModel(req, providerIo, signal, redactor, (costMicros) => {
|
|
109
115
|
pendingCostMicros = (pendingCostMicros ?? 0) + costMicros;
|
|
@@ -148,15 +154,18 @@ export class EngineLeafExecutor {
|
|
|
148
154
|
leafIndex,
|
|
149
155
|
});
|
|
150
156
|
},
|
|
151
|
-
// A memory dir (`agent({ memory })`) is workspace-relative
|
|
152
|
-
//
|
|
153
|
-
//
|
|
154
|
-
//
|
|
157
|
+
// A memory dir (`agent({ memory })`) is workspace-relative and is persisted BECAUSE this hook
|
|
158
|
+
// registers it — memory carries no manifest declaration (`sdk/src/types.ts`), so this is the
|
|
159
|
+
// only signal the workspace store gets. It used to only validate, on the assumption that the
|
|
160
|
+
// whole workspace was persisted "when the manifest opts in": true for `persist: true`, and
|
|
161
|
+
// false for every other case, so memory silently evaporated on hosted runs. Validate first
|
|
162
|
+
// (defense-in-depth; the engine's buildToolSet already shape-validates), then register.
|
|
155
163
|
memoryUsed: (dir) => {
|
|
156
164
|
const abs = join(this.deps.workspaceRoot, dir);
|
|
157
165
|
if (abs !== this.deps.workspaceRoot && !abs.startsWith(this.deps.workspaceRoot + "/")) {
|
|
158
166
|
throw new EngineError("VALIDATION", `agent() memory dir "${dir}" escapes the workspace.`);
|
|
159
167
|
}
|
|
168
|
+
this.deps.onMemoryUsed?.(dir);
|
|
160
169
|
},
|
|
161
170
|
// OAuth bearer brokering for a hosted MCP server. Called REACTIVELY by the engine — only after a
|
|
162
171
|
// server 401s the static `headers` — so static-bearer / no-auth servers never reach here. When
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { PersistSelection } from "./workspace_store.js";
|
|
2
|
+
/** The durable directory for one scope, under the runner's persist root. */
|
|
3
|
+
export declare function localScopeDir(persistRoot: string, workflowId: string, environmentId: string | null): string;
|
|
4
|
+
export interface LocalWorkspaceStoreDeps {
|
|
5
|
+
/** This scope's durable directory ({@link localScopeDir}). */
|
|
6
|
+
scopeDir: string;
|
|
7
|
+
/** The run's `/workspace` — hydrated into at start, snapshotted from at terminal. */
|
|
8
|
+
workspaceRoot: string;
|
|
9
|
+
/** What to persist, read AT PERSIST TIME: the manifest's declaration ∪ the run's memory dirs. */
|
|
10
|
+
selection: () => PersistSelection;
|
|
11
|
+
}
|
|
12
|
+
export declare class LocalWorkspaceStore {
|
|
13
|
+
private readonly deps;
|
|
14
|
+
constructor(deps: LocalWorkspaceStoreDeps);
|
|
15
|
+
/** Copy this scope's durable state into the run's workspace. No-op on the first run (nothing
|
|
16
|
+
* persisted yet). Best-effort, like the hosted store: a restore miss must not fail the run — the
|
|
17
|
+
* workflow just re-does filesystem work, exactly as it would without persistence. */
|
|
18
|
+
hydrate(): Promise<void>;
|
|
19
|
+
/** Replace this scope's durable state with the run's. Returns the bytes written for the caller's
|
|
20
|
+
* logging — 0 on a no-op/failure, mirroring the hosted store's contract. */
|
|
21
|
+
persist(): Promise<number>;
|
|
22
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// LocalWorkspaceStore — per-(workflow, environment) persistent `/workspace` on the RUNNER's OWN disk.
|
|
2
|
+
//
|
|
3
|
+
// The self-hosted half of docs/WORKSPACE_PERSISTENCE.md I3: persistence has the same semantics on
|
|
4
|
+
// every substrate, and only the STORE changes. `runs_on` decides WHERE the bytes live, never WHETHER
|
|
5
|
+
// persistence happens.
|
|
6
|
+
//
|
|
7
|
+
// Hosted runs push a tarball through broker-presigned S3 URLs (workspace_store.ts). A self-hosted
|
|
8
|
+
// runner must never do that — its workspace is the customer's data on the customer's disk, and we
|
|
9
|
+
// don't store it (the broker returns null URLs for self-hosted runs, by design). But the answer to
|
|
10
|
+
// "don't upload it" was "don't persist it at all", so `workspace.persist` and `agent({ memory })`
|
|
11
|
+
// were SILENT no-ops on self-hosted: the same workflow compounded state on dev and hosted, and
|
|
12
|
+
// quietly forgot everything on a self-hosted runner. This is the missing third store — a plain
|
|
13
|
+
// directory tree the daemon owns, mirroring what the OSS engine does locally (boardwalk's
|
|
14
|
+
// run_dir.ts), with no network and no tarball.
|
|
15
|
+
//
|
|
16
|
+
// Layout: <persistRoot>/<workflowId>/<environmentId ?? _base>. Same scope key as the hosted S3 key,
|
|
17
|
+
// for the same reason — one workflow program runs against N environments (§4).
|
|
18
|
+
import { cp, mkdir, rm, stat } from "node:fs/promises";
|
|
19
|
+
import { dirname, join } from "node:path";
|
|
20
|
+
import { createLogger } from "./support/index.js";
|
|
21
|
+
const log = createLogger("LocalWorkspaceStore");
|
|
22
|
+
/** Directory name standing in for the BASE scope (a run with no environment). An environment id is a
|
|
23
|
+
* ULID, so it can never collide with this. */
|
|
24
|
+
const BASE_SCOPE_DIR = "_base";
|
|
25
|
+
/** The durable directory for one scope, under the runner's persist root. */
|
|
26
|
+
export function localScopeDir(persistRoot, workflowId, environmentId) {
|
|
27
|
+
return join(persistRoot, workflowId, environmentId ?? BASE_SCOPE_DIR);
|
|
28
|
+
}
|
|
29
|
+
export class LocalWorkspaceStore {
|
|
30
|
+
deps;
|
|
31
|
+
constructor(deps) {
|
|
32
|
+
this.deps = deps;
|
|
33
|
+
}
|
|
34
|
+
/** Copy this scope's durable state into the run's workspace. No-op on the first run (nothing
|
|
35
|
+
* persisted yet). Best-effort, like the hosted store: a restore miss must not fail the run — the
|
|
36
|
+
* workflow just re-does filesystem work, exactly as it would without persistence. */
|
|
37
|
+
async hydrate() {
|
|
38
|
+
try {
|
|
39
|
+
if (!(await exists(this.deps.scopeDir)))
|
|
40
|
+
return; // first run of this scope
|
|
41
|
+
// The durable root only ever holds what a previous run persisted (declared dirs + memory
|
|
42
|
+
// dirs), so all of it belongs in the workspace.
|
|
43
|
+
await cp(this.deps.scopeDir, this.deps.workspaceRoot, { recursive: true });
|
|
44
|
+
log.info("workspace_hydrated_local", { scopeDir: this.deps.scopeDir });
|
|
45
|
+
}
|
|
46
|
+
catch (err) {
|
|
47
|
+
log.warn("workspace_hydrate_local_failed", { error: errMsg(err) });
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
/** Replace this scope's durable state with the run's. Returns the bytes written for the caller's
|
|
51
|
+
* logging — 0 on a no-op/failure, mirroring the hosted store's contract. */
|
|
52
|
+
async persist() {
|
|
53
|
+
try {
|
|
54
|
+
const selection = this.deps.selection();
|
|
55
|
+
if (selection === true) {
|
|
56
|
+
// The whole workspace compounds: replace the scope wholesale, so a file the run DELETED is
|
|
57
|
+
// actually gone next run rather than resurrected from the old copy.
|
|
58
|
+
await rm(this.deps.scopeDir, { recursive: true, force: true });
|
|
59
|
+
await mkdir(dirname(this.deps.scopeDir), { recursive: true });
|
|
60
|
+
await cp(this.deps.workspaceRoot, this.deps.scopeDir, { recursive: true });
|
|
61
|
+
return await dirSize(this.deps.scopeDir);
|
|
62
|
+
}
|
|
63
|
+
if (selection.length === 0)
|
|
64
|
+
return 0; // nothing declared, no memory used — the common case
|
|
65
|
+
for (const dir of selection) {
|
|
66
|
+
const source = join(this.deps.workspaceRoot, dir);
|
|
67
|
+
const target = join(this.deps.scopeDir, dir);
|
|
68
|
+
// Replace per-dir (not merge) for the same reason as above: a deletion inside a persisted
|
|
69
|
+
// dir must survive. A declared dir the run never created is simply skipped.
|
|
70
|
+
await rm(target, { recursive: true, force: true });
|
|
71
|
+
if (!(await exists(source)))
|
|
72
|
+
continue;
|
|
73
|
+
await mkdir(dirname(target), { recursive: true });
|
|
74
|
+
await cp(source, target, { recursive: true });
|
|
75
|
+
}
|
|
76
|
+
return await dirSize(this.deps.scopeDir);
|
|
77
|
+
}
|
|
78
|
+
catch (err) {
|
|
79
|
+
log.warn("workspace_persist_local_failed", { error: errMsg(err) });
|
|
80
|
+
return 0;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
async function exists(path) {
|
|
85
|
+
return (await stat(path).catch(() => null)) !== null;
|
|
86
|
+
}
|
|
87
|
+
/** Bytes on disk under `dir` — reported for parity with the hosted store's return, never metered
|
|
88
|
+
* (self-hosted storage is the customer's own disk, so it is not our storage counter's business). */
|
|
89
|
+
async function dirSize(dir) {
|
|
90
|
+
const { readdir } = await import("node:fs/promises");
|
|
91
|
+
let total = 0;
|
|
92
|
+
const entries = await readdir(dir, { withFileTypes: true, recursive: true }).catch(() => []);
|
|
93
|
+
for (const entry of entries) {
|
|
94
|
+
if (!entry.isFile())
|
|
95
|
+
continue;
|
|
96
|
+
const s = await stat(join(entry.parentPath, entry.name)).catch(() => null);
|
|
97
|
+
total += s?.size ?? 0;
|
|
98
|
+
}
|
|
99
|
+
return total;
|
|
100
|
+
}
|
|
101
|
+
function errMsg(err) {
|
|
102
|
+
return err instanceof Error ? err.message : String(err);
|
|
103
|
+
}
|
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
import type { WorkflowHost } from "@boardwalk-labs/workflow/runtime";
|
|
2
|
-
import { type SuspendSignal } from "./suspension.js";
|
|
3
2
|
/**
|
|
4
|
-
* Link THIS runtime's `@boardwalk-labs/workflow` into the exec dir's `node_modules` so the
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
3
|
+
* Link THIS runtime's `@boardwalk-labs/workflow` into the exec dir's `node_modules` so the program's
|
|
4
|
+
* 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.
|
|
11
12
|
*/
|
|
12
13
|
export declare function ensureSdkLink(execDir: string): Promise<void>;
|
|
13
14
|
/** Resolve the program's entry module inside the extraction dir, refusing any path that escapes it.
|
|
@@ -15,6 +16,13 @@ export declare function ensureSdkLink(execDir: string): Promise<void>;
|
|
|
15
16
|
* arbitrary control plane, so this is defense-in-depth: an absolute path or a `..` that resolves
|
|
16
17
|
* outside `dir` throws rather than importing code from elsewhere on the machine. */
|
|
17
18
|
export declare function resolveEntryPath(dir: string, entry: string): string;
|
|
19
|
+
/**
|
|
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.
|
|
24
|
+
*/
|
|
25
|
+
export declare function assertProgramRootOutsideWorkspace(programRoot: string, workspaceRoot: string): void;
|
|
18
26
|
export interface RunProgramArgs {
|
|
19
27
|
/** Run id — used for the temp dir path + correlation. */
|
|
20
28
|
runId: string;
|
|
@@ -31,6 +39,18 @@ export interface RunProgramArgs {
|
|
|
31
39
|
export interface ProgramRunnerDeps {
|
|
32
40
|
/** The host adapter the program's hooks delegate to (agent leaf, sleep hold, child calls, secrets). */
|
|
33
41
|
host: WorkflowHost;
|
|
42
|
+
/**
|
|
43
|
+
* The run's `/workspace` — the working directory AND `HOME` for author code (I1). Must already
|
|
44
|
+
* exist (the orchestrator's `ensureWorkspace` guarantees it); a missing workspace fails the run
|
|
45
|
+
* loudly rather than silently running from wherever the process happened to start.
|
|
46
|
+
*/
|
|
47
|
+
workspaceRoot: string;
|
|
48
|
+
/**
|
|
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.
|
|
52
|
+
*/
|
|
53
|
+
programRoot: string;
|
|
34
54
|
/**
|
|
35
55
|
* Extract a gzipped tar file into a directory (created already). System `tar` in production
|
|
36
56
|
* (matches WorkspaceArchiver); injected in tests. The artifact's relative layout is preserved so
|
|
@@ -43,12 +63,6 @@ export interface ProgramRunnerDeps {
|
|
|
43
63
|
* (`<dir>/skills/<name>.md`). Optional — the local/test path may omit it.
|
|
44
64
|
*/
|
|
45
65
|
onExtracted?: (programDir: string) => void;
|
|
46
|
-
/**
|
|
47
|
-
* Root whose tree can resolve `@boardwalk-labs/workflow` (has `node_modules` reachable). The extracted
|
|
48
|
-
* program lives under here so its bare SDK import resolves to the same package instance the host
|
|
49
|
-
* was installed on. Defaults to `process.cwd()`.
|
|
50
|
-
*/
|
|
51
|
-
workRoot?: string;
|
|
52
66
|
/**
|
|
53
67
|
* Scrubs known secret values out of a string (the run's `SecretRedactor.redactText`). Applied to a
|
|
54
68
|
* top-level throw's message before it is logged AND before it is returned to the worker — a program
|
|
@@ -63,18 +77,11 @@ export interface ProgramRunnerDeps {
|
|
|
63
77
|
* throw (a telemetry hiccup can't change the run's result). Absent ⇒ no output entry.
|
|
64
78
|
*/
|
|
65
79
|
onOutput?: (value: unknown) => void;
|
|
66
|
-
/**
|
|
67
|
-
* Resolves when a host seam SUSPENDS the run (the durable-suspension design) — the worker wires it to the
|
|
68
|
-
* host's `onSuspend` so a suspend is surfaced OUT OF BAND, racing the program body. The program's
|
|
69
|
-
* own `try/catch` can't swallow a suspend this way (the suspending seam never resolves; this signal
|
|
70
|
-
* short-circuits at the runner). Absent ⇒ no suspension wired (a seam that suspends throws
|
|
71
|
-
* {@link SuspendError} instead, which is caught here just the same).
|
|
72
|
-
*/
|
|
73
|
-
suspendSignal?: Promise<SuspendSignal>;
|
|
74
80
|
}
|
|
75
|
-
/** Terminal
|
|
76
|
-
*
|
|
77
|
-
*
|
|
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. */
|
|
78
85
|
export type ProgramResult = {
|
|
79
86
|
kind: "completed";
|
|
80
87
|
output: unknown;
|
|
@@ -85,9 +92,6 @@ export type ProgramResult = {
|
|
|
85
92
|
code: string;
|
|
86
93
|
message: string;
|
|
87
94
|
};
|
|
88
|
-
} | {
|
|
89
|
-
kind: "suspended";
|
|
90
|
-
signal: SuspendSignal;
|
|
91
95
|
};
|
|
92
96
|
/**
|
|
93
97
|
* Run a workflow program to completion. Installs the host + input, extracts the VERIFIED artifact +
|