@boardwalk-labs/runner 0.2.17 → 0.3.0-alpha.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/runtime/agent/budget.d.ts +43 -24
- package/dist/runtime/agent/budget.js +108 -57
- package/dist/runtime/agent/events.d.ts +1 -1
- package/dist/runtime/broker_child_dispatcher.d.ts +5 -2
- package/dist/runtime/broker_child_dispatcher.js +24 -6
- package/dist/runtime/budget_gate.d.ts +58 -21
- package/dist/runtime/budget_gate.js +227 -46
- package/dist/runtime/host_capabilities.d.ts +4 -0
- package/dist/runtime/host_capabilities.js +25 -0
- package/dist/runtime/host_server.d.ts +137 -0
- package/dist/runtime/host_server.js +562 -0
- package/dist/runtime/index.js +45 -12
- package/dist/runtime/leaf_executor.d.ts +7 -4
- package/dist/runtime/leaf_executor.js +11 -6
- package/dist/runtime/program_runner.d.ts +62 -42
- package/dist/runtime/program_runner.js +156 -101
- package/dist/runtime/program_worker.d.ts +22 -11
- package/dist/runtime/program_worker.js +26 -48
- package/dist/runtime/python_program.d.ts +68 -0
- package/dist/runtime/python_program.js +270 -0
- package/dist/runtime/run_context.d.ts +13 -0
- package/dist/runtime/run_context.js +77 -0
- package/dist/runtime/runner_control_client.d.ts +23 -0
- package/dist/runtime/runner_control_client.js +69 -147
- package/dist/runtime/shell_exec.d.ts +17 -0
- package/dist/runtime/shell_exec.js +144 -0
- package/dist/runtime/support/index.d.ts +6 -0
- package/dist/runtime/support/index.js +14 -0
- package/dist/runtime/wire/inference_proxy.js +1 -1
- package/dist/runtime/wire/run.d.ts +5 -2
- package/dist/runtime/workflow_host.d.ts +53 -7
- package/dist/runtime/workflow_host.js +82 -42
- package/package.json +4 -3
|
@@ -72,6 +72,43 @@ export class RunnerControlClient {
|
|
|
72
72
|
bulkFetch(url, init) {
|
|
73
73
|
return this.retryingFetch(url, init, this.bulkTimeoutMs);
|
|
74
74
|
}
|
|
75
|
+
// The three shapes every UNIFORM control endpoint reduces to. Endpoints where a specific
|
|
76
|
+
// status is a meaningful answer (claim/renew 409, version/child 404, mcp/token 403, the
|
|
77
|
+
// no-body hydrate-url POST, and the streaming inference call) keep their own fetch logic.
|
|
78
|
+
// `op` is the error/log label passed to {@link brokerError} (it sometimes differs from the path).
|
|
79
|
+
/** GET a run-scoped control path; any status but 200 throws. The `as T` is the client's one
|
|
80
|
+
* trust-boundary cast: the broker is the platform's own API and each caller names the
|
|
81
|
+
* handler's documented reply shape. */
|
|
82
|
+
async getJson(op, suffix) {
|
|
83
|
+
const res = await this.controlFetch(this.url(suffix), {
|
|
84
|
+
method: "GET",
|
|
85
|
+
headers: this.headers(false),
|
|
86
|
+
});
|
|
87
|
+
if (res.status !== 200)
|
|
88
|
+
throw await brokerError(res, op);
|
|
89
|
+
return (await res.json());
|
|
90
|
+
}
|
|
91
|
+
/** POST a JSON body to a run-scoped control path; any status but `expect` throws. */
|
|
92
|
+
async postJson(op, suffix, body, expect = 200) {
|
|
93
|
+
const res = await this.controlFetch(this.url(suffix), {
|
|
94
|
+
method: "POST",
|
|
95
|
+
headers: this.headers(true),
|
|
96
|
+
body: JSON.stringify(body),
|
|
97
|
+
});
|
|
98
|
+
if (res.status !== expect)
|
|
99
|
+
throw await brokerError(res, op);
|
|
100
|
+
return (await res.json());
|
|
101
|
+
}
|
|
102
|
+
/** POST a JSON body to a run-scoped control path expecting an empty 204 reply. */
|
|
103
|
+
async postVoid(op, suffix, body) {
|
|
104
|
+
const res = await this.controlFetch(this.url(suffix), {
|
|
105
|
+
method: "POST",
|
|
106
|
+
headers: this.headers(true),
|
|
107
|
+
body: JSON.stringify(body),
|
|
108
|
+
});
|
|
109
|
+
if (res.status !== 204)
|
|
110
|
+
throw await brokerError(res, op);
|
|
111
|
+
}
|
|
75
112
|
/**
|
|
76
113
|
* One attempt per entry in the backoff schedule (+1): retry thrown network failures (connection
|
|
77
114
|
* reset/refused mid-rollover, our own per-attempt timeout on a dead socket) and the
|
|
@@ -127,6 +164,8 @@ export class RunnerControlClient {
|
|
|
127
164
|
return {
|
|
128
165
|
run: body.run,
|
|
129
166
|
lastEventCursor: body.lastEventCursor ?? 0,
|
|
167
|
+
...(body.workflowVersion !== undefined ? { workflowVersion: body.workflowVersion } : {}),
|
|
168
|
+
...(body.environment !== undefined ? { environment: body.environment } : {}),
|
|
130
169
|
};
|
|
131
170
|
}
|
|
132
171
|
/** Heartbeat: extend our lease so a long run isn't reclaimed mid-flight. Returns the new
|
|
@@ -149,13 +188,7 @@ export class RunnerControlClient {
|
|
|
149
188
|
* whose lease expired and whose run was reclaimed + re-dispatched to a new owner), so a
|
|
150
189
|
* hung/partitioned worker that later recovers can't clobber the live run or revive a terminal one. */
|
|
151
190
|
async finalize(status, output, workerId) {
|
|
152
|
-
|
|
153
|
-
method: "POST",
|
|
154
|
-
headers: this.headers(true),
|
|
155
|
-
body: JSON.stringify({ status, output, workerId }),
|
|
156
|
-
});
|
|
157
|
-
if (res.status !== 204)
|
|
158
|
-
throw await brokerError(res, "finalize");
|
|
191
|
+
await this.postVoid("finalize", "finalize", { status, output, workerId });
|
|
159
192
|
}
|
|
160
193
|
/** Fetch the run's pinned manifest + program source, or null when the version is missing (404). */
|
|
161
194
|
async getVersion() {
|
|
@@ -172,71 +205,40 @@ export class RunnerControlClient {
|
|
|
172
205
|
/** Book a runtime-seconds DELTA (the worker's RuntimeFlusher → broker). `identifier` makes a
|
|
173
206
|
* retried/duplicate flush idempotent; distinct per-flush ids sum into the run's runtime total. */
|
|
174
207
|
async reportUsage(runtimeSeconds, identifier) {
|
|
175
|
-
|
|
176
|
-
method: "POST",
|
|
177
|
-
headers: this.headers(true),
|
|
178
|
-
body: JSON.stringify({ runtimeSeconds, identifier }),
|
|
179
|
-
});
|
|
180
|
-
if (res.status !== 204)
|
|
181
|
-
throw await brokerError(res, "usage");
|
|
208
|
+
await this.postVoid("usage", "usage", { runtimeSeconds, identifier });
|
|
182
209
|
}
|
|
183
210
|
/** Report a token-usage delta for incremental in-run metering (the usage flusher → broker). The
|
|
184
211
|
* broker gates on the run's per-connection `billed_by_boardwalk` server-side + meters usage to the
|
|
185
212
|
* platform; `identifier` makes a retried/duplicate flush idempotent. Satisfies {@link TokenUsageReporter}. */
|
|
186
213
|
async meterTokens(input) {
|
|
187
|
-
|
|
188
|
-
method: "POST",
|
|
189
|
-
headers: this.headers(true),
|
|
190
|
-
body: JSON.stringify(input),
|
|
191
|
-
});
|
|
192
|
-
if (res.status !== 204)
|
|
193
|
-
throw await brokerError(res, "usage/tokens");
|
|
214
|
+
await this.postVoid("usage/tokens", "usage/tokens", input);
|
|
194
215
|
}
|
|
195
216
|
/** Check whether the run's org is still funded (the CreditWatcher → broker). The broker reads the
|
|
196
217
|
* live billing balance server-side; `false` means out of credit (the watcher then aborts the run). */
|
|
197
218
|
async checkCredit() {
|
|
198
|
-
|
|
199
|
-
method: "GET",
|
|
200
|
-
headers: this.headers(false),
|
|
201
|
-
});
|
|
202
|
-
if (res.status !== 200)
|
|
203
|
-
throw await brokerError(res, "credit");
|
|
204
|
-
return (await res.json()).funded;
|
|
219
|
+
return (await this.getJson("credit", "credit")).funded;
|
|
205
220
|
}
|
|
206
221
|
/** Check whether the run has been asked to cancel (the CancelWatcher → broker). `true` once the user
|
|
207
222
|
* cancelled the run (the broker flipped it to `cancelling`/`cancelled`); the watcher then aborts the
|
|
208
223
|
* run. Brokered because the runner holds no DB/Redis — this replaces the unreachable Redis channel. */
|
|
209
224
|
async checkCancelled() {
|
|
210
|
-
|
|
211
|
-
method: "GET",
|
|
212
|
-
headers: this.headers(false),
|
|
213
|
-
});
|
|
214
|
-
if (res.status !== 200)
|
|
215
|
-
throw await brokerError(res, "cancel");
|
|
216
|
-
return (await res.json()).cancelRequested;
|
|
225
|
+
return (await this.getJson("cancel", "cancel")).cancelRequested;
|
|
217
226
|
}
|
|
218
227
|
/** Register-without-release: register a HELD HITL gate's request row
|
|
219
228
|
* so it is answerable while the run keeps running — no suspend. Idempotent. Returns whether a new
|
|
220
229
|
* gate was registered. */
|
|
221
230
|
async registerInput(seq, gate) {
|
|
222
|
-
const
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
body: JSON.stringify({ seq, humanInput: gate }),
|
|
231
|
+
const body = await this.postJson("register-input", "inputs", {
|
|
232
|
+
seq,
|
|
233
|
+
humanInput: gate,
|
|
226
234
|
});
|
|
227
|
-
|
|
228
|
-
throw await brokerError(res, "register-input");
|
|
229
|
-
return (await res.json()).registered;
|
|
235
|
+
return body.registered;
|
|
230
236
|
}
|
|
231
237
|
/** Poll the resolved answers for a held gate at `seq` (empty until a human responds). */
|
|
232
238
|
async pollInputAnswers(seq) {
|
|
233
|
-
const
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
});
|
|
237
|
-
if (res.status !== 200)
|
|
238
|
-
throw await brokerError(res, "poll-inputs");
|
|
239
|
-
return (await res.json()).answers;
|
|
239
|
+
const suffix = `inputs/${encodeURIComponent(String(seq))}`;
|
|
240
|
+
return (await this.getJson("poll-inputs", suffix))
|
|
241
|
+
.answers;
|
|
240
242
|
}
|
|
241
243
|
/** Mint a presigned GET URL to restore this workflow's last `/workspace` snapshot (workspace
|
|
242
244
|
* persistence). `null` when the run isn't eligible (not opted-in, or self-hosted). */
|
|
@@ -254,14 +256,7 @@ export class RunnerControlClient {
|
|
|
254
256
|
* (the worker tars BEFORE requesting the URL) — the broker records it for the org storage counter +
|
|
255
257
|
* daily meter; the snapshot overwrites one per-workflow key, so it's the workflow's full footprint. */
|
|
256
258
|
async workspacePersistUrl(sizeBytes) {
|
|
257
|
-
const
|
|
258
|
-
method: "POST",
|
|
259
|
-
headers: this.headers(true),
|
|
260
|
-
body: JSON.stringify({ sizeBytes }),
|
|
261
|
-
});
|
|
262
|
-
if (res.status !== 200)
|
|
263
|
-
throw await brokerError(res, "workspace/persist-url");
|
|
264
|
-
const body = (await res.json());
|
|
259
|
+
const body = await this.postJson("workspace/persist-url", "workspace/persist-url", { sizeBytes });
|
|
265
260
|
return body.url === null ? null : { url: body.url, contentType: body.contentType ?? "" };
|
|
266
261
|
}
|
|
267
262
|
/** Download bytes from a presigned S3 URL (workspace hydrate). `null` on 404 (no snapshot yet —
|
|
@@ -278,74 +273,37 @@ export class RunnerControlClient {
|
|
|
278
273
|
* third-party-verifiable token (gated server-side on `permissions.id_token: "write"`) — used to
|
|
279
274
|
* federate into the org's OWN cloud (AWS/GCP). DIFFERENT from this client's run token. */
|
|
280
275
|
async requestOidcToken(audience) {
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
headers: this.headers(true),
|
|
284
|
-
body: JSON.stringify({ audience }),
|
|
276
|
+
return this.postJson("oidc/token", "oidc/token", {
|
|
277
|
+
audience,
|
|
285
278
|
});
|
|
286
|
-
if (res.status !== 200)
|
|
287
|
-
throw await brokerError(res, "oidc/token");
|
|
288
|
-
return (await res.json());
|
|
289
279
|
}
|
|
290
280
|
/** Publish a batch of live agent-event frames (the SSE live-tail source) — the broker publishes
|
|
291
281
|
* them to the run's Redis channel server-side, so the runner holds no Redis credential. */
|
|
292
282
|
async publishTelemetry(frames) {
|
|
293
|
-
|
|
294
|
-
method: "POST",
|
|
295
|
-
headers: this.headers(true),
|
|
296
|
-
body: JSON.stringify({ frames }),
|
|
297
|
-
});
|
|
298
|
-
if (res.status !== 204)
|
|
299
|
-
throw await brokerError(res, "telemetry");
|
|
283
|
+
await this.postVoid("telemetry", "telemetry", { frames });
|
|
300
284
|
}
|
|
301
285
|
/** Push a batch of encoded desktop frames (base64 JPEG) for the live-view surface — the broker
|
|
302
286
|
* republishes them to the run's live-view channel server-side (never durably stored; the session
|
|
303
287
|
* recording is the durable copy). See docs/SCREEN_CAPTURE.md §5. */
|
|
304
288
|
async publishLiveView(frames) {
|
|
305
|
-
|
|
306
|
-
method: "POST",
|
|
307
|
-
headers: this.headers(true),
|
|
308
|
-
body: JSON.stringify({ frames }),
|
|
309
|
-
});
|
|
310
|
-
if (res.status !== 204)
|
|
311
|
-
throw await brokerError(res, "liveview");
|
|
289
|
+
await this.postVoid("liveview", "liveview", { frames });
|
|
312
290
|
}
|
|
313
291
|
/** Is a browser currently watching this run's live-view? The capture loop polls this so it only
|
|
314
292
|
* captures + pushes frames while someone is attached (capture costs guest CPU + metered egress). */
|
|
315
293
|
async liveViewWanted() {
|
|
316
|
-
const
|
|
317
|
-
method: "GET",
|
|
318
|
-
headers: this.headers(false),
|
|
319
|
-
});
|
|
320
|
-
if (res.status !== 200)
|
|
321
|
-
throw await brokerError(res, "liveview/wanted");
|
|
322
|
-
const body = (await res.json());
|
|
294
|
+
const body = await this.getJson("liveview/wanted", "liveview/wanted");
|
|
323
295
|
return body.wanted === true;
|
|
324
296
|
}
|
|
325
297
|
/** Store a run artifact through the broker (which holds the S3 credential + neutralizes the served
|
|
326
298
|
* content type server-side). Returns the catalog id + a signed download URL. */
|
|
327
299
|
async writeArtifact(input) {
|
|
328
|
-
|
|
329
|
-
method: "POST",
|
|
330
|
-
headers: this.headers(true),
|
|
331
|
-
body: JSON.stringify(input),
|
|
332
|
-
});
|
|
333
|
-
if (res.status !== 201)
|
|
334
|
-
throw await brokerError(res, "artifacts");
|
|
335
|
-
return (await res.json());
|
|
300
|
+
return this.postJson("artifacts", "artifacts", input, 201);
|
|
336
301
|
}
|
|
337
302
|
/** Phase 1 of the LARGE-artifact path (the Runner Credential Broker model): presign an S3 PUT. The
|
|
338
303
|
* broker derives the S3 key + neutralizes/pins the served content type; it returns the upload URL +
|
|
339
304
|
* required headers + the `s3Key` to echo back at commit. No catalog row exists yet. */
|
|
340
305
|
async presignArtifact(input) {
|
|
341
|
-
|
|
342
|
-
method: "POST",
|
|
343
|
-
headers: this.headers(true),
|
|
344
|
-
body: JSON.stringify(input),
|
|
345
|
-
});
|
|
346
|
-
if (res.status !== 201)
|
|
347
|
-
throw await brokerError(res, "artifacts/presign");
|
|
348
|
-
return (await res.json());
|
|
306
|
+
return this.postJson("artifacts/presign", "artifacts/presign", input, 201);
|
|
349
307
|
}
|
|
350
308
|
/** Upload bytes to a presigned S3 URL (the large-artifact path). The `headers` come from the
|
|
351
309
|
* presign response and MUST be sent verbatim — the content type is pinned into the signature, so
|
|
@@ -360,47 +318,24 @@ export class RunnerControlClient {
|
|
|
360
318
|
* broker re-validates the run prefix + re-neutralizes the content type, then returns the catalog id
|
|
361
319
|
* + a signed download URL. */
|
|
362
320
|
async commitArtifact(input) {
|
|
363
|
-
|
|
364
|
-
method: "POST",
|
|
365
|
-
headers: this.headers(true),
|
|
366
|
-
body: JSON.stringify(input),
|
|
367
|
-
});
|
|
368
|
-
if (res.status !== 201)
|
|
369
|
-
throw await brokerError(res, "artifacts/commit");
|
|
370
|
-
return (await res.json());
|
|
321
|
+
return this.postJson("artifacts/commit", "artifacts/commit", input, 201);
|
|
371
322
|
}
|
|
372
323
|
/** List the artifacts this run has produced. */
|
|
373
324
|
async listArtifacts() {
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
headers: this.headers(false),
|
|
377
|
-
});
|
|
378
|
-
if (res.status !== 200)
|
|
379
|
-
throw await brokerError(res, "artifacts-list");
|
|
380
|
-
return (await res.json()).artifacts;
|
|
325
|
+
return (await this.getJson("artifacts-list", "artifacts"))
|
|
326
|
+
.artifacts;
|
|
381
327
|
}
|
|
382
328
|
/** Mint a fresh signed download URL for one of this run's artifacts. */
|
|
383
329
|
async signArtifactUrl(artifactId, ttlSeconds) {
|
|
384
|
-
const
|
|
385
|
-
|
|
386
|
-
headers: this.headers(true),
|
|
387
|
-
body: JSON.stringify({ ttlSeconds }),
|
|
388
|
-
});
|
|
389
|
-
if (res.status !== 200)
|
|
390
|
-
throw await brokerError(res, "artifacts-sign");
|
|
391
|
-
return (await res.json());
|
|
330
|
+
const suffix = `artifacts/${encodeURIComponent(artifactId)}/signed-url`;
|
|
331
|
+
return this.postJson("artifacts-sign", suffix, { ttlSeconds });
|
|
392
332
|
}
|
|
393
333
|
/** Resolve an org secret the run's manifest allows (the program's `secrets.get`). The broker
|
|
394
334
|
* enforces the allowlist + returns the value; a forbidden/missing secret surfaces as a throw. */
|
|
395
335
|
async resolveSecret(name) {
|
|
396
|
-
const
|
|
397
|
-
|
|
398
|
-
headers: this.headers(true),
|
|
399
|
-
body: JSON.stringify({ name }),
|
|
336
|
+
const body = await this.postJson("secrets/resolve", "secrets/resolve", {
|
|
337
|
+
name,
|
|
400
338
|
});
|
|
401
|
-
if (res.status !== 200)
|
|
402
|
-
throw await brokerError(res, "secrets/resolve");
|
|
403
|
-
const body = (await res.json());
|
|
404
339
|
return body.value;
|
|
405
340
|
}
|
|
406
341
|
/** Broker a short-lived OAuth bearer for a hosted MCP server (the engine's `mcpToken` hook, called
|
|
@@ -427,14 +362,7 @@ export class RunnerControlClient {
|
|
|
427
362
|
/** Proxy a web_search through the broker (which holds the Tavily key) — the runner sends the
|
|
428
363
|
* query, the broker calls Tavily and returns the results. */
|
|
429
364
|
async webSearch(input) {
|
|
430
|
-
|
|
431
|
-
method: "POST",
|
|
432
|
-
headers: this.headers(true),
|
|
433
|
-
body: JSON.stringify(input),
|
|
434
|
-
});
|
|
435
|
-
if (res.status !== 200)
|
|
436
|
-
throw await brokerError(res, "tools/web_search");
|
|
437
|
-
return (await res.json());
|
|
365
|
+
return this.postJson("tools/web_search", "tools/web_search", input);
|
|
438
366
|
}
|
|
439
367
|
/** Create (or idempotently re-attach to) a child run for `workflows.call`. */
|
|
440
368
|
async startChild(slug, input) {
|
|
@@ -450,14 +378,8 @@ export class RunnerControlClient {
|
|
|
450
378
|
}
|
|
451
379
|
/** Provision a durable schedule for `workflows.schedule`; returns the new schedule's id. */
|
|
452
380
|
async scheduleWorkflow(slug, input, spec) {
|
|
453
|
-
const
|
|
454
|
-
|
|
455
|
-
headers: this.headers(true),
|
|
456
|
-
body: JSON.stringify({ slug, input, ...spec }),
|
|
457
|
-
});
|
|
458
|
-
if (res.status !== 201)
|
|
459
|
-
throw await brokerError(res, "schedules");
|
|
460
|
-
return (await res.json()).scheduleId;
|
|
381
|
+
const body = await this.postJson("schedules", "schedules", { slug, input, ...spec }, 201);
|
|
382
|
+
return body.scheduleId;
|
|
461
383
|
}
|
|
462
384
|
/** Poll a child run's status/output, or null when it isn't this run's child (404). */
|
|
463
385
|
async getChild(childRunId) {
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { ShellResult } from "@boardwalk-labs/workflow/runtime";
|
|
2
|
+
/** Default cap on captured stdout/stderr bytes (matches the SDK's documented 16 MiB). */
|
|
3
|
+
export declare const SHELL_DEFAULT_MAX_BUFFER: number;
|
|
4
|
+
export interface ShellExecOptions {
|
|
5
|
+
cwd?: string | undefined;
|
|
6
|
+
env?: Record<string, string> | undefined;
|
|
7
|
+
timeoutMs?: number | undefined;
|
|
8
|
+
maxBuffer?: number | undefined;
|
|
9
|
+
}
|
|
10
|
+
export interface ShellExecDeps {
|
|
11
|
+
/** The run's workspace root — the default cwd, and the base a relative `cwd` resolves against. */
|
|
12
|
+
workspaceRoot: string;
|
|
13
|
+
/** The run's cooperative-cancellation signal: an abort kills the command and REJECTS. */
|
|
14
|
+
signal?: AbortSignal | undefined;
|
|
15
|
+
}
|
|
16
|
+
/** Run one shell command to completion (see the module header for the resolve/reject contract). */
|
|
17
|
+
export declare function runShell(cmd: string, opts: ShellExecOptions | undefined, deps: ShellExecDeps): Promise<ShellResult>;
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// runShell — the host-side backing for the protocol's `shell` capability.
|
|
3
|
+
//
|
|
4
|
+
// The old SDK ran `execSync` inside the program process; under the host protocol the program is
|
|
5
|
+
// a protocol client, so the command runs HERE (same VM, same trust domain — the program layer is
|
|
6
|
+
// trusted) and the completed result crosses the wire. Contract (sdk shell.ts / protocol.ts):
|
|
7
|
+
// resolve `{exitCode, stdout, stderr}` — a non-zero exit RESOLVES (data to branch on, never
|
|
8
|
+
// thrown); only a command that could not run at all, an exceeded output buffer, or a cancelled
|
|
9
|
+
// run rejects. A command killed by the `timeoutMs` option resolves with the shell convention
|
|
10
|
+
// `128 + signal number` (SIGTERM ⇒ 143), the same exit code a shell would report.
|
|
11
|
+
//
|
|
12
|
+
// cwd defaults to the run's workspace root (the same dir `agent({ cwd })` resolves against);
|
|
13
|
+
// `env` merges OVER the process env — the author owns process.env outright, and platform
|
|
14
|
+
// credentials were scrubbed from it at bootstrap, so inheriting it leaks nothing.
|
|
15
|
+
import { spawn } from "node:child_process";
|
|
16
|
+
import { isAbsolute, join } from "node:path";
|
|
17
|
+
import { throwIfAborted } from "./run_abort.js";
|
|
18
|
+
/** Default cap on captured stdout/stderr bytes (matches the SDK's documented 16 MiB). */
|
|
19
|
+
export const SHELL_DEFAULT_MAX_BUFFER = 16 * 1024 * 1024;
|
|
20
|
+
/** SIGKILL follows this long after a kill's SIGTERM if the process ignores it. */
|
|
21
|
+
const SHELL_KILL_GRACE_MS = 5_000;
|
|
22
|
+
const SIGNAL_EXIT_CODES = {
|
|
23
|
+
SIGHUP: 129,
|
|
24
|
+
SIGINT: 130,
|
|
25
|
+
SIGQUIT: 131,
|
|
26
|
+
SIGKILL: 137,
|
|
27
|
+
SIGTERM: 143,
|
|
28
|
+
};
|
|
29
|
+
/** Run one shell command to completion (see the module header for the resolve/reject contract). */
|
|
30
|
+
export function runShell(cmd, opts, deps) {
|
|
31
|
+
throwIfAborted(deps.signal);
|
|
32
|
+
const cwd = opts?.cwd === undefined
|
|
33
|
+
? deps.workspaceRoot
|
|
34
|
+
: isAbsolute(opts.cwd)
|
|
35
|
+
? opts.cwd
|
|
36
|
+
: join(deps.workspaceRoot, opts.cwd);
|
|
37
|
+
return new Promise((resolve, reject) => {
|
|
38
|
+
const child = spawn(cmd, {
|
|
39
|
+
shell: true,
|
|
40
|
+
cwd,
|
|
41
|
+
env: opts?.env === undefined ? process.env : { ...process.env, ...opts.env },
|
|
42
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
43
|
+
// Own process GROUP: `sh -c` may not exec-optimize, leaving the real command a GRANDCHILD
|
|
44
|
+
// holding the stdio pipes — killing only the shell would leak it and `close` would not
|
|
45
|
+
// fire until the grandchild exits. Group-kill reaches the whole tree. (Lockstep with the
|
|
46
|
+
// engine's src/run/shell_exec.ts.)
|
|
47
|
+
detached: true,
|
|
48
|
+
});
|
|
49
|
+
const maxBuffer = opts?.maxBuffer ?? SHELL_DEFAULT_MAX_BUFFER;
|
|
50
|
+
let stdout = "";
|
|
51
|
+
let stderr = "";
|
|
52
|
+
let settledReason = null;
|
|
53
|
+
let killTimer = null;
|
|
54
|
+
let graceTimer = null;
|
|
55
|
+
const killTree = (signal) => {
|
|
56
|
+
// Negative pid = the process group (see `detached`); fall back to the direct child when
|
|
57
|
+
// the group is already gone.
|
|
58
|
+
try {
|
|
59
|
+
if (child.pid !== undefined)
|
|
60
|
+
process.kill(-child.pid, signal);
|
|
61
|
+
else
|
|
62
|
+
child.kill(signal);
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
child.kill(signal);
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
const kill = (reason) => {
|
|
69
|
+
if (settledReason === null)
|
|
70
|
+
settledReason = reason;
|
|
71
|
+
killTree("SIGTERM");
|
|
72
|
+
// A process ignoring SIGTERM still ends: SIGKILL after a short grace.
|
|
73
|
+
graceTimer ??= setTimeout(() => {
|
|
74
|
+
killTree("SIGKILL");
|
|
75
|
+
}, SHELL_KILL_GRACE_MS);
|
|
76
|
+
graceTimer.unref();
|
|
77
|
+
};
|
|
78
|
+
if (opts?.timeoutMs !== undefined) {
|
|
79
|
+
killTimer = setTimeout(() => {
|
|
80
|
+
kill("timeout");
|
|
81
|
+
}, opts.timeoutMs);
|
|
82
|
+
}
|
|
83
|
+
const capture = (current, chunk) => {
|
|
84
|
+
const next = current + chunk.toString("utf8");
|
|
85
|
+
if (next.length > maxBuffer)
|
|
86
|
+
kill("maxBuffer"); // cap exceeded — stop the command, then reject
|
|
87
|
+
return next.length > maxBuffer ? next.slice(0, maxBuffer) : next;
|
|
88
|
+
};
|
|
89
|
+
child.stdout?.on("data", (chunk) => {
|
|
90
|
+
stdout = capture(stdout, chunk);
|
|
91
|
+
});
|
|
92
|
+
child.stderr?.on("data", (chunk) => {
|
|
93
|
+
stderr = capture(stderr, chunk);
|
|
94
|
+
});
|
|
95
|
+
const onAbort = () => {
|
|
96
|
+
kill("abort");
|
|
97
|
+
};
|
|
98
|
+
if (deps.signal !== undefined)
|
|
99
|
+
deps.signal.addEventListener("abort", onAbort, { once: true });
|
|
100
|
+
const cleanup = () => {
|
|
101
|
+
if (killTimer !== null)
|
|
102
|
+
clearTimeout(killTimer);
|
|
103
|
+
if (graceTimer !== null)
|
|
104
|
+
clearTimeout(graceTimer);
|
|
105
|
+
if (deps.signal !== undefined)
|
|
106
|
+
deps.signal.removeEventListener("abort", onAbort);
|
|
107
|
+
};
|
|
108
|
+
child.on("error", (err) => {
|
|
109
|
+
// The command could not run at all (e.g. no shell) — a rejecting case.
|
|
110
|
+
cleanup();
|
|
111
|
+
reject(err);
|
|
112
|
+
});
|
|
113
|
+
child.on("close", (code, signal) => {
|
|
114
|
+
cleanup();
|
|
115
|
+
if (settledReason === "abort") {
|
|
116
|
+
try {
|
|
117
|
+
throwIfAborted(deps.signal);
|
|
118
|
+
reject(new Error("shell command aborted"));
|
|
119
|
+
}
|
|
120
|
+
catch (abortErr) {
|
|
121
|
+
reject(abortErr instanceof Error ? abortErr : new Error(String(abortErr)));
|
|
122
|
+
}
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
if (settledReason === "maxBuffer") {
|
|
126
|
+
// The ratified contract: an exceeded output cap REJECTS (never a silently truncated
|
|
127
|
+
// success). The message keeps node's conventional wording for greppability.
|
|
128
|
+
reject(new Error(`shell output exceeded maxBuffer (${String(maxBuffer)} bytes)`));
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
if (code !== null) {
|
|
132
|
+
resolve({ exitCode: code, stdout, stderr });
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
// Killed by the timeout (or an external signal): resolve with the shell's 128+signum
|
|
136
|
+
// convention so a timed-out command is still data to branch on.
|
|
137
|
+
if (signal !== null) {
|
|
138
|
+
resolve({ exitCode: SIGNAL_EXIT_CODES[signal] ?? 128, stdout, stderr });
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
resolve({ exitCode: -1, stdout, stderr }); // neither code nor signal — Node says can't happen
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
}
|
|
@@ -25,6 +25,12 @@ export declare class AppError extends Error {
|
|
|
25
25
|
constructor(code: ErrorCode, message: string, detail?: unknown);
|
|
26
26
|
}
|
|
27
27
|
export declare function isAppError(err: unknown): err is AppError;
|
|
28
|
+
/**
|
|
29
|
+
* The SEMANTIC code of a thrown value, or undefined when it carries none. Duck-typed rather than
|
|
30
|
+
* `instanceof` — errors cross package boundaries (SDK/engine dual copies), where class checks fail.
|
|
31
|
+
* The SCREAMING_SNAKE shape gate keeps prose out of a field consumers render as a code.
|
|
32
|
+
*/
|
|
33
|
+
export declare function errorCodeOf(err: unknown): string | undefined;
|
|
28
34
|
/** Run-lease heartbeat period (ported from the platform's checkpoint module — the broker's
|
|
29
35
|
* renew endpoint mirrors it). */
|
|
30
36
|
export declare const DEFAULT_LEASE_MS: number;
|
|
@@ -61,6 +61,20 @@ export class AppError extends Error {
|
|
|
61
61
|
export function isAppError(err) {
|
|
62
62
|
return err instanceof AppError;
|
|
63
63
|
}
|
|
64
|
+
/** A machine-readable error code is SCREAMING_SNAKE — an engine `EngineError.code` (`VALIDATION`,
|
|
65
|
+
* `PROVIDER_ERROR`, …), an {@link AppError} code, and a Node syscall code (`ENOENT`) all are. */
|
|
66
|
+
const ERROR_CODE_RE = /^[A-Z][A-Z0-9_]{0,63}$/;
|
|
67
|
+
/**
|
|
68
|
+
* The SEMANTIC code of a thrown value, or undefined when it carries none. Duck-typed rather than
|
|
69
|
+
* `instanceof` — errors cross package boundaries (SDK/engine dual copies), where class checks fail.
|
|
70
|
+
* The SCREAMING_SNAKE shape gate keeps prose out of a field consumers render as a code.
|
|
71
|
+
*/
|
|
72
|
+
export function errorCodeOf(err) {
|
|
73
|
+
if (typeof err !== "object" || err === null)
|
|
74
|
+
return undefined;
|
|
75
|
+
const code = err.code;
|
|
76
|
+
return typeof code === "string" && ERROR_CODE_RE.test(code) ? code : undefined;
|
|
77
|
+
}
|
|
64
78
|
// ---- leases ----
|
|
65
79
|
/** Run-lease heartbeat period (ported from the platform's checkpoint module — the broker's
|
|
66
80
|
* renew endpoint mirrors it). */
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
// A well-formed stream is zero-or-more `delta` frames followed by EXACTLY ONE `result` frame, OR a
|
|
23
23
|
// single terminal `error` frame. The neutral conversation shapes (ChatMessage / ChatTurn / ToolSpec)
|
|
24
24
|
// are the engine's `@boardwalk-labs/engine/core` types — so the worker's loop and the broker's
|
|
25
|
-
// adapters speak the same wire as
|
|
25
|
+
// adapters speak the same wire as the self-hosted server (one agent loop).
|
|
26
26
|
/** `POST /runner/v1/runs/{run_id}/inference` (run-token authed; the addressed run must match). */
|
|
27
27
|
export const RUNNER_INFERENCE_PATH_RE = /^\/runner\/v1\/runs\/([^/]+)\/inference$/;
|
|
28
28
|
/** Build the inference path for a run. */
|
|
@@ -16,9 +16,9 @@ export type RunActor = {
|
|
|
16
16
|
type: "event";
|
|
17
17
|
subscription_id: string;
|
|
18
18
|
source_run_id: string;
|
|
19
|
-
|
|
19
|
+
source_workflow_id: string;
|
|
20
|
+
event_type: string;
|
|
20
21
|
event_chain_depth: number;
|
|
21
|
-
user_id?: string;
|
|
22
22
|
};
|
|
23
23
|
export interface Run {
|
|
24
24
|
id: string;
|
|
@@ -26,6 +26,9 @@ export interface Run {
|
|
|
26
26
|
workflowId: string;
|
|
27
27
|
workflowVersionId: string;
|
|
28
28
|
environmentId: string | null;
|
|
29
|
+
/** 1-based crash-restart-from-top counter (context.attempt). Optional: an older backend's
|
|
30
|
+
* claim payload may predate the column; the honest fallback is 1. */
|
|
31
|
+
attempt?: number;
|
|
29
32
|
parentRunId: string | null;
|
|
30
33
|
actor: RunActor;
|
|
31
34
|
triggerKind: string;
|