@boardwalk-labs/runner 0.2.0 → 0.2.2
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 +14 -1
- package/dist/runtime/index.js +72 -17
- 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 +28 -13
- package/dist/runtime/program_runner.js +69 -23
- package/dist/runtime/program_worker.d.ts +5 -0
- package/dist/runtime/program_worker.js +2 -0
- package/dist/runtime/suspension.d.ts +8 -2
- package/dist/runtime/workflow_host.d.ts +23 -2
- package/dist/runtime/workflow_host.js +35 -0
- package/dist/runtime/workspace_store.d.ts +31 -3
- package/dist/runtime/workspace_store.js +43 -4
- package/package.json +3 -3
|
@@ -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,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
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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
|
|
7
|
-
//
|
|
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
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
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
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
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
|
|
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
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
deps.
|
|
157
|
-
|
|
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
|
-
/**
|
|
9
|
-
|
|
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
|
-
|
|
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
|
}
|