@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.
Files changed (35) hide show
  1. package/README.md +11 -0
  2. package/dist/contract.d.ts +2 -0
  3. package/dist/contract.js +14 -0
  4. package/dist/daemon/container.d.ts +3 -2
  5. package/dist/daemon/container.js +15 -2
  6. package/dist/daemon/daemon.d.ts +1 -0
  7. package/dist/daemon/daemon.js +16 -1
  8. package/dist/runtime/agent/budget.d.ts +14 -1
  9. package/dist/runtime/agent/budget.js +23 -0
  10. package/dist/runtime/broker_child_dispatcher.d.ts +0 -2
  11. package/dist/runtime/broker_child_dispatcher.js +0 -7
  12. package/dist/runtime/budget_gate.d.ts +51 -0
  13. package/dist/runtime/budget_gate.js +114 -0
  14. package/dist/runtime/freeze_coordinator.js +8 -0
  15. package/dist/runtime/index.d.ts +15 -2
  16. package/dist/runtime/index.js +80 -57
  17. package/dist/runtime/leaf_executor.d.ts +20 -0
  18. package/dist/runtime/leaf_executor.js +13 -4
  19. package/dist/runtime/local_workspace_store.d.ts +22 -0
  20. package/dist/runtime/local_workspace_store.js +103 -0
  21. package/dist/runtime/program_runner.d.ts +32 -28
  22. package/dist/runtime/program_runner.js +75 -44
  23. package/dist/runtime/program_worker.d.ts +5 -18
  24. package/dist/runtime/program_worker.js +3 -20
  25. package/dist/runtime/runner_control_client.d.ts +2 -22
  26. package/dist/runtime/runner_control_client.js +2 -49
  27. package/dist/runtime/suspension.d.ts +20 -109
  28. package/dist/runtime/suspension.js +18 -111
  29. package/dist/runtime/wire/human_input.d.ts +1 -1
  30. package/dist/runtime/wire/human_input.js +2 -2
  31. package/dist/runtime/workflow_host.d.ts +65 -62
  32. package/dist/runtime/workflow_host.js +124 -199
  33. package/dist/runtime/workspace_store.d.ts +31 -3
  34. package/dist/runtime/workspace_store.js +43 -4
  35. package/package.json +3 -3
@@ -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: only when the manifest opts in. The BROKER additionally
297
- // gates on hosted (self-hosted runs get null URLs), and snapshots are keyed per-workflow + scoped
298
- // by the run token so even the untrusted in-process program can't reach another tenant's data.
299
- const workspaceStore = manifest.workspace?.persist === true
300
- ? new WorkspaceStore({
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
- : undefined;
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
- ...(workspaceStore !== undefined
370
- ? {
371
- onBeforeSleep: async () => {
372
- await workspaceStore.persist();
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
- // Register-without-release for held HITL gates only on the
380
- // freeze substrate, backed by the broker's inputs endpoints.
381
- ...(freeze !== undefined
382
- ? {
383
- heldInput: {
384
- register: (seq, gate) => broker.registerInput(seq, gate),
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?.persist();
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
- // Resolves when a host seam suspends the run; the program runner races it against the body.
467
- suspendSignal,
468
- ...(workspaceStore !== undefined ? { workspace: workspaceStore } : {}),
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; the run's whole `/workspace` is
152
- // already persisted by the worker when the manifest opts in (workspace.persist), so this only
153
- // needs to validate the dir is inside the workspace (defense-in-depth) there is no separate
154
- // per-dir snapshot to register. The engine's buildToolSet already shape-validates the path.
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
- * program's bare import resolves anywhere (a self-hosted daemon workspace has no ancestor
6
- * `node_modules`; hosted images do, making the link a harmless no-op there). A symlink not a
7
- * copy is load-bearing: Node resolves it to the REAL path, so the program gets the same
8
- * module instance the host adapter was installed on (the singleton contract). `junction` covers
9
- * Windows without elevation; a failed link is only logged, since a resolvable ancestor may
10
- * still exist.
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 (or suspended) result of running a workflow program. `output` is what the program
76
- * declared via `output(value)` (null when it never did); for a failure it's null and `error` is set;
77
- * for a suspension the `signal` carries the wake condition (the worker persists it, no finalize). */
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 +