@boardwalk-labs/runner 0.2.0 → 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.
@@ -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,12 +1,14 @@
1
1
  import type { WorkflowHost } from "@boardwalk-labs/workflow/runtime";
2
2
  /**
3
- * Link THIS runtime's `@boardwalk-labs/workflow` into the exec dir's `node_modules` so the
4
- * program's bare import resolves anywhere (a self-hosted daemon workspace has no ancestor
5
- * `node_modules`; hosted images do, making the link a harmless no-op there). A symlink not a
6
- * copy is load-bearing: Node resolves it to the REAL path, so the program gets the same
7
- * module instance the host adapter was installed on (the singleton contract). `junction` covers
8
- * Windows without elevation; a failed link is only logged, since a resolvable ancestor may
9
- * 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.
10
12
  */
11
13
  export declare function ensureSdkLink(execDir: string): Promise<void>;
12
14
  /** Resolve the program's entry module inside the extraction dir, refusing any path that escapes it.
@@ -14,6 +16,13 @@ export declare function ensureSdkLink(execDir: string): Promise<void>;
14
16
  * arbitrary control plane, so this is defense-in-depth: an absolute path or a `..` that resolves
15
17
  * outside `dir` throws rather than importing code from elsewhere on the machine. */
16
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;
17
26
  export interface RunProgramArgs {
18
27
  /** Run id — used for the temp dir path + correlation. */
19
28
  runId: string;
@@ -30,6 +39,18 @@ export interface RunProgramArgs {
30
39
  export interface ProgramRunnerDeps {
31
40
  /** The host adapter the program's hooks delegate to (agent leaf, sleep hold, child calls, secrets). */
32
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;
33
54
  /**
34
55
  * Extract a gzipped tar file into a directory (created already). System `tar` in production
35
56
  * (matches WorkspaceArchiver); injected in tests. The artifact's relative layout is preserved so
@@ -42,12 +63,6 @@ export interface ProgramRunnerDeps {
42
63
  * (`<dir>/skills/<name>.md`). Optional — the local/test path may omit it.
43
64
  */
44
65
  onExtracted?: (programDir: string) => void;
45
- /**
46
- * Root whose tree can resolve `@boardwalk-labs/workflow` (has `node_modules` reachable). The extracted
47
- * program lives under here so its bare SDK import resolves to the same package instance the host
48
- * was installed on. Defaults to `process.cwd()`.
49
- */
50
- workRoot?: string;
51
66
  /**
52
67
  * Scrubs known secret values out of a string (the run's `SecretRedactor.redactText`). Applied to a
53
68
  * top-level throw's message before it is logged AND before it is returned to the worker — a program
@@ -3,8 +3,8 @@
3
3
  // A run is the execution of a built program ARTIFACT: the worker is handed the VERIFIED tarball
4
4
  // (its sha256 already checked against the pinned digest by the orchestrator) plus the entry module
5
5
  // name. This module is the mechanism: it installs the host adapter + trigger payload onto the
6
- // `@boardwalk-labs/workflow` singleton, extracts the artifact into a unique temp dir under a
7
- // node_modules-reachable root, and dynamic-imports the entry so the program's body runs. The
6
+ // `@boardwalk-labs/workflow` singleton, extracts the artifact into a unique temp dir under the
7
+ // program root, chdirs to the workspace, and dynamic-imports the entry so the program's body runs. The
8
8
  // program's `import { agent, sleep, … } from "@boardwalk-labs/workflow"` resolves to the SAME package
9
9
  // instance the host was installed on (one instance per process), so the hooks reach our adapter.
10
10
  //
@@ -13,15 +13,19 @@
13
13
  // `@boardwalk-labs/workflow` is left external in the bundle, so the imported program resolves it to the SDK
14
14
  // package present in the worker image (giving up its own copy would break the dual-adapter).
15
15
  //
16
- // Why a temp dir under a node_modules-reachable root (not os.tmpdir): the program imports the bare
17
- // specifier `@boardwalk-labs/workflow`, which Node resolves by walking up from the module's location to
18
- // `node_modules`. The extracted tree must therefore live inside the app tree (/app/.bw-runs/*).
16
+ // Two places, both explicit, neither derived from `process.cwd()` (docs/WORKSPACE_PERSISTENCE.md I1/I2):
17
+ // - `workspaceRoot` the run's `/workspace`. The working directory + HOME for AUTHOR code, so a
18
+ // relative write is the correct write and lands in what `workspace.persist` archives.
19
+ // - `programRoot` — where the artifact extracts (`<programRoot>/.bw-runs/<runId>-<uuid>`). Must be
20
+ // OUTSIDE the workspace, or the bundle rides into every snapshot and accumulates across runs.
21
+ // The extracted tree needs no node_modules-reachable ancestor: `ensureSdkLink` (below) links the SDK
22
+ // into the exec dir, so the bare `@boardwalk-labs/workflow` import resolves from any root.
19
23
  //
20
24
  // Durability: the body runs once, in-process. `sleep`/`workflows.call` hold in-process via the host
21
25
  // (no checkpoint, no exit). A crash mid-run restarts the run from the top (handled by the
22
26
  // worker/scheduler-sweep, not here). Output capture is deferred (v0 returns null).
23
27
  import { mkdir, writeFile, rm, symlink, lstat } from "node:fs/promises";
24
- import { dirname, join, isAbsolute, relative, sep } from "node:path";
28
+ import { dirname, join, isAbsolute, relative, resolve, sep } from "node:path";
25
29
  import { createRequire } from "node:module";
26
30
  import { pathToFileURL } from "node:url";
27
31
  import { randomUUID } from "node:crypto";
@@ -31,13 +35,15 @@ const log = createLogger("ProgramRunner");
31
35
  /** Subdirectory (under the work root) that holds transient extracted program trees. */
32
36
  const RUN_DIR = ".bw-runs";
33
37
  /**
34
- * Link THIS runtime's `@boardwalk-labs/workflow` into the exec dir's `node_modules` so the
35
- * program's bare import resolves anywhere (a self-hosted daemon workspace has no ancestor
36
- * `node_modules`; hosted images do, making the link a harmless no-op there). A symlink not a
37
- * copy is load-bearing: Node resolves it to the REAL path, so the program gets the same
38
- * module instance the host adapter was installed on (the singleton contract). `junction` covers
39
- * Windows without elevation; a failed link is only logged, since a resolvable ancestor may
40
- * still exist.
38
+ * Link THIS runtime's `@boardwalk-labs/workflow` into the exec dir's `node_modules` so the program's
39
+ * bare import resolves from ANY program root this link, not an ancestor `node_modules`, is what
40
+ * makes resolution work. (Worth stating plainly: a stale comment claiming the extraction dir had to
41
+ * sit under `/app` so the import could walk up to `/app/node_modules` outlived this function by
42
+ * several versions and sent a later reader chasing a dependency that no longer exists. The live
43
+ * fleet has no `/app` at all and resolves fine.) A symlink — not a copy is load-bearing: Node
44
+ * resolves it to the REAL path, so the program gets the same module instance the host adapter was
45
+ * installed on (the singleton contract). `junction` covers Windows without elevation; a failed link
46
+ * is only logged, since a resolvable ancestor may still exist.
41
47
  */
42
48
  export async function ensureSdkLink(execDir) {
43
49
  // A program tarball that ships its own `node_modules/@boardwalk-labs/workflow` would shadow the
@@ -82,19 +88,31 @@ export function resolveEntryPath(dir, entry) {
82
88
  }
83
89
  /** Scratch filename for the in-flight artifact tarball inside a run's dir. */
84
90
  const ARTIFACT_FILE = "__program.tgz";
91
+ /**
92
+ * Enforce I2: the extracted program must not live inside the run's workspace. Incidental separation
93
+ * is what broke before — the extraction root used to be `process.cwd()`, so it landed inside the
94
+ * workspace on exactly the lanes whose cwd was already correct. Compares RESOLVED paths, and treats
95
+ * "the workspace itself" as inside.
96
+ */
97
+ export function assertProgramRootOutsideWorkspace(programRoot, workspaceRoot) {
98
+ const rel = relative(resolve(workspaceRoot), resolve(programRoot));
99
+ if (rel === "" || (!rel.startsWith(`..${sep}`) && rel !== ".." && !isAbsolute(rel))) {
100
+ throw new AppError(ErrorCode.VALIDATION_FAILED, `The program root "${programRoot}" is inside the workspace "${workspaceRoot}"; the extracted program must live outside it.`);
101
+ }
102
+ }
85
103
  /**
86
104
  * Run a workflow program to completion. Installs the host + input, extracts the VERIFIED artifact +
87
105
  * dynamic-imports its entry (which runs the body), and returns the terminal result. Always tears the
88
106
  * runtime state down and removes the temp tree afterward.
89
107
  */
90
108
  export async function runWorkflowProgram(args, deps) {
91
- const workRoot = deps.workRoot ?? process.cwd();
92
- const dir = join(workRoot, RUN_DIR, `${args.runId}-${randomUUID()}`);
109
+ const dir = join(deps.programRoot, RUN_DIR, `${args.runId}-${randomUUID()}`);
93
110
  installHost(deps.host);
94
111
  installInput(args.input);
95
112
  // The run row's config is arbitrary JSON (jsonb); it IS valid JSON, so narrow to the SDK's JsonValue.
96
113
  installConfig(args.config);
97
114
  try {
115
+ assertProgramRootOutsideWorkspace(deps.programRoot, deps.workspaceRoot);
98
116
  await mkdir(dir, { recursive: true });
99
117
  const tgzPath = join(dir, ARTIFACT_FILE);
100
118
  await writeFile(tgzPath, args.tarball);
@@ -145,14 +163,42 @@ export async function runWorkflowProgram(args, deps) {
145
163
  * result; a program failure THROWS and is handled by the caller. A unique dir per run gives a
146
164
  * fresh URL so the module cache never returns an already-run program; `@vite-ignore` keeps
147
165
  * vitest/vite from statically analyzing the runtime URL.
166
+ *
167
+ * The chdir to the workspace (I1) happens HERE, at the boundary where author code starts, and only
168
+ * after the artifact is extracted from {@link ProgramRunnerDeps.programRoot} — so the program's
169
+ * files land in the workspace while the bundle stays out of it. Module resolution is unaffected:
170
+ * Node resolves file-relative (the entry is imported by absolute URL, the SDK via `ensureSdkLink`),
171
+ * never cwd-relative. The engine's local path has run exactly this shape since dev-on-engine
172
+ * (`boardwalk/src/run/child.ts`), and the self-hosted daemon spawns with the same cwd.
148
173
  */
149
174
  async function runProgramBody(entryPath, deps) {
150
- await import(/* @vite-ignore */ pathToFileURL(entryPath).href);
151
- // The program declares its result via `output(value)` (top-level code can't `return`); null when it
152
- // never called it. This becomes the run's persisted output + a `workflows.call` parent's value, and
153
- // (when actually declared) an `output` entry in the run's activity log.
154
- const declared = takeDeclaredOutput();
155
- if (declared !== null)
156
- deps.onOutput?.(declared.value);
157
- return { kind: "completed", output: declared !== null ? declared.value : null };
175
+ const callerCwd = process.cwd();
176
+ try {
177
+ process.chdir(deps.workspaceRoot);
178
+ }
179
+ catch (err) {
180
+ // Fail loud. Running author code from an arbitrary cwd is what silently threw writes away.
181
+ throw new AppError(ErrorCode.VALIDATION_FAILED, `The run's workspace "${deps.workspaceRoot}" is not usable as the working directory: ${err instanceof Error ? err.message : String(err)}`);
182
+ }
183
+ try {
184
+ await import(/* @vite-ignore */ pathToFileURL(entryPath).href);
185
+ // The program declares its result via `output(value)` (top-level code can't `return`); null when it
186
+ // never called it. This becomes the run's persisted output + a `workflows.call` parent's value, and
187
+ // (when actually declared) an `output` entry in the run's activity log.
188
+ const declared = takeDeclaredOutput();
189
+ if (declared !== null)
190
+ deps.onOutput?.(declared.value);
191
+ return { kind: "completed", output: declared !== null ? declared.value : null };
192
+ }
193
+ finally {
194
+ // One run per process in production, so this matters for tests + the local/dev path — but a
195
+ // function that permanently moves its caller's cwd is a trap either way. Best-effort: the caller's
196
+ // dir may itself be gone, and that must not mask the run's real outcome.
197
+ try {
198
+ process.chdir(callerCwd);
199
+ }
200
+ catch {
201
+ /* the caller's cwd vanished mid-run; nothing useful to do */
202
+ }
203
+ }
158
204
  }
@@ -128,6 +128,11 @@ export type LeaseWatchStarter = (args: {
128
128
  export interface ProgramWorkerDeps {
129
129
  runs: RunClaimer;
130
130
  versions: ProgramVersionReader;
131
+ /** The run's `/workspace` — cwd + HOME for author code (docs/WORKSPACE_PERSISTENCE.md I1), and the
132
+ * tree `workspace.persist` archives. Passed through to the program runner. */
133
+ workspaceRoot: string;
134
+ /** Where the program artifact extracts — OUTSIDE the workspace (I2). Passed through to the runner. */
135
+ programRoot: string;
131
136
  /** Download the program artifact bytes from the broker's presigned URL (broker.downloadBytes). */
132
137
  fetchProgram: (downloadUrl: string) => Promise<Uint8Array>;
133
138
  /** Extract a gzipped tar into a dir (system `tar`); passed through to the program runner. */
@@ -190,6 +190,8 @@ export async function runProgramWorker(runId, deps) {
190
190
  // onOutput emits the `output` activity entry into the run's log when the program declared one.
191
191
  {
192
192
  host,
193
+ workspaceRoot: deps.workspaceRoot,
194
+ programRoot: deps.programRoot,
193
195
  redactText: (text) => redactor.redactText(text),
194
196
  extract: deps.extractArchive,
195
197
  ...(setProgramDir !== undefined ? { onExtracted: setProgramDir } : {}),
@@ -5,8 +5,14 @@ import type { LeafCheckpoint } from "@boardwalk-labs/engine/core";
5
5
  * costs more than it saves. Without a freeze substrate every sleep holds, whatever its length.
6
6
  */
7
7
  export declare const SUSPEND_THRESHOLD_MS = 30000;
8
- /** Why a seam suspended the run. */
9
- export type SuspendReason = "human_input" | "sleep" | "workflow_call";
8
+ /**
9
+ * Why a seam suspended the run. `budget` is the odd one out: it is NOT a seam the program called but
10
+ * an involuntary park — the run hit its `max_usd` cap and is waiting for a person to approve more
11
+ * spend (docs/SUSPEND_POLICY.md Decision 3). It still carries a `humanInput` gate (key `budget`), so
12
+ * the control plane persists, surfaces, and answers it exactly like any other gate; only the reason
13
+ * differs, which is what lets the UI say "budget" instead of "waiting on a human".
14
+ */
15
+ export type SuspendReason = "human_input" | "sleep" | "workflow_call" | "budget";
10
16
  /** A human-in-the-loop gate carried out of a suspending seam (program-level or the in-leaf tool). */
11
17
  export interface HumanInputGate {
12
18
  /** The stable key the responder answers by (an author/derived key, or the model's tool-call id). */
@@ -45,8 +45,6 @@ export interface ChildDispatcher {
45
45
  call(slug: string, input: unknown, opts: CallOptions | undefined, signal?: AbortSignal): Promise<unknown>;
46
46
  /** Start (or idempotently re-attach to) a child run; resolves its current state. */
47
47
  start(slug: string, input: unknown, opts: CallOptions | undefined, signal?: AbortSignal): Promise<ChildResult>;
48
- /** Poll a child run's current state by id, or null when it isn't this run's child. */
49
- poll(childRunId: string): Promise<ChildResult | null>;
50
48
  run(slug: string, input: unknown, opts: CallOptions | undefined): Promise<string>;
51
49
  schedule(slug: string, input: unknown, opts: ScheduleOptions): Promise<string>;
52
50
  }
@@ -209,6 +207,29 @@ export declare class WorkerWorkflowHost implements WorkflowHost {
209
207
  * host always implements it. */
210
208
  humanInput(opts: HumanInputOptions): Promise<HumanInputResult>;
211
209
  private humanInputSeam;
210
+ /**
211
+ * Park the run at a BUDGET gate and resolve with the responder's answer (docs/SUSPEND_POLICY.md
212
+ * Decision 3). Called by the leaf executor's `streamModel` seam when the run's `max_usd` cap is
213
+ * breached, i.e. from INSIDE an in-flight `agent()` — which drives two deliberate differences from
214
+ * {@link humanInputSeam}:
215
+ *
216
+ * - **No `guarded()` wrapper.** The enclosing `agent()` seam already counted this leaf as work via
217
+ * `trackWork`. Wrapping again would double-count it, and the extra count would never be released
218
+ * — quiescence would never be reached and the freeze would hang forever. `freezeHumanInput` →
219
+ * `freezeWait` does the right thing here: it `endWork`s for the duration of the park (this leaf
220
+ * is waiting, not working, which is exactly what lets the run reach quiescence and freeze) and
221
+ * rejoins on resume.
222
+ * - **Abort is not re-checked up front.** `streamModel` has just done it; a park is not a new
223
+ * entry point.
224
+ *
225
+ * The gate itself is an ordinary {@link HumanInputGate} keyed `budget`, so it persists, surfaces in
226
+ * the inbox, and is answered by the same machinery as any other gate. No timeout: an unanswered
227
+ * budget gate is aged out by the control plane's inactive-cancel reaper, not by a wake we schedule.
228
+ */
229
+ budgetClearance(gate: {
230
+ prompt: string;
231
+ inputSpec: unknown;
232
+ }): Promise<HumanInputResult>;
212
233
  /** Absolute wake time for a `humanInput({ timeout })`, or null when there is none / it's unparseable. */
213
234
  private timeoutExpiry;
214
235
  callWorkflow(slug: string, input: unknown, opts: CallOptions | undefined): Promise<unknown>;
@@ -12,6 +12,7 @@
12
12
  import { AppError, ErrorCode } from "./support/index.js";
13
13
  import { LeafParked } from "@boardwalk-labs/engine/core";
14
14
  import { normalizeHumanInputResult } from "./wire/human_input.js";
15
+ import { BUDGET_GATE_KEY } from "./budget_gate.js";
15
16
  import { SuspensionCounter, SUSPEND_THRESHOLD_MS } from "./suspension.js";
16
17
  import { throwIfAborted } from "./run_abort.js";
17
18
  /** Parse a `humanInput({ timeout })` string (`"48h"`, `"30m"`, `"90s"`, `"7d"`) to milliseconds, or
@@ -303,6 +304,40 @@ export class WorkerWorkflowHost {
303
304
  // Hold path: register the gate and poll until a person answers.
304
305
  return normalizeHumanInputResult(await this.holdForAnswer(seq, signal.humanInput));
305
306
  }
307
+ /**
308
+ * Park the run at a BUDGET gate and resolve with the responder's answer (docs/SUSPEND_POLICY.md
309
+ * Decision 3). Called by the leaf executor's `streamModel` seam when the run's `max_usd` cap is
310
+ * breached, i.e. from INSIDE an in-flight `agent()` — which drives two deliberate differences from
311
+ * {@link humanInputSeam}:
312
+ *
313
+ * - **No `guarded()` wrapper.** The enclosing `agent()` seam already counted this leaf as work via
314
+ * `trackWork`. Wrapping again would double-count it, and the extra count would never be released
315
+ * — quiescence would never be reached and the freeze would hang forever. `freezeHumanInput` →
316
+ * `freezeWait` does the right thing here: it `endWork`s for the duration of the park (this leaf
317
+ * is waiting, not working, which is exactly what lets the run reach quiescence and freeze) and
318
+ * rejoins on resume.
319
+ * - **Abort is not re-checked up front.** `streamModel` has just done it; a park is not a new
320
+ * entry point.
321
+ *
322
+ * The gate itself is an ordinary {@link HumanInputGate} keyed `budget`, so it persists, surfaces in
323
+ * the inbox, and is answered by the same machinery as any other gate. No timeout: an unanswered
324
+ * budget gate is aged out by the control plane's inactive-cancel reaper, not by a wake we schedule.
325
+ */
326
+ async budgetClearance(gate) {
327
+ const seq = this.seq.next();
328
+ const key = BUDGET_GATE_KEY;
329
+ const signal = {
330
+ reason: "budget",
331
+ seq,
332
+ humanInput: { key, prompt: gate.prompt, inputSpec: gate.inputSpec },
333
+ };
334
+ const freeze = this.deps.freeze;
335
+ if (freeze !== undefined) {
336
+ return await this.freezeHumanInput(freeze, signal, key);
337
+ }
338
+ // No freeze substrate (self-hosted runner / local dev): hold the live process until answered.
339
+ return normalizeHumanInputResult(await this.holdForAnswer(seq, signal.humanInput));
340
+ }
306
341
  /** Absolute wake time for a `humanInput({ timeout })`, or null when there is none / it's unparseable. */
307
342
  timeoutExpiry(timeout) {
308
343
  const ms = parseTimeoutMs(timeout);
@@ -6,10 +6,27 @@
6
6
  * workspace never enters memory. NOT a security boundary (tenant isolation is the per-workflow key);
7
7
  * purely a guardrail, like the run budget's `max_usd`. */
8
8
  export declare const WORKSPACE_SNAPSHOT_MAX_BYTES: number;
9
+ /** What a run persists: the WHOLE workspace, or exactly these workspace-relative dirs (possibly none). */
10
+ export type PersistSelection = true | readonly string[];
11
+ /**
12
+ * Resolve what this run persists (docs/WORKSPACE_PERSISTENCE.md §3): the manifest's declaration
13
+ * UNIONED with every `agent({ memory })` dir the run actually used.
14
+ *
15
+ * The union is why this is resolved at PERSIST time, not construction time: memory dirs are
16
+ * undeclared by design (`sdk/src/types.ts` — "`mcp` servers, `skills`, and `memory` — the manifest
17
+ * declares none of them"), so they are only known once the run has made the agent calls. A workflow
18
+ * whose manifest says nothing at all still persists, iff it used memory.
19
+ *
20
+ * `true` swallows the list: the whole workspace already contains every memory dir. An empty array
21
+ * means persist nothing, which is the common case and must stay cheap.
22
+ */
23
+ export declare function resolvePersistSelection(declared: boolean | readonly string[] | undefined, memoryDirs: ReadonlySet<string>): PersistSelection;
9
24
  /** tar+gzip a directory to a file / extract one. Shells out to `tar` in production; injected in tests. */
10
25
  export interface WorkspaceArchiver {
11
- /** Create a gzipped tar of `dir`'s CONTENTS at `destPath`; resolve to the archive's byte size. */
12
- archive(dir: string, destPath: string): Promise<number>;
26
+ /** Create a gzipped tar of `dir`'s CONTENTS at `destPath`; resolve to the archive's byte size.
27
+ * `paths` (workspace-relative, already filtered to those that exist) narrows the archive to
28
+ * exactly those entries — the `persist: [...]` form. Omitted ⇒ the whole tree (`persist: true`). */
29
+ archive(dir: string, destPath: string, paths?: readonly string[]): Promise<number>;
13
30
  /** Extract a gzipped tar `srcPath` into `dir` (created if absent). */
14
31
  extract(srcPath: string, dir: string): Promise<void>;
15
32
  }
@@ -30,6 +47,10 @@ export interface WorkspaceFs {
30
47
  readFile(path: string): Promise<Uint8Array>;
31
48
  writeFile(path: string, data: Uint8Array): Promise<void>;
32
49
  rm(path: string): Promise<void>;
50
+ /** Does this path exist? Used to narrow a `persist: [...]` selection to the dirs the run actually
51
+ * created — `tar` fails the whole archive on one missing member, and a declared-but-unused dir is
52
+ * normal (a workflow declares `["cache", "index"]` and only writes `cache` on its first run). */
53
+ exists(path: string): Promise<boolean>;
33
54
  }
34
55
  export interface WorkspaceStoreDeps {
35
56
  broker: WorkspaceBrokerTransport;
@@ -37,6 +58,9 @@ export interface WorkspaceStoreDeps {
37
58
  fs: WorkspaceFs;
38
59
  /** The `/workspace` root to snapshot/restore. */
39
60
  workspaceRoot: string;
61
+ /** What to persist, read AT PERSIST TIME (see {@link resolvePersistSelection}) — the run's memory
62
+ * dirs aren't known until its agent calls have run, so this cannot be a construction-time value. */
63
+ selection: () => PersistSelection;
40
64
  /** Scratch path for the in-flight tarball. */
41
65
  tmpPath?: string;
42
66
  /** Snapshot tarballs larger than this are skipped (logged). Defaults to {@link WORKSPACE_SNAPSHOT_MAX_BYTES}. */
@@ -59,10 +83,13 @@ export declare class WorkspaceStore {
59
83
  * manifest opts into persistence, so archiving-first never runs for a non-persist workflow; the
60
84
  * only redundant archive is a self-hosted+persist run, where the broker returns a null URL. */
61
85
  persist(): Promise<number>;
86
+ /** Narrow a selection to the dirs that exist: `tar` fails the whole archive on one missing member,
87
+ * and declaring a dir the run hasn't written yet is ordinary (first run of `persist: ["cache"]`). */
88
+ private presentPaths;
62
89
  }
63
90
  /** Production archiver — shells out to the runner image's `tar` (the runner has full shell tooling). */
64
91
  export declare class TarWorkspaceArchiver implements WorkspaceArchiver {
65
- archive(dir: string, destPath: string): Promise<number>;
92
+ archive(dir: string, destPath: string, paths?: readonly string[]): Promise<number>;
66
93
  extract(srcPath: string, dir: string): Promise<void>;
67
94
  }
68
95
  /** Production fs — node's fs/promises. */
@@ -70,4 +97,5 @@ export declare class NodeWorkspaceFs implements WorkspaceFs {
70
97
  readFile(path: string): Promise<Uint8Array>;
71
98
  writeFile(path: string, data: Uint8Array): Promise<void>;
72
99
  rm(path: string): Promise<void>;
100
+ exists(path: string): Promise<boolean>;
73
101
  }