@boardwalk-labs/runner 0.1.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/LICENSE +202 -0
- package/README.md +68 -0
- package/dist/bin.d.ts +2 -0
- package/dist/bin.js +214 -0
- package/dist/contract.d.ts +154 -0
- package/dist/contract.js +196 -0
- package/dist/daemon/daemon.d.ts +45 -0
- package/dist/daemon/daemon.js +129 -0
- package/dist/daemon/identity.d.ts +15 -0
- package/dist/daemon/identity.js +44 -0
- package/dist/daemon/index.d.ts +3 -0
- package/dist/daemon/index.js +4 -0
- package/dist/daemon/pool_client.d.ts +31 -0
- package/dist/daemon/pool_client.js +101 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +8 -0
- package/dist/runtime/agent/budget.d.ts +123 -0
- package/dist/runtime/agent/budget.js +174 -0
- package/dist/runtime/agent/events.d.ts +46 -0
- package/dist/runtime/agent/events.js +8 -0
- package/dist/runtime/agent/model_rates.d.ts +7 -0
- package/dist/runtime/agent/model_rates.js +21 -0
- package/dist/runtime/agent/secret_redactor.d.ts +42 -0
- package/dist/runtime/agent/secret_redactor.js +98 -0
- package/dist/runtime/broker_artifact_store.d.ts +17 -0
- package/dist/runtime/broker_artifact_store.js +46 -0
- package/dist/runtime/broker_child_dispatcher.d.ts +31 -0
- package/dist/runtime/broker_child_dispatcher.js +88 -0
- package/dist/runtime/broker_event_publisher.d.ts +25 -0
- package/dist/runtime/broker_event_publisher.js +69 -0
- package/dist/runtime/broker_tool_host.d.ts +49 -0
- package/dist/runtime/broker_tool_host.js +240 -0
- package/dist/runtime/cancel_watcher.d.ts +31 -0
- package/dist/runtime/cancel_watcher.js +77 -0
- package/dist/runtime/credit_watcher.d.ts +30 -0
- package/dist/runtime/credit_watcher.js +78 -0
- package/dist/runtime/direct_inference.d.ts +29 -0
- package/dist/runtime/direct_inference.js +78 -0
- package/dist/runtime/index.d.ts +85 -0
- package/dist/runtime/index.js +525 -0
- package/dist/runtime/inference_transport.d.ts +5 -0
- package/dist/runtime/inference_transport.js +9 -0
- package/dist/runtime/leaf_executor.d.ts +104 -0
- package/dist/runtime/leaf_executor.js +333 -0
- package/dist/runtime/lease_renewer.d.ts +31 -0
- package/dist/runtime/lease_renewer.js +80 -0
- package/dist/runtime/main.d.ts +1 -0
- package/dist/runtime/main.js +7 -0
- package/dist/runtime/phase_tracker.d.ts +26 -0
- package/dist/runtime/phase_tracker.js +45 -0
- package/dist/runtime/program_log_capture.d.ts +20 -0
- package/dist/runtime/program_log_capture.js +79 -0
- package/dist/runtime/program_runner.d.ts +82 -0
- package/dist/runtime/program_runner.js +116 -0
- package/dist/runtime/program_worker.d.ts +177 -0
- package/dist/runtime/program_worker.js +272 -0
- package/dist/runtime/recording_secret_resolver.d.ts +9 -0
- package/dist/runtime/recording_secret_resolver.js +22 -0
- package/dist/runtime/run_abort.d.ts +16 -0
- package/dist/runtime/run_abort.js +38 -0
- package/dist/runtime/run_event_emitter.d.ts +24 -0
- package/dist/runtime/run_event_emitter.js +63 -0
- package/dist/runtime/runner_control_client.d.ts +184 -0
- package/dist/runtime/runner_control_client.js +501 -0
- package/dist/runtime/runtime_flusher.d.ts +41 -0
- package/dist/runtime/runtime_flusher.js +90 -0
- package/dist/runtime/sandbox_config.d.ts +21 -0
- package/dist/runtime/sandbox_config.js +34 -0
- package/dist/runtime/support/index.d.ts +53 -0
- package/dist/runtime/support/index.js +114 -0
- package/dist/runtime/suspension.d.ts +141 -0
- package/dist/runtime/suspension.js +120 -0
- package/dist/runtime/testing_artifact_build.d.ts +27 -0
- package/dist/runtime/testing_artifact_build.js +96 -0
- package/dist/runtime/tools/artifacts.d.ts +146 -0
- package/dist/runtime/tools/artifacts.js +125 -0
- package/dist/runtime/tools/types.d.ts +114 -0
- package/dist/runtime/tools/types.js +61 -0
- package/dist/runtime/tools/web_search.d.ts +66 -0
- package/dist/runtime/tools/web_search.js +165 -0
- package/dist/runtime/wire/artifact_storage.d.ts +37 -0
- package/dist/runtime/wire/artifact_storage.js +93 -0
- package/dist/runtime/wire/artifact_verify.d.ts +1 -0
- package/dist/runtime/wire/artifact_verify.js +8 -0
- package/dist/runtime/wire/human_input.d.ts +62 -0
- package/dist/runtime/wire/human_input.js +156 -0
- package/dist/runtime/wire/inference_proxy.d.ts +70 -0
- package/dist/runtime/wire/inference_proxy.js +157 -0
- package/dist/runtime/wire/manifest.d.ts +16 -0
- package/dist/runtime/wire/manifest.js +18 -0
- package/dist/runtime/wire/run.d.ts +54 -0
- package/dist/runtime/wire/run.js +2 -0
- package/dist/runtime/workflow_host.d.ts +194 -0
- package/dist/runtime/workflow_host.js +382 -0
- package/dist/runtime/workspace_store.d.ts +73 -0
- package/dist/runtime/workspace_store.js +118 -0
- package/package.json +70 -0
|
@@ -0,0 +1,525 @@
|
|
|
1
|
+
// Worker composition root (docs/WORKFLOW_RUNTIME.md + docs/RUNNER_BROKER.md). The Boardwalk-hosted
|
|
2
|
+
// worker container runs `node dist/fargate/worker/index.js`; the dispatcher launches it per run with
|
|
3
|
+
// RUN_ID + the per-run control-plane handle. This file assembles real implementations behind every
|
|
4
|
+
// seam `runProgramWorker(runId, deps)` injects, then runs the one workflow program to terminal.
|
|
5
|
+
//
|
|
6
|
+
// BROKERED-ONLY (security review #1): the runner is UNTRUSTED and holds NO platform credential — only
|
|
7
|
+
// its short-lived run token. EVERY privileged seam goes through the Runner Control API (the broker):
|
|
8
|
+
// - agent() → EngineLeafExecutor: the OSS engine's loop (runAgentLeaf) over a broker-backed
|
|
9
|
+
// LeafIo — the model turn streams through /inference (invoked server-side).
|
|
10
|
+
// - sleep() → WorkerWorkflowHost in-process hold (hold-and-pay; no checkpoint/exit).
|
|
11
|
+
// - secrets.get() → broker /secrets/resolve (allowlist enforced server-side).
|
|
12
|
+
// - workflows.call()→ broker /children (durable child run, hold + poll).
|
|
13
|
+
// - events.emit() → broker /events (fan-out server-side).
|
|
14
|
+
// - artifacts / web_search → broker /artifacts + /tools/web_search (no S3 / Tavily creds here).
|
|
15
|
+
// - lifecycle / usage / telemetry → broker /claim,/version,/finalize,/usage,/telemetry.
|
|
16
|
+
// There is no DB / Redis / Stripe / Bedrock client on the runner — so its task role is near-zero and
|
|
17
|
+
// the metadata-endpoint escape has nothing to steal. The legacy direct (pre-broker) path is removed.
|
|
18
|
+
//
|
|
19
|
+
// Split: `assembleWorkerDeps(runtime)` is pure wiring (unit-tested with a fake fetch); `main()` is the
|
|
20
|
+
// bootstrap shell (read the control-plane env, install signal handlers).
|
|
21
|
+
//
|
|
22
|
+
// Per-session loops wired here (all brokered): a UsageFlusher meters token deltas (§10.7 →
|
|
23
|
+
// /usage/tokens), a CreditWatcher polls funding (§15 → /credit, aborting the run on exhaustion), and a
|
|
24
|
+
// CancelWatcher polls for a user cancel (§6 → /cancel, aborting the run when the user cancels — the
|
|
25
|
+
// brokered worker holds no Redis, so it can't receive the api-server's cancel publish directly).
|
|
26
|
+
//
|
|
27
|
+
// Documented v0 deferral (a clear seam): durable events — a no-op AgentEventStore (live Redis fan-out
|
|
28
|
+
// via the broker only) until the run_step_events table lands.
|
|
29
|
+
import { createLogger, newId, AppError, ErrorCode, DEFAULT_LEASE_MS } from "./support/index.js";
|
|
30
|
+
import { LspService } from "@boardwalk-labs/engine/core";
|
|
31
|
+
import { BudgetMeter } from "./agent/budget.js";
|
|
32
|
+
import { WorkerRunEventEmitter } from "./run_event_emitter.js";
|
|
33
|
+
import { BUDGET_GUARDRAIL_RATE } from "./agent/model_rates.js";
|
|
34
|
+
import { SecretRedactor } from "./agent/secret_redactor.js";
|
|
35
|
+
import { RecordingSecretResolver } from "./recording_secret_resolver.js";
|
|
36
|
+
import { EngineLeafExecutor } from "./leaf_executor.js";
|
|
37
|
+
import { parseByoProviders } from "./direct_inference.js";
|
|
38
|
+
import { WorkerWorkflowHost } from "./workflow_host.js";
|
|
39
|
+
import { runProgramWorker, } from "./program_worker.js";
|
|
40
|
+
import { RunnerControlClient } from "./runner_control_client.js";
|
|
41
|
+
import { BrokerChildDispatcher } from "./broker_child_dispatcher.js";
|
|
42
|
+
import { BrokerEventPublisher } from "./broker_event_publisher.js";
|
|
43
|
+
import { createProgramLogSink } from "./program_log_capture.js";
|
|
44
|
+
import { BrokerArtifactStore } from "./broker_artifact_store.js";
|
|
45
|
+
import { buildBrokerToolHost } from "./broker_tool_host.js";
|
|
46
|
+
import { CreditWatcher } from "./credit_watcher.js";
|
|
47
|
+
import { CancelWatcher } from "./cancel_watcher.js";
|
|
48
|
+
import { LeaseRenewer } from "./lease_renewer.js";
|
|
49
|
+
import { RuntimeFlusher } from "./runtime_flusher.js";
|
|
50
|
+
import { WorkspaceStore, TarWorkspaceArchiver, NodeWorkspaceFs } from "./workspace_store.js";
|
|
51
|
+
import { PhaseTracker } from "./phase_tracker.js";
|
|
52
|
+
import { mkdir } from "node:fs/promises";
|
|
53
|
+
import { join } from "node:path";
|
|
54
|
+
const log = createLogger("worker_entrypoint");
|
|
55
|
+
/** Durable events are deferred (run_step_events table) — live fan-out via the broker only. */
|
|
56
|
+
/** Synthesize the run's AuthContext. The run was already authorized at trigger time; the
|
|
57
|
+
* worker acts on the org's behalf, so it carries the org + an owner role. Tool-level
|
|
58
|
+
* boundaries (the broker's server-side manifest allowlist) are the real guard.
|
|
59
|
+
*
|
|
60
|
+
* source='workflow' (NOT 'session_jwt'): the program must never perform SESSION_JWT_ONLY
|
|
61
|
+
* credential mutations (§12.11), so a tool that ever exposed such a service is denied by
|
|
62
|
+
* construction regardless of the owner role. */
|
|
63
|
+
export function workerAuthContext(run) {
|
|
64
|
+
const userId = run.actor.type === "user" ? run.actor.user_id : `workflow:${run.workflowId}`;
|
|
65
|
+
return { userId, orgId: run.orgId, role: "owner", source: "workflow" };
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Sequencer for per-call token-metering identifiers, `<runId>:<sessionId>:<leafIndex>:<turnSeq>`.
|
|
69
|
+
*
|
|
70
|
+
* The worker's `meterUsage` hook fires once per model TURN (the engine's leaf loop reports usage
|
|
71
|
+
* after every model call), but the display aggregate is idempotent on this identifier
|
|
72
|
+
* (UsageEventRepository.recordAndAggregate → `onConflictDoNothing`). A key that didn't vary per call
|
|
73
|
+
* (it was `<runId>:<sessionId>:<leafIndex>`) collapsed every turn after a leaf's first into a dropped
|
|
74
|
+
* duplicate — so `runs.tokens_in/out` reflected only each leaf's FIRST turn and under-reported
|
|
75
|
+
* multi-turn leaves by orders of magnitude, even as the BudgetMeter (which accumulates every turn)
|
|
76
|
+
* still tripped on the real spend. Bumping a per-session counter gives every turn its own idempotent
|
|
77
|
+
* event. Restart safety rides the session id (a fresh `meteringSessionId` per worker session) — a
|
|
78
|
+
* restart genuinely re-spends inference, so it must re-meter rather than dedupe against a prior run.
|
|
79
|
+
*/
|
|
80
|
+
export function tokenMeterIdentifiers(runId, sessionId) {
|
|
81
|
+
let seq = 0;
|
|
82
|
+
return (leafIndex) => `${runId}:${sessionId}:${String(leafIndex)}:${String(seq++)}`;
|
|
83
|
+
}
|
|
84
|
+
/** Pure wiring: build the full ProgramWorkerDeps from the control-plane handle. The runner reaches
|
|
85
|
+
* every privileged seam through the broker over its run token — no DB / Redis / Stripe / model creds. */
|
|
86
|
+
export function assembleWorkerDeps(runtime) {
|
|
87
|
+
const broker = new RunnerControlClient({
|
|
88
|
+
baseUrl: runtime.controlPlane.baseUrl,
|
|
89
|
+
runToken: runtime.controlPlane.runToken,
|
|
90
|
+
runId: runtime.runId,
|
|
91
|
+
});
|
|
92
|
+
log.info("runner_control_enabled", { runId: runtime.runId });
|
|
93
|
+
// The replay frontier (the highest journaled seq at claim) drives SILENT REPLAY (docs/SUSPENSION.md):
|
|
94
|
+
// a resumed run suppresses observability for seams it already ran. Captured at claim, read by
|
|
95
|
+
// buildHost (which runs right after claim) when constructing the per-run host.
|
|
96
|
+
let replayFrontier = 0;
|
|
97
|
+
// Live agent-event stream → /telemetry (broker publishes to Redis server-side). Inference,
|
|
98
|
+
// web_search, artifacts, and the model call are all brokered — no Redis / model creds / Tavily / S3.
|
|
99
|
+
const eventPublisher = new BrokerEventPublisher({
|
|
100
|
+
send: (frames) => broker.publishTelemetry(frames),
|
|
101
|
+
});
|
|
102
|
+
// The run's ONE event envelope counter (v1 wire format, engine parity): phases, program logs,
|
|
103
|
+
// declared output, and every agent turn stamp through this emitter, so cursors are run-globally
|
|
104
|
+
// monotonic with no separate program band. Frames publish as `{cursor, event}` rows via the
|
|
105
|
+
// broker telemetry path. The claim wrapper below bumps it past a previous session's frames.
|
|
106
|
+
const runEvents = new WorkerRunEventEmitter({ runId: runtime.runId, publisher: eventPublisher });
|
|
107
|
+
const phaseTracker = new PhaseTracker({ sink: runEvents });
|
|
108
|
+
// The run's public-API credential (was BOARDWALK_API_KEY). It is NO LONGER in process.env — the
|
|
109
|
+
// bootstrap captured + scrubbed it (capturePlatformContext) so the agent's bash / subprocesses
|
|
110
|
+
// can't read it. The program reaches it ONLY via `runtime.apiToken()` (built below); we still
|
|
111
|
+
// record it in each run's redactor so a value the program threads into a tool can't echo back out
|
|
112
|
+
// of a tool result. Absent in the dev no-signing-key path. (docs/RUN_ENV_AND_CREDS.md)
|
|
113
|
+
const runApiKey = runtime.controlPlane.apiToken;
|
|
114
|
+
// The broker (Runner Control API) is hosted on the api-server, so the public API shares its
|
|
115
|
+
// origin; `runtime.apiUrl` exposes it and the program appends `/v1` or `/mcp/v1`.
|
|
116
|
+
const apiUrl = publicApiOrigin(runtime.controlPlane.baseUrl);
|
|
117
|
+
// Per-run host: agent() leaf + sleep-hold + secrets + children + events, all brokered. `signal`
|
|
118
|
+
// carries cooperative cancellation (credit exhaustion) into every host hook.
|
|
119
|
+
const buildHost = (run, manifest, signal) => {
|
|
120
|
+
const budget = new BudgetMeter({
|
|
121
|
+
...(manifest.budget !== undefined ? { budget: manifest.budget } : {}),
|
|
122
|
+
// Fallback budget-cap rate. A managed turn caps on the broker's EXACT upstream cost (forwarded on
|
|
123
|
+
// the inference result frame → BudgetMeter `realCostUsd`); this representative sonnet-class rate
|
|
124
|
+
// applies only to a turn with no upstream cost (BYO / unavailable). `max_usd` is a guardrail, not
|
|
125
|
+
// the bill — that's per-leaf cost pass-through at the broker.
|
|
126
|
+
rate: BUDGET_GUARDRAIL_RATE,
|
|
127
|
+
startedAt: Date.now(),
|
|
128
|
+
// deadline_seconds is WALL-CLOCK from the run's ORIGINAL start (incl. suspended idle), so a run
|
|
129
|
+
// resumed past its deadline trips on its first cap check. Falls back to now on the first session
|
|
130
|
+
// (startedAt is being set this session anyway).
|
|
131
|
+
deadlineStartedAt: run.startedAt ?? Date.now(),
|
|
132
|
+
});
|
|
133
|
+
// Per-session id for per-turn metering identifiers (`<runId>:<sessionId>:<leafIndex>:<turnSeq>`) —
|
|
134
|
+
// a fresh value per worker session keeps a resumed run's events from colliding with a prior
|
|
135
|
+
// session's (a restart genuinely re-spends, so it must re-meter).
|
|
136
|
+
const meteringSessionId = newId();
|
|
137
|
+
// Mint a unique identifier per metering call. `meterUsage` fires once per model TURN, so the key
|
|
138
|
+
// must vary per call: the display aggregate is idempotent on it (see {@link tokenMeterIdentifiers}).
|
|
139
|
+
const nextMeterIdentifier = tokenMeterIdentifiers(run.id, meteringSessionId);
|
|
140
|
+
// Per-run secret boundary: every value resolved (program `secrets.get` OR a tool's
|
|
141
|
+
// ctx.secrets.resolve) is recorded into one shared redactor; the leaf seeds a fresh engine
|
|
142
|
+
// Redactor from it so the loop scrubs those values out of all model-bound content. MASTER_SPEC §12.
|
|
143
|
+
const redactor = new SecretRedactor();
|
|
144
|
+
// Record the run's own API key so a prompt-injected agent can't echo it back to the model.
|
|
145
|
+
if (runApiKey !== undefined && runApiKey !== "")
|
|
146
|
+
redactor.record(runApiKey);
|
|
147
|
+
const secretResolver = new RecordingSecretResolver({ resolve: (ref) => broker.resolveSecret(ref.name) }, redactor);
|
|
148
|
+
// The extracted program dir isn't known until the runner unpacks the artifact (mid-run); the leaf
|
|
149
|
+
// reads it through this holder, which `setProgramDir` (returned below → runner `onExtracted`) fills.
|
|
150
|
+
let programDir = null;
|
|
151
|
+
// Broker-backed artifact store (shared by the program's artifacts.write hook AND the engine's
|
|
152
|
+
// host-backed `artifacts` tool). Presigned for large bodies; the runner holds no S3 creds.
|
|
153
|
+
const artifactStore = new BrokerArtifactStore(broker);
|
|
154
|
+
// Backend for the engine's host-backed built-in tools (webfetch/web_search/artifacts): web_search
|
|
155
|
+
// + artifact read-back go through the broker (no Tavily/S3 creds here); webfetch is an in-process
|
|
156
|
+
// fetch already gated by the worker's egress proxy.
|
|
157
|
+
const toolHost = buildBrokerToolHost({ broker, artifacts: artifactStore });
|
|
158
|
+
// ONE engine-native LSP per RUN (not per leaf): the language server must persist across the run's
|
|
159
|
+
// edits/leaves so it stays warm. The engine reads `capabilities.lspService` to light up the
|
|
160
|
+
// `diagnostics` tool + diagnostics-after-edit; it spawns `typescript-language-server` in the worker
|
|
161
|
+
// and degrades gracefully when that binary isn't in the image (a separate runner-image change adds
|
|
162
|
+
// it). Workspace-rooted at the same `/workspace` the leaf + tools use. Closed on the run's teardown
|
|
163
|
+
// (returned below as `lsp` → program_worker's finally) so no language-server process leaks.
|
|
164
|
+
const lspService = new LspService({ workspaceDir: runtime.workspaceRoot });
|
|
165
|
+
const leaf = new EngineLeafExecutor({
|
|
166
|
+
inference: broker,
|
|
167
|
+
// Direct BYO (D7): the claim's registry + the run's RECORDING resolver, so a provider key
|
|
168
|
+
// registers with the redactor the moment it resolves.
|
|
169
|
+
...(runtime.byoProviders !== undefined && runtime.byoProviders.length > 0
|
|
170
|
+
? {
|
|
171
|
+
byo: {
|
|
172
|
+
registry: runtime.byoProviders,
|
|
173
|
+
resolveSecret: (name) => secretResolver.resolve({ name }),
|
|
174
|
+
},
|
|
175
|
+
}
|
|
176
|
+
: {}),
|
|
177
|
+
budget,
|
|
178
|
+
redactor,
|
|
179
|
+
toolHost,
|
|
180
|
+
lspService,
|
|
181
|
+
workspaceRoot: runtime.workspaceRoot,
|
|
182
|
+
// Resolve this run's deployed `skills/` dir for `agent({ skills })` — the engine reads
|
|
183
|
+
// `<skillsDir>/<name>.md`, so it's the `skills` subdir of the extracted program tree (filled
|
|
184
|
+
// once the artifact extracts). Null until then / when no program dir is known.
|
|
185
|
+
skillsDir: () => (programDir === null ? null : join(programDir, "skills")),
|
|
186
|
+
// The extracted program tree IS the workflow PACKAGE root on hosted runs: the deploy artifact
|
|
187
|
+
// ships the bundled `AGENTS.md` at its root (and `skills/` beside it), so the engine reads
|
|
188
|
+
// `<programDir>/AGENTS.md` for the bundled tier. Same holder the `skillsDir` thunk reads — null
|
|
189
|
+
// until the artifact extracts. The empty `/workspace` and this dir are SEPARATE dirs on hosted,
|
|
190
|
+
// exactly the two-tier case the engine's AGENTS.md loader is built for.
|
|
191
|
+
programDir: () => programDir,
|
|
192
|
+
// Per-leaf, per-model token metering — reported THROUGH the broker (the worker holds no Stripe
|
|
193
|
+
// credential); the broker decides `billed_by_boardwalk` per model + meters to Stripe (BYO models
|
|
194
|
+
// no-op there). Fire-and-forget: a metering hiccup must never fail the run.
|
|
195
|
+
meterUsage: ({ model, inputTokens, outputTokens, cachedReadTokens, cachedWriteTokens, leafIndex, }) => {
|
|
196
|
+
void broker
|
|
197
|
+
.meterTokens({
|
|
198
|
+
inputTokens,
|
|
199
|
+
outputTokens,
|
|
200
|
+
model,
|
|
201
|
+
identifier: nextMeterIdentifier(leafIndex),
|
|
202
|
+
...(cachedReadTokens === undefined ? {} : { cachedReadTokens }),
|
|
203
|
+
...(cachedWriteTokens === undefined ? {} : { cachedWriteTokens }),
|
|
204
|
+
})
|
|
205
|
+
.catch((err) => {
|
|
206
|
+
log.warn("leaf_usage_meter_failed", {
|
|
207
|
+
runId: run.id,
|
|
208
|
+
error: err instanceof Error ? err.message : String(err),
|
|
209
|
+
});
|
|
210
|
+
});
|
|
211
|
+
},
|
|
212
|
+
// Turn numbering is owned by the shared emitter (beginTurn opens the stride block); the leaf
|
|
213
|
+
// index stays only in metering identifiers. `identity` names the leaf on its turn frames.
|
|
214
|
+
makeEventSink: (_leafIndex, _identity) => runEvents,
|
|
215
|
+
// Broker an OAuth bearer for an `agent({ mcp })` server (called reactively on a 401). The token
|
|
216
|
+
// comes from the org's connection vault via the Runner Control API — never stored on the worker.
|
|
217
|
+
brokerMcpToken: (serverUrl, invalidateToken) => broker.mcpToken(serverUrl, invalidateToken),
|
|
218
|
+
});
|
|
219
|
+
// Per-workflow persistent /workspace (§5): only when the manifest opts in. The BROKER additionally
|
|
220
|
+
// gates on hosted (self-hosted runs get null URLs), and snapshots are keyed per-workflow + scoped
|
|
221
|
+
// by the run token — so even the untrusted in-process program can't reach another tenant's data.
|
|
222
|
+
const workspaceStore = manifest.workspace?.persist === true
|
|
223
|
+
? new WorkspaceStore({
|
|
224
|
+
broker,
|
|
225
|
+
archiver: new TarWorkspaceArchiver(),
|
|
226
|
+
fs: new NodeWorkspaceFs(),
|
|
227
|
+
workspaceRoot: runtime.workspaceRoot,
|
|
228
|
+
})
|
|
229
|
+
: undefined;
|
|
230
|
+
// Durable suspension (docs/SUSPENSION.md): the host raises a suspend OUT OF BAND (onSuspend +
|
|
231
|
+
// a never-resolving seam), so a program's own try/catch can't swallow it; the program runner
|
|
232
|
+
// races `suspendSignal` against the body. One deferred per run — the first seam to suspend wins.
|
|
233
|
+
let resolveSuspend = () => undefined;
|
|
234
|
+
const suspendSignal = new Promise((resolve) => {
|
|
235
|
+
resolveSuspend = resolve;
|
|
236
|
+
});
|
|
237
|
+
// The run's identity + on-demand public-API bearer, surfaced to the program via `import { runtime }`.
|
|
238
|
+
// ids come from the claimed run; the bearer is the captured (scrubbed-from-env) api token, already
|
|
239
|
+
// recorded in this run's redactor above — so threading it into an MCP header keeps it out of LLM
|
|
240
|
+
// context. Throws clearly when a run was provisioned no api token (dev no-signing-key path).
|
|
241
|
+
const runtimeContext = {
|
|
242
|
+
runId: run.id,
|
|
243
|
+
workflowId: run.workflowId,
|
|
244
|
+
orgId: run.orgId,
|
|
245
|
+
apiUrl,
|
|
246
|
+
apiToken: () => {
|
|
247
|
+
if (runApiKey === undefined || runApiKey === "") {
|
|
248
|
+
return Promise.reject(new AppError(ErrorCode.FORBIDDEN, "runtime.apiToken() is unavailable: this run was not provisioned a public-API token"));
|
|
249
|
+
}
|
|
250
|
+
return Promise.resolve(runApiKey);
|
|
251
|
+
},
|
|
252
|
+
};
|
|
253
|
+
const host = new WorkerWorkflowHost({
|
|
254
|
+
leaf,
|
|
255
|
+
runtime: runtimeContext,
|
|
256
|
+
// workflows.call → broker /children (resolve + callable_by gate server-side), bound to THIS run.
|
|
257
|
+
children: new BrokerChildDispatcher({ client: broker }),
|
|
258
|
+
// The durable-seam journal (memoization + replay) over the broker; the replay frontier drives
|
|
259
|
+
// silent replay; onSuspend resolves the deferred the program runner races.
|
|
260
|
+
journal: broker.journalSeam(),
|
|
261
|
+
replayFrontier,
|
|
262
|
+
onSuspend: (signal) => {
|
|
263
|
+
resolveSuspend(signal);
|
|
264
|
+
},
|
|
265
|
+
// The program's secrets.get(name) → the run's fail-closed RECORDING resolver.
|
|
266
|
+
secrets: { get: (name) => secretResolver.resolve({ name }) },
|
|
267
|
+
// events.emit → broker /events (fan-out server-side).
|
|
268
|
+
// artifacts.write → broker artifact store (presigned; the runner holds no S3 creds). Text ships
|
|
269
|
+
// as utf8, raw bytes as base64; the stored artifact's signed URL surfaces as ArtifactRef.url.
|
|
270
|
+
writeArtifact: (name, contentType, body, metadata) => artifactStore
|
|
271
|
+
.write({
|
|
272
|
+
name,
|
|
273
|
+
contentType,
|
|
274
|
+
body: typeof body === "string" ? body : Buffer.from(body).toString("base64"),
|
|
275
|
+
encoding: typeof body === "string" ? "utf8" : "base64",
|
|
276
|
+
...(metadata !== undefined ? { metadata } : {}),
|
|
277
|
+
})
|
|
278
|
+
.then((res) => ({ id: res.id, name: res.name, url: res.signedUrl })),
|
|
279
|
+
// Cooperative cancellation: every host hook honors this (credit exhaustion aborts it).
|
|
280
|
+
signal,
|
|
281
|
+
// Snapshot the workspace before a long sleep (crash-during-hold recovery). The byte count
|
|
282
|
+
// is only logged by the store itself.
|
|
283
|
+
...(workspaceStore !== undefined
|
|
284
|
+
? {
|
|
285
|
+
onBeforeSleep: async () => {
|
|
286
|
+
await workspaceStore.persist();
|
|
287
|
+
},
|
|
288
|
+
}
|
|
289
|
+
: {}),
|
|
290
|
+
phases: phaseTracker,
|
|
291
|
+
});
|
|
292
|
+
// Hand the redactor back too: the worker scrubs a terminal error's message with it (review #5);
|
|
293
|
+
// workspace (when opted in) hydrates at start + persists at terminal.
|
|
294
|
+
return Promise.resolve({
|
|
295
|
+
host,
|
|
296
|
+
redactor,
|
|
297
|
+
phases: phaseTracker,
|
|
298
|
+
// The orchestrator closes the per-run LSP on every terminal path (close() is idempotent + never
|
|
299
|
+
// throws) so no language-server process leaks past the run.
|
|
300
|
+
lsp: lspService,
|
|
301
|
+
// The orchestrator emits the program's declared output onto the wire (`output` kind).
|
|
302
|
+
activity: { output: (value) => void runEvents.emit({ kind: "output", value }) },
|
|
303
|
+
// Filled by the runner once the artifact extracts; the leaf's `skillsDir` thunk reads it.
|
|
304
|
+
setProgramDir: (dir) => {
|
|
305
|
+
programDir = dir;
|
|
306
|
+
},
|
|
307
|
+
// Resolves when a host seam suspends the run; the program runner races it against the body.
|
|
308
|
+
suspendSignal,
|
|
309
|
+
...(workspaceStore !== undefined ? { workspace: workspaceStore } : {}),
|
|
310
|
+
});
|
|
311
|
+
};
|
|
312
|
+
return {
|
|
313
|
+
runs: {
|
|
314
|
+
// The broker owns the lease duration server-side; convert the absolute lease back to seconds.
|
|
315
|
+
claimForWorker: async (_runId, workerId, leaseUntil, nowMs) => {
|
|
316
|
+
const claimed = await broker.claim(workerId, Math.max(1, Math.round((leaseUntil - nowMs) / 1000)));
|
|
317
|
+
if (claimed === null)
|
|
318
|
+
return null;
|
|
319
|
+
// Order this session's frames after a previous (crashed) session's, then announce the
|
|
320
|
+
// lifecycle transition the claim IS — the wire's `running` frame.
|
|
321
|
+
runEvents.resumeAfter(claimed.lastEventCursor);
|
|
322
|
+
// The replay frontier for a resumed run (0 on a fresh run); buildHost reads it next.
|
|
323
|
+
replayFrontier = claimed.lastJournalSeq;
|
|
324
|
+
runEvents.emit({ kind: "run_status", status: "running" });
|
|
325
|
+
return claimed.run;
|
|
326
|
+
},
|
|
327
|
+
},
|
|
328
|
+
versions: {
|
|
329
|
+
getById: async () => {
|
|
330
|
+
const v = await broker.getVersion();
|
|
331
|
+
return v === null ? null : { manifest: v.manifest, program: v.program };
|
|
332
|
+
},
|
|
333
|
+
},
|
|
334
|
+
// Download the pinned program artifact from the broker's presigned URL; the worker verifies its
|
|
335
|
+
// digest (program_worker) before extracting + importing it.
|
|
336
|
+
fetchProgram: async (downloadUrl) => {
|
|
337
|
+
const bytes = await broker.downloadBytes(downloadUrl);
|
|
338
|
+
if (bytes === null)
|
|
339
|
+
throw new Error("program artifact download returned no body");
|
|
340
|
+
return bytes;
|
|
341
|
+
},
|
|
342
|
+
// Extract the verified artifact tarball via system `tar` (same impl as workspace snapshots).
|
|
343
|
+
extractArchive: (tgzPath, destDir) => new TarWorkspaceArchiver().extract(tgzPath, destDir),
|
|
344
|
+
// Guarantee /workspace exists before the program runs (override-safe; the image also pre-creates
|
|
345
|
+
// it). Lets authors write to /workspace without a defensive mkdir — see WorkflowMeta.workspace.
|
|
346
|
+
ensureWorkspace: async () => {
|
|
347
|
+
await mkdir(runtime.workspaceRoot, { recursive: true });
|
|
348
|
+
},
|
|
349
|
+
finalizer: {
|
|
350
|
+
finalize: (_id, status, output) => broker.finalize(status, output, runtime.workerId),
|
|
351
|
+
},
|
|
352
|
+
// Durable suspension (docs/SUSPENSION.md): persist the wake condition through the broker (journal
|
|
353
|
+
// entry + a HITL request row, or the wake time for a long sleep) and release the lease — no
|
|
354
|
+
// finalize. A wake (an answer or a timer) re-dispatches the run.
|
|
355
|
+
suspender: {
|
|
356
|
+
suspend: (signal, workerId) => broker.suspend(signal, workerId),
|
|
357
|
+
},
|
|
358
|
+
// Runtime metering (§15): flush runtime as periodic deltas (+ a terminal tail) through the broker,
|
|
359
|
+
// idempotent per flush, so a long/perpetual run bills as it burns and the credit watcher sees it —
|
|
360
|
+
// not a single charge at terminal (which a never-terminating run never reached). A fresh per-session
|
|
361
|
+
// id keeps a restarted run's sessions distinct in the idempotency key (distinct ids sum).
|
|
362
|
+
startRuntimeFlush: ({ run, startedAtMs }) => {
|
|
363
|
+
const flusher = new RuntimeFlusher({
|
|
364
|
+
runId: run.id,
|
|
365
|
+
sessionId: newId(),
|
|
366
|
+
startedAtMs,
|
|
367
|
+
// Bill per vCPU-second: scale wall-clock by the task's vCPUs ($0.05/vCPU-min). At the 1-vCPU
|
|
368
|
+
// default this is a no-op; a 4-vCPU `large` run books 4× the seconds it holds the task for.
|
|
369
|
+
vcpus: runtime.vcpus,
|
|
370
|
+
now: Date.now,
|
|
371
|
+
report: (deltaSeconds, identifier) => broker.reportUsage(deltaSeconds, identifier),
|
|
372
|
+
});
|
|
373
|
+
flusher.start();
|
|
374
|
+
return { stop: () => flusher.stop(), flushFinal: () => flusher.flushFinal() };
|
|
375
|
+
},
|
|
376
|
+
buildHost,
|
|
377
|
+
// Mid-run credit watching (§15): a CreditWatcher polls the broker's GET /credit on a timer; when
|
|
378
|
+
// the org runs out, onExhausted aborts the run (the orchestrator wires it to AbortController).
|
|
379
|
+
startCreditWatch: ({ run, onExhausted }) => {
|
|
380
|
+
const watcher = new CreditWatcher({
|
|
381
|
+
runId: run.id,
|
|
382
|
+
isFunded: () => broker.checkCredit(),
|
|
383
|
+
onExhausted,
|
|
384
|
+
});
|
|
385
|
+
watcher.start();
|
|
386
|
+
return { stop: () => watcher.stop() };
|
|
387
|
+
},
|
|
388
|
+
// Mid-run user-cancel watching (§6): a CancelWatcher polls the broker's GET /cancel on a timer;
|
|
389
|
+
// when the user cancels (the run is flipped to `cancelling`), onCancelled aborts the run. This is
|
|
390
|
+
// how the brokered (Redis-less) worker learns of a cancel — the api-server's Redis publish can't
|
|
391
|
+
// reach it.
|
|
392
|
+
startCancelWatch: ({ run, onCancelled }) => {
|
|
393
|
+
const watcher = new CancelWatcher({
|
|
394
|
+
runId: run.id,
|
|
395
|
+
isCancelled: () => broker.checkCancelled(),
|
|
396
|
+
onCancelled,
|
|
397
|
+
});
|
|
398
|
+
watcher.start();
|
|
399
|
+
return { stop: () => watcher.stop() };
|
|
400
|
+
},
|
|
401
|
+
// Lease renewal (§6): a LeaseRenewer extends the lease through the broker's POST /renew on a timer
|
|
402
|
+
// (well under the lease), so a run longer than the 5-min lease isn't reclaimed mid-flight by the
|
|
403
|
+
// recovery sweep. `renew` re-extends with the SAME workerId that claimed it; a null result (the
|
|
404
|
+
// run is no longer ours) fires onLost → the run aborts `lease_lost` without finalizing.
|
|
405
|
+
startLeaseRenew: ({ run, onLost }) => {
|
|
406
|
+
const watcher = new LeaseRenewer({
|
|
407
|
+
runId: run.id,
|
|
408
|
+
renew: async () => (await broker.renewLease(runtime.workerId, Math.round(DEFAULT_LEASE_MS / 1000))) !== null,
|
|
409
|
+
onLost,
|
|
410
|
+
});
|
|
411
|
+
watcher.start();
|
|
412
|
+
return { stop: () => watcher.stop() };
|
|
413
|
+
},
|
|
414
|
+
// Surface the program's console.* output as `program_output` run-events (the log channel),
|
|
415
|
+
// so a plain console.log program shows up in the run's live tail.
|
|
416
|
+
onProgramLog: createProgramLogSink({ sink: runEvents }),
|
|
417
|
+
workerId: runtime.workerId,
|
|
418
|
+
// Drain the live-tail telemetry buffer before the worker exits (it batches frames).
|
|
419
|
+
flushTelemetry: () => eventPublisher.close(),
|
|
420
|
+
};
|
|
421
|
+
}
|
|
422
|
+
// ---- Bootstrap (real container only) -------------------------------------------------
|
|
423
|
+
/**
|
|
424
|
+
* The per-run platform values the dispatcher injects as container env (ecs_run_task_client.ts). They
|
|
425
|
+
* are captured into private worker state at bootstrap and DELETED from `process.env` before any user
|
|
426
|
+
* program / agent leaf / subprocess can run — the run token + API token are credentials, and nothing
|
|
427
|
+
* untrusted run code touches should inherit them (docs/RUN_ENV_AND_CREDS.md). The user owns the rest
|
|
428
|
+
* of `process.env` outright.
|
|
429
|
+
*/
|
|
430
|
+
export const PLATFORM_ENV_KEYS = [
|
|
431
|
+
"RUN_ID",
|
|
432
|
+
"BOARDWALK_CONTROL_PLANE_URL",
|
|
433
|
+
"BOARDWALK_RUN_TOKEN",
|
|
434
|
+
"BOARDWALK_API_KEY",
|
|
435
|
+
"BOARDWALK_TASK_CPU_UNITS",
|
|
436
|
+
];
|
|
437
|
+
/** The public-API origin a run uses for raw API / MCP / CLI calls. The broker (Runner Control API)
|
|
438
|
+
* is hosted on the api-server, so the public API shares its origin; the program appends `/v1` or
|
|
439
|
+
* `/mcp/v1`. Falls back to the broker URL unchanged if it can't be parsed. */
|
|
440
|
+
export function publicApiOrigin(controlPlaneBaseUrl) {
|
|
441
|
+
try {
|
|
442
|
+
return new URL(controlPlaneBaseUrl).origin;
|
|
443
|
+
}
|
|
444
|
+
catch {
|
|
445
|
+
return controlPlaneBaseUrl;
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
/**
|
|
449
|
+
* Read the dispatcher-injected platform context from `env`, then DELETE every {@link PLATFORM_ENV_KEYS}
|
|
450
|
+
* key from it so the user program, the agent leaf, and any subprocess inherit none of them. Mutates
|
|
451
|
+
* `env` (called once on `process.env` at the very top of `main`, before any run code can read it).
|
|
452
|
+
*/
|
|
453
|
+
export function capturePlatformContext(env) {
|
|
454
|
+
const read = (name) => {
|
|
455
|
+
const value = env[name];
|
|
456
|
+
if (value === undefined || value.trim().length === 0) {
|
|
457
|
+
throw new Error(`Worker missing required env var ${name}`);
|
|
458
|
+
}
|
|
459
|
+
return value.trim();
|
|
460
|
+
};
|
|
461
|
+
// The dispatcher always injects the control-plane handle (docs/RUNNER_BROKER.md). The runner is
|
|
462
|
+
// brokered-only — there is no DB/Redis/Stripe fallback — so a missing handle is a hard config error.
|
|
463
|
+
const runId = read("RUN_ID");
|
|
464
|
+
const apiToken = env.BOARDWALK_API_KEY?.trim();
|
|
465
|
+
const controlPlane = {
|
|
466
|
+
baseUrl: read("BOARDWALK_CONTROL_PLANE_URL"),
|
|
467
|
+
runToken: read("BOARDWALK_RUN_TOKEN"),
|
|
468
|
+
...(apiToken !== undefined && apiToken.length > 0 ? { apiToken } : {}),
|
|
469
|
+
};
|
|
470
|
+
// The dispatcher injects the resolved Fargate size as BOARDWALK_TASK_CPU_UNITS (1024 = 1 vCPU). Fall
|
|
471
|
+
// back to 1 vCPU when absent (legacy task / self-hosted) so runtime bills at least wall-clock.
|
|
472
|
+
const cpuUnits = Number(env.BOARDWALK_TASK_CPU_UNITS);
|
|
473
|
+
const vcpus = Number.isFinite(cpuUnits) && cpuUnits > 0 ? cpuUnits / 1024 : 1;
|
|
474
|
+
// Scrub LAST — after every read — so the credentials live only in the returned (private) object.
|
|
475
|
+
// (`delete` via Reflect, not `env[key] = undefined`: assigning undefined to process.env coerces to
|
|
476
|
+
// the string "undefined", leaving the key readable; deletion actually removes it.)
|
|
477
|
+
for (const key of PLATFORM_ENV_KEYS)
|
|
478
|
+
Reflect.deleteProperty(env, key);
|
|
479
|
+
return { runId, controlPlane, vcpus };
|
|
480
|
+
}
|
|
481
|
+
export async function main() {
|
|
482
|
+
// Capture the platform context into private state and remove it from process.env BEFORE anything
|
|
483
|
+
// else — so no user program / agent tool / subprocess we later spawn can read the run token or
|
|
484
|
+
// API token (docs/RUN_ENV_AND_CREDS.md). WORKER_ID / WORKSPACE_ROOT are non-secret infra knobs.
|
|
485
|
+
const platform = capturePlatformContext(process.env);
|
|
486
|
+
const runId = platform.runId;
|
|
487
|
+
const byoProviders = parseByoProviders(process.env.BOARDWALK_BYO_PROVIDERS);
|
|
488
|
+
Reflect.deleteProperty(process.env, "BOARDWALK_BYO_PROVIDERS");
|
|
489
|
+
const deps = assembleWorkerDeps({
|
|
490
|
+
workerId: process.env.WORKER_ID ?? `worker-${runId}`,
|
|
491
|
+
workspaceRoot: process.env.WORKSPACE_ROOT ?? "/workspace",
|
|
492
|
+
runId,
|
|
493
|
+
controlPlane: platform.controlPlane,
|
|
494
|
+
vcpus: platform.vcpus,
|
|
495
|
+
...(byoProviders.length > 0 ? { byoProviders } : {}),
|
|
496
|
+
});
|
|
497
|
+
// The only thing to drain is the batched telemetry buffer — the runner opens no DB/Redis/SQS.
|
|
498
|
+
const cleanup = async () => {
|
|
499
|
+
await deps.flushTelemetry?.().catch(() => undefined);
|
|
500
|
+
};
|
|
501
|
+
// SIGTERM: ECS is stopping the task. Hold-and-pay has no mid-run checkpoint; we exit and let the
|
|
502
|
+
// lease expire → the scheduler-sweep reclaims it and a fresh worker RESTARTS the run from the
|
|
503
|
+
// top (Lambda/GHA semantics; durable children re-attach via idempotency). Crash-safe by design.
|
|
504
|
+
let shuttingDown = false;
|
|
505
|
+
for (const signal of ["SIGTERM", "SIGINT"]) {
|
|
506
|
+
process.on(signal, () => {
|
|
507
|
+
if (shuttingDown)
|
|
508
|
+
return;
|
|
509
|
+
shuttingDown = true;
|
|
510
|
+
log.warn("worker_signal_exit", { runId, signal });
|
|
511
|
+
void cleanup().finally(() => process.exit(0));
|
|
512
|
+
});
|
|
513
|
+
}
|
|
514
|
+
try {
|
|
515
|
+
const outcome = await runProgramWorker(runId, deps);
|
|
516
|
+
log.info("worker_finished", { runId, outcome: outcome.kind });
|
|
517
|
+
}
|
|
518
|
+
catch (err) {
|
|
519
|
+
log.error("worker_crashed", { runId, error: err instanceof Error ? err.message : String(err) });
|
|
520
|
+
await cleanup();
|
|
521
|
+
process.exit(1);
|
|
522
|
+
}
|
|
523
|
+
await cleanup();
|
|
524
|
+
process.exit(0);
|
|
525
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { InferenceFrame, InferenceProxyRequest } from "./wire/inference_proxy.js";
|
|
2
|
+
/** Streams one model turn through the broker as `InferenceFrame`s (RunnerControlClient satisfies it). */
|
|
3
|
+
export interface InferenceProxyTransport {
|
|
4
|
+
streamInference(req: InferenceProxyRequest): AsyncIterable<InferenceFrame>;
|
|
5
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// The broker inference transport the engine-backed leaf streams a model turn through
|
|
2
|
+
// (docs/RUNNER_BROKER.md §4 — "Inference (class 2)").
|
|
3
|
+
//
|
|
4
|
+
// Under the Runner Credential Broker the runner invokes NO model directly: it holds neither the
|
|
5
|
+
// managed-inference key nor any BYO provider key. So the `agent()` leaf's `LeafIo.streamModel`
|
|
6
|
+
// forwards the neutral conversation here, and this transport POSTs it to the broker's `/inference`
|
|
7
|
+
// endpoint; the broker resolves the real model server-side and relays its stream back as the wire's
|
|
8
|
+
// `InferenceFrame`s (delta / result / error). `RunnerControlClient.streamInference` satisfies it.
|
|
9
|
+
export {};
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { type LeafResume, type ToolHost, type LspService, type McpTokenResult } from "@boardwalk-labs/engine/core";
|
|
2
|
+
import type { AgentOptions } from "@boardwalk-labs/workflow/runtime";
|
|
3
|
+
import type { BudgetMeter } from "./agent/budget.js";
|
|
4
|
+
import type { SecretRedactor } from "./agent/secret_redactor.js";
|
|
5
|
+
import type { AgentIdentity, TurnEventSink } from "./agent/events.js";
|
|
6
|
+
import type { InferenceProxyTransport } from "./inference_transport.js";
|
|
7
|
+
import { type DirectInferenceDeps } from "./direct_inference.js";
|
|
8
|
+
import type { LeafExecutor } from "./workflow_host.js";
|
|
9
|
+
/** Builds the per-leaf event sink (`TurnEventSink`) for the leaf's `leafIndex`-numbered turn. */
|
|
10
|
+
export type EventSinkFactory = (leafIndex: number, identity: AgentIdentity) => TurnEventSink;
|
|
11
|
+
/** Per-leaf, per-model token metering input (fire-and-forget → the broker). */
|
|
12
|
+
export interface MeterUsageInput {
|
|
13
|
+
model: string;
|
|
14
|
+
inputTokens: number;
|
|
15
|
+
outputTokens: number;
|
|
16
|
+
/** Cache-served input tokens for this leaf (display-only annotation; omitted when absent). */
|
|
17
|
+
cachedReadTokens?: number;
|
|
18
|
+
cachedWriteTokens?: number;
|
|
19
|
+
leafIndex: number;
|
|
20
|
+
}
|
|
21
|
+
export interface EngineLeafExecutorDeps {
|
|
22
|
+
/** Streams one model turn through the broker (RunnerControlClient satisfies it). */
|
|
23
|
+
inference: InferenceProxyTransport;
|
|
24
|
+
/** Runner-direct BYO inference (docs/SELF_HOSTED_RUNNERS.md D7): when a per-`agent()` provider
|
|
25
|
+
* matches this registry (key-based HTTP sources only), the turn goes STRAIGHT to the org's
|
|
26
|
+
* endpoint with the engine adapters — the managed lane and bedrock stay brokered. Omitted ⇒
|
|
27
|
+
* everything brokered (legacy dispatch without a registry). */
|
|
28
|
+
byo?: DirectInferenceDeps;
|
|
29
|
+
/** RUN-level meter, shared across every leaf so caps + token totals span the whole run. The engine
|
|
30
|
+
* reports usage after EVERY model call via `reportUsage`; we feed it here and throw on a cap
|
|
31
|
+
* breach so the loop terminates mid-flight (the budget authority). */
|
|
32
|
+
budget: BudgetMeter;
|
|
33
|
+
/** RUN-level redactor (shared with the secret resolver): every resolved secret value is recorded
|
|
34
|
+
* here. We seed a fresh engine `Redactor` from it per leaf so the loop scrubs known values out of
|
|
35
|
+
* the prompt, tool args/results, and transcript before they reach the model (MASTER_SPEC §12). */
|
|
36
|
+
redactor: SecretRedactor;
|
|
37
|
+
/** Builds this leaf's event sink; `leafIndex` (1-based) is only a metering identifier — the sink
|
|
38
|
+
* owns the run-global cursor. `identity` names the leaf on its turn frames. */
|
|
39
|
+
makeEventSink: EventSinkFactory;
|
|
40
|
+
/** The run's persistent `/workspace` root — memory dirs (`agent({ memory })`) are relative to it. */
|
|
41
|
+
workspaceRoot: string;
|
|
42
|
+
/** Resolves the directory holding this run's bundled files (the extracted program tree, where a
|
|
43
|
+
* skill lives at `skills/<name>.md`). Known only once the artifact is extracted (mid-run), so it's
|
|
44
|
+
* a thunk. Null / omitted ⇒ a leaf that names `skills` fails loud. */
|
|
45
|
+
skillsDir?: () => string | null;
|
|
46
|
+
/** Resolves the run's workflow PACKAGE root — the extracted program tree, whose ROOT holds the
|
|
47
|
+
* author's standing instructions (`<programDir>/AGENTS.md`) and `skills/` beside them. The engine
|
|
48
|
+
* reads `capabilities.programDir` for the BUNDLED `AGENTS.md` tier, read by every agent() before
|
|
49
|
+
* any AGENTS.md the run cloned into its workspace. A thunk for the same reason `skillsDir` is (the
|
|
50
|
+
* dir is known only once the artifact extracts); it is in fact the parent of `skillsDir`. Null /
|
|
51
|
+
* omitted ⇒ no bundled tier (only the workspace AGENTS.md applies). */
|
|
52
|
+
programDir?: () => string | null;
|
|
53
|
+
/** Per-leaf token metering seam (fire-and-forget). Reports THIS leaf's tokens + its model to the
|
|
54
|
+
* broker, which decides `billed_by_boardwalk` per model + meters to Stripe. Omitted in tests. */
|
|
55
|
+
meterUsage?: (input: MeterUsageInput) => void;
|
|
56
|
+
/** Backend for the engine's host-backed built-in tools (`webfetch` / `web_search` / `artifacts`):
|
|
57
|
+
* set as the leaf's `capabilities.host` so the engine registers them. Broker-backed on hosted runs
|
|
58
|
+
* (BrokerToolHost). Omitted ⇒ those three tools are simply absent (the engine never registers a
|
|
59
|
+
* host-backed tool whose hook the host doesn't provide). */
|
|
60
|
+
toolHost?: ToolHost;
|
|
61
|
+
/** Engine-native LSP for the `diagnostics` tool + diagnostics-after-edit. Set as the leaf's
|
|
62
|
+
* `capabilities.lspService`. Constructed ONCE per RUN (not per leaf) so the language server stays
|
|
63
|
+
* warm across the run's edits/leaves, and closed on the run's teardown. Omitted ⇒ the `diagnostics`
|
|
64
|
+
* tool and after-edit diagnostics are best-effort-skipped (the correct degradation). */
|
|
65
|
+
lspService?: LspService;
|
|
66
|
+
/** Brokers a short-lived OAuth bearer for a hosted MCP server. The engine calls this REACTIVELY —
|
|
67
|
+
* only after a server answers 401 to the static `headers` — so static-bearer servers never reach
|
|
68
|
+
* it. Routes to the Runner Control API's `mcp/token` vend endpoint (the OAuth token state lives in
|
|
69
|
+
* the control-plane vault, never on the worker). Omitted ⇒ no OAuth brokering: a server that needs
|
|
70
|
+
* a token gets `{ accessToken: null }` and the leaf fails loud with a clear hint (the correct
|
|
71
|
+
* degradation — static-bearer and no-auth servers still work). */
|
|
72
|
+
brokerMcpToken?: (serverUrl: string, invalidateToken?: string) => Promise<McpTokenResult>;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Per-run leaf executor. The worker constructs one bound to the run + run-level budget, and wires it
|
|
76
|
+
* as the `LeafExecutor` on the run's WorkflowHost, so every `agent()` the program calls runs here
|
|
77
|
+
* through the engine's loop.
|
|
78
|
+
*/
|
|
79
|
+
export declare class EngineLeafExecutor implements LeafExecutor {
|
|
80
|
+
private readonly deps;
|
|
81
|
+
private leafCount;
|
|
82
|
+
constructor(deps: EngineLeafExecutorDeps);
|
|
83
|
+
run(prompt: string, opts: AgentOptions | undefined, signal?: AbortSignal, resume?: LeafResume): Promise<unknown>;
|
|
84
|
+
/** Assemble the broker-backed `LeafIo` the engine loop drives for one leaf call. */
|
|
85
|
+
private buildLeafIo;
|
|
86
|
+
/** POST one model turn to the broker's `/inference` and adapt its NDJSON stream into the engine's
|
|
87
|
+
* ModelTurnResult: each `delta` frame drives providerIo.onDelta; the terminal `result` frame is
|
|
88
|
+
* the turn. An `error` frame throws (the broker already classified it). An abort mid-stream throws. */
|
|
89
|
+
private streamModel;
|
|
90
|
+
}
|
|
91
|
+
/** The MCP server refs an `agent({ mcp })` call may name, derived from the SDK's `AgentOptions`. */
|
|
92
|
+
type HostedMcpServerRef = NonNullable<AgentOptions["mcp"]>[number];
|
|
93
|
+
/**
|
|
94
|
+
* Gate the MCP servers a hosted leaf may use: only the `http` transport (no arbitrary `stdio`
|
|
95
|
+
* processes on the worker) and a parseable URL. Throws a clear VALIDATION_FAILED for the first
|
|
96
|
+
* offending ref so the leaf fails at its boundary, before the engine connects out.
|
|
97
|
+
*
|
|
98
|
+
* Reachability of the host is NOT gated here: a hosted run's egress is OPEN by default (the forward
|
|
99
|
+
* proxy allows all public destinations; a workflow restricts it via manifest.egress), and the proxy
|
|
100
|
+
* is the single enforcement point — a server blocked by a restrictive egress fails at the proxy when
|
|
101
|
+
* the engine connects, not via an MCP-specific allowlist that would be stricter than the platform.
|
|
102
|
+
*/
|
|
103
|
+
export declare function assertHostedMcpAllowed(refs: readonly HostedMcpServerRef[] | undefined): void;
|
|
104
|
+
export {};
|