@agent-compose/sdk 0.2.1 → 0.2.3

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/src/sandbox.ts ADDED
@@ -0,0 +1,458 @@
1
+ /**
2
+ * Sandbox provider registry — creates and manages isolated execution environments.
3
+ *
4
+ * Providers: "vercel" (Vercel Sandbox), "e2b" (E2B), "e2b-desktop" (E2B Desktop).
5
+ * Each provider validates its required env vars at creation time.
6
+ */
7
+
8
+ import { promises as fs } from "node:fs";
9
+ import { dirname } from "node:path";
10
+ import { spawn } from "node:child_process";
11
+ import { Sandbox } from "e2b";
12
+ import { Sandbox as Desktop } from "@e2b/desktop";
13
+ import pRetry from "p-retry";
14
+ import type { SandboxProvider, DesktopSandboxProvider } from "./types/sandbox.js";
15
+
16
+ export type { SandboxProvider, DesktopSandboxProvider } from "./types/sandbox.js";
17
+
18
+ // Fleet-wide tag, NOT machine-scoped. Previously we embedded FLY_MACHINE_ID
19
+ // so each replica scoped its own E2B sandboxes at boot, but that made
20
+ // cross-replica orphan reconciliation impossible: when a replica hard-died,
21
+ // its E2B sandboxes became invisible to every surviving replica and stayed
22
+ // running until E2B's own lifetime cap. With a fleet-wide tag the provider
23
+ // reconciler (`listOwned` + DB cross-ref) handles cleanup correctly across
24
+ // all replicas. Boot-time `killAllSandboxes` was deliberately removed when
25
+ // this changed — killing at boot with a shared tag would nuke live sandboxes
26
+ // owned by other replicas.
27
+ export const AGENT_COMPOSE_TAG = process.env.AGENT_COMPOSE_TAG ??
28
+ `agent-compose-${process.env.AGENT_COMPOSE_ENV ?? "dev"}`;
29
+
30
+ /** Upper bound for how old a live Vercel sandbox can be: Pro/Enterprise plan
31
+ * cap (5h) + 1h slack for clock skew and `extendTimeout()` calls. */
32
+ const VERCEL_VM_LIFETIME_WINDOW_MS = 6 * 60 * 60 * 1000;
33
+
34
+ /**
35
+ * Vercel-compatible network policy for outbound HTTPS requests.
36
+ * When a sandbox makes a request matching a domain in `allow`, the Vercel
37
+ * firewall injects the specified headers before forwarding — credentials never
38
+ * exist inside the VM. E2B ignores this field (future self-hosted mapping TBD).
39
+ */
40
+ export type SandboxNetworkPolicy =
41
+ | "allow-all"
42
+ | "deny-all"
43
+ | {
44
+ allow?: string[] | Record<string, Array<{ transform?: Array<{ headers?: Record<string, string> }> }>>;
45
+ subnets?: { allow?: string[]; deny?: string[] };
46
+ };
47
+
48
+ export interface SandboxCreateOpts {
49
+ envs: Record<string, string>;
50
+ metadata: Record<string, string>;
51
+ timeoutMs: number;
52
+ /** Provider-specific template/snapshot identifier. E2B: template ID; Vercel: snapshot ID. */
53
+ template?: string;
54
+ /** Outbound request policy. Vercel only — E2B silently ignores. */
55
+ networkPolicy?: SandboxNetworkPolicy;
56
+ }
57
+
58
+ /**
59
+ * What the provider reports as "currently alive" — the single input to orphan
60
+ * reconciliation. `metadata` is best-effort: E2B populates it from sandbox
61
+ * labels, Vercel leaves it empty (the API has no metadata field). Callers
62
+ * that need runId correlation cross-reference `sandboxId` against their own
63
+ * state (server: `workflow_runs.sandbox_id` + `run_agent_sandboxes.provider_sandbox_id`).
64
+ */
65
+ export interface OwnedSandbox {
66
+ sandboxId: string;
67
+ createdAt: Date;
68
+ metadata: Record<string, string>;
69
+ }
70
+
71
+ interface SandboxProviderDef {
72
+ requiredEnv: Record<string, string>;
73
+ create: (opts: SandboxCreateOpts, env: Record<string, string>) => Promise<SandboxProvider>;
74
+ reconnect?: (sandboxId: string) => Promise<SandboxProvider>;
75
+ killAll?: (env: Record<string, string>) => Promise<void>;
76
+ /**
77
+ * Returns the number of sandboxes currently running against this provider.
78
+ * Used for quota observability — account-wide, not per-instance.
79
+ *
80
+ * E2B scopes by our AGENT_COMPOSE_TAG metadata so each machine only reports
81
+ * its own sandboxes; DD aggregates across instances with `sum by {provider}`.
82
+ * Vercel has no user metadata support, so it counts every running sandbox in
83
+ * the configured project (assumed dedicated to agent-compose).
84
+ */
85
+ getActiveCount?: (env: Record<string, string>) => Promise<number>;
86
+ /**
87
+ * Provider's view of what's currently alive for our account/fleet.
88
+ * The reconciliation SoT — a sandbox missing from this list IS dead,
89
+ * regardless of what our DB says. Implemented by listing the provider's
90
+ * running sandboxes; E2B filters by metadata tag, Vercel lists the whole
91
+ * project (it has no metadata search). 200-row cap applies to Vercel.
92
+ */
93
+ listOwned?: (env: Record<string, string>) => Promise<OwnedSandbox[]>;
94
+ /**
95
+ * Delete a snapshot by id. Called from the customer-initiated
96
+ * `DELETE /workflows/:runId/snapshot` route. Best-effort — the DB is
97
+ * source of truth; an orphan on the provider side is benign and cleaned
98
+ * up eventually by the provider's own retention.
99
+ */
100
+ deleteSnapshot?: (snapshotId: string, env: Record<string, string>) => Promise<void>;
101
+ }
102
+
103
+ // ── E2B helpers ───────────────────────────────────────────────────────────────
104
+
105
+ export function makeSandboxProvider(sb: Sandbox | Desktop): SandboxProvider {
106
+ return { sandboxId: sb.sandboxId, commands: sb.commands, files: sb.files, kill: () => sb.kill() };
107
+ }
108
+
109
+ export function makeDesktopSandboxProvider(sb: Desktop): DesktopSandboxProvider {
110
+ return {
111
+ ...makeSandboxProvider(sb),
112
+ screenshot: () => sb.screenshot() as Promise<Buffer>,
113
+ leftClick: (x, y) => sb.leftClick(x, y),
114
+ doubleClick: (x, y) => sb.doubleClick(x, y),
115
+ rightClick: (x, y) => sb.rightClick(x, y),
116
+ middleClick: (x, y) => sb.middleClick(x, y),
117
+ moveMouse: (x, y) => sb.moveMouse(x, y),
118
+ write: (text) => sb.write(text),
119
+ press: (key) => sb.press(key),
120
+ scroll: (dir, ticks) => sb.scroll(dir, ticks),
121
+ drag: (from, to) => sb.drag(from, to),
122
+ };
123
+ }
124
+
125
+ // ── SSE helper (used by broker clients in runner.ts) ─────────────────────────
126
+
127
+ type SseEvent = { type: string; data?: string; exitCode?: number };
128
+
129
+ /**
130
+ * Parse an SSE exec stream from a ReadableStream.
131
+ * Used by the agent sandbox broker in runner.ts.
132
+ */
133
+ export async function parseSseExecStream(
134
+ body: ReadableStream<Uint8Array>,
135
+ opts?: { onStdout?: (d: string) => void; onStderr?: (d: string) => void },
136
+ ): Promise<{ exitCode: number; stdout: string }> {
137
+ let stdout = "", exitCode = 0, exited = false;
138
+ const reader = body.getReader(), decoder = new TextDecoder();
139
+ let buf = "";
140
+ while (true) {
141
+ const { done, value } = await reader.read();
142
+ if (done) break;
143
+ buf += decoder.decode(value, { stream: true });
144
+ const lines = buf.replace(/\r\n/g, "\n").split("\n");
145
+ buf = lines.pop() ?? "";
146
+ for (const line of lines) {
147
+ if (!line.startsWith("data: ")) continue;
148
+ let event: SseEvent;
149
+ try { event = JSON.parse(line.slice(6)) as SseEvent; } catch { continue; }
150
+ if (event.type === "stdout") { stdout += event.data ?? ""; opts?.onStdout?.(event.data ?? ""); }
151
+ else if (event.type === "stderr") { opts?.onStderr?.(event.data ?? ""); }
152
+ else if (event.type === "exit") { exitCode = event.exitCode ?? 0; exited = true; }
153
+ else if (event.type === "error") { throw new Error(`Sandbox exec error: ${event.data}`); }
154
+ }
155
+ // Stop reading as soon as the exit event is received — don't wait for stream close.
156
+ // QUIC tunnels can delay the stream-end signal even after all data has arrived.
157
+ if (exited) break;
158
+ }
159
+ return { exitCode, stdout };
160
+ }
161
+
162
+ // ── Vercel helpers ────────────────────────────────────────────────────────────
163
+
164
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
165
+ function makeVercelSandboxProvider(sb: any, globalEnvs?: Record<string, string>): SandboxProvider {
166
+ // Vercel's Sandbox.create({ env }) does NOT flow to runCommand subprocesses — they start fresh shells.
167
+ // Capture the sandbox-level envs and merge them into every runCommand call so that env vars like
168
+ // ANTHROPIC_API_KEY (placeholder for network policy injection) are actually visible to subprocesses.
169
+ const mergeEnvs = (cmdEnvs?: Record<string, string>) =>
170
+ globalEnvs ? { ...globalEnvs, ...cmdEnvs } : cmdEnvs;
171
+
172
+ return {
173
+ sandboxId: sb.sandboxId,
174
+ commands: {
175
+ async run(cmd, opts) {
176
+ if (opts?.background) {
177
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
178
+ void (sb.runCommand({ cmd: "sh", args: ["-c", cmd], cwd: opts.cwd, env: mergeEnvs(opts.envs), detached: true }) as Promise<any>).catch(() => {});
179
+ return { exitCode: 0, stdout: "" };
180
+ }
181
+ const signal = opts?.timeoutMs ? AbortSignal.timeout(opts.timeoutMs) : undefined;
182
+ let stdout = "";
183
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
184
+ const handle: any = await sb.runCommand({ cmd: "sh", args: ["-c", cmd], cwd: opts?.cwd, env: mergeEnvs(opts?.envs), detached: true, signal });
185
+ // Reconnect to the already-running command on transient stream failures (e.g. BrotliDecompressionError).
186
+ await pRetry(async (attempt) => {
187
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
188
+ const h: any = attempt === 1 ? handle : await sb.getCommand(handle.cmdId);
189
+ for await (const log of h.logs()) {
190
+ if (log.stream === "stdout") { stdout += log.data; opts?.onStdout?.(log.data); }
191
+ else { opts?.onStderr?.(log.data); }
192
+ }
193
+ }, { retries: 3, minTimeout: 1_000, factor: 2 });
194
+ const finished = await handle.wait();
195
+ return { exitCode: finished.exitCode, stdout };
196
+ },
197
+ },
198
+ files: {
199
+ async write(path: string, content: string) {
200
+ await sb.writeFiles([{ path, content }]);
201
+ },
202
+ },
203
+ // Propagate errors — `killAllRunSandboxes` relies on kill failures being
204
+ // observable so it can leave `sandbox_id` set for `findOrphanedSandboxes`
205
+ // to retry on next boot. Swallowing here makes the orphan retry loop blind.
206
+ async kill() { await sb.stop(); },
207
+ // Vercel native snapshot — used by sandbox-environments to capture the
208
+ // configured VM after the customer's `setup()` completes. `expiration: 0`
209
+ // is Vercel's "never expires" value; env snapshots are long-lived by
210
+ // design (they ARE the env) so we always pass it.
211
+ async snapshot() {
212
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
213
+ const res: any = await sb.snapshot({ expiration: 0 });
214
+ return { snapshotId: res.snapshotId as string };
215
+ },
216
+ };
217
+ }
218
+
219
+ // ── Local provider (runner's own VM) ──────────────────────────────────────────
220
+
221
+ /** Minimal SandboxProvider that targets the current process's host VM.
222
+ * `commands.run` → `child_process.spawn`; `files.write` → `fs.writeFile`;
223
+ * `kill` is a no-op because the caller IS the VM. Used by
224
+ * `defineSandboxEnvironment` so setup recipes read like imperative
225
+ * provisioning scripts. Uses `node:child_process` — works under both Node
226
+ * (the runner executes `node /tmp/runner.bundle.js`) and Bun. */
227
+ export function makeLocalSandboxProvider(): SandboxProvider {
228
+ return {
229
+ sandboxId: "local",
230
+ commands: {
231
+ run(cmd, opts) {
232
+ return new Promise((resolve, reject) => {
233
+ const proc = spawn("sh", ["-c", cmd], {
234
+ ...(opts?.cwd ? { cwd: opts.cwd } : {}),
235
+ env: { ...process.env, ...(opts?.envs ?? {}) },
236
+ stdio: ["ignore", "pipe", "pipe"],
237
+ });
238
+ let stdout = "";
239
+ proc.stdout?.setEncoding("utf8");
240
+ proc.stderr?.setEncoding("utf8");
241
+ proc.stdout?.on("data", (chunk: string) => { stdout += chunk; opts?.onStdout?.(chunk); });
242
+ proc.stderr?.on("data", (chunk: string) => { opts?.onStderr?.(chunk); });
243
+ proc.on("error", reject);
244
+ proc.on("close", (code) => resolve({ exitCode: code ?? 0, stdout }));
245
+ });
246
+ },
247
+ },
248
+ files: {
249
+ async write(path, content) {
250
+ await fs.mkdir(dirname(path), { recursive: true });
251
+ await fs.writeFile(path, content);
252
+ },
253
+ },
254
+ async kill() { /* caller IS the sandbox — killing it is the server's job */ },
255
+ };
256
+ }
257
+
258
+ // ── Provider registry ─────────────────────────────────────────────────────────
259
+
260
+ const SANDBOX_PROVIDERS: Record<string, SandboxProviderDef> = {
261
+ "vercel": {
262
+ requiredEnv: {
263
+ VERCEL_ACCESS_TOKEN: "Vercel access token — vercel.com/account/tokens",
264
+ VERCEL_TEAM_ID: "Vercel team ID — vercel.com/account/settings",
265
+ VERCEL_PROJECT_ID: "Vercel project ID — vercel.com/[team]/[project]/settings",
266
+ },
267
+ create: async (opts, env) => {
268
+ // Dynamic import keeps @vercel/sandbox out of runner.bundle.js (runner never calls this path)
269
+ const { Sandbox: VercelSandbox } = await import("@vercel/sandbox");
270
+ const creds = { token: env.VERCEL_ACCESS_TOKEN, teamId: env.VERCEL_TEAM_ID, projectId: env.VERCEL_PROJECT_ID };
271
+ // `template` is a snapshot id (runtime is baked in); absent → fresh node24.
272
+ const np = opts.networkPolicy;
273
+ const sb = await VercelSandbox.create(opts.template
274
+ ? { source: { type: "snapshot" as const, snapshotId: opts.template }, timeout: opts.timeoutMs, env: opts.envs, ...(np ? { networkPolicy: np } : {}), ...creds }
275
+ : { runtime: "node24" as const, timeout: opts.timeoutMs, env: opts.envs, ...(np ? { networkPolicy: np } : {}), ...creds },
276
+ );
277
+ // Pass envs as globalEnvs so they're injected into every runCommand subprocess.
278
+ // (Vercel's Sandbox.create env parameter does not flow to runCommand subprocesses.)
279
+ return makeVercelSandboxProvider(sb, opts.envs);
280
+ },
281
+ reconnect: async (sandboxId) => {
282
+ const { Sandbox: VercelSandbox } = await import("@vercel/sandbox");
283
+ const creds = {
284
+ token: process.env.VERCEL_ACCESS_TOKEN ?? "",
285
+ teamId: process.env.VERCEL_TEAM_ID ?? "",
286
+ projectId: process.env.VERCEL_PROJECT_ID ?? "",
287
+ };
288
+ return makeVercelSandboxProvider(await VercelSandbox.get({ sandboxId, ...creds }));
289
+ },
290
+ getActiveCount: async (env) => (await SANDBOX_PROVIDERS.vercel.listOwned!(env)).length,
291
+ listOwned: async (env) => {
292
+ const { Sandbox: VercelSandbox } = await import("@vercel/sandbox");
293
+ const creds = { token: env.VERCEL_ACCESS_TOKEN, teamId: env.VERCEL_TEAM_ID, projectId: env.VERCEL_PROJECT_ID };
294
+ // Bounds the scan to `VERCEL_VM_LIFETIME_WINDOW_MS` — Pro/Enterprise caps
295
+ // VM lifetime at 5h, +1h slack for clock skew and `extendTimeout()`.
296
+ // Without this, projects with thousands of historical terminal sandboxes
297
+ // page forever (10k+ in v0.6.28). Truncating silently would hide orphans,
298
+ // so we throw at the 50-page cap instead. Each page is wrapped in pRetry
299
+ // so a transient 5xx/429/network blip doesn't cost a reconcile tick.
300
+ const since = Date.now() - VERCEL_VM_LIFETIME_WINDOW_MS;
301
+ const out: OwnedSandbox[] = [];
302
+ let until: number | undefined;
303
+ for (let page = 0; page < 50; page++) {
304
+ const { json } = await pRetry(
305
+ () => VercelSandbox.list({ ...creds, limit: 200, since, ...(until !== undefined ? { until } : {}) }),
306
+ { retries: 3, minTimeout: 500, factor: 2 },
307
+ );
308
+ for (const sb of json.sandboxes) if (sb.status === "running") {
309
+ out.push({ sandboxId: sb.id, createdAt: new Date(sb.createdAt), metadata: {} });
310
+ }
311
+ if (json.pagination.next === null) return out;
312
+ until = json.pagination.next;
313
+ }
314
+ throw new Error("Vercel listSandboxes exceeded 50 pages within the lifetime window — refuse to silently truncate");
315
+ },
316
+ deleteSnapshot: async (snapshotId, env) => {
317
+ const { Snapshot } = await import("@vercel/sandbox");
318
+ const creds = { token: env.VERCEL_ACCESS_TOKEN, teamId: env.VERCEL_TEAM_ID, projectId: env.VERCEL_PROJECT_ID };
319
+ const snap = await Snapshot.get({ snapshotId, ...creds });
320
+ await snap.delete();
321
+ },
322
+ },
323
+ "e2b": {
324
+ requiredEnv: { E2B_API_KEY: "E2B API key — e2b.dev/dashboard" },
325
+ create: async ({ template, ...sandboxOpts }) => {
326
+ if (!template) throw new Error("E2B provider requires an explicit `template` (Dockerfile-based — no default base image)");
327
+ return makeSandboxProvider(await Sandbox.create(template, sandboxOpts));
328
+ },
329
+ reconnect: async (sandboxId) => makeSandboxProvider(
330
+ await Sandbox.connect(sandboxId, { apiKey: process.env.E2B_API_KEY ?? "", timeoutMs: 60 * 60 * 1000 }),
331
+ ),
332
+ killAll: async () => {
333
+ const paginator = Sandbox.list({ query: { metadata: { executor: AGENT_COMPOSE_TAG } } });
334
+ const sandboxes = [];
335
+ while (paginator.hasNext) sandboxes.push(...await paginator.nextItems());
336
+ if (sandboxes.length === 0) return;
337
+ await Promise.all(sandboxes.map(s => Sandbox.kill(s.sandboxId).catch(() => {})));
338
+ console.info(`[sandbox] killed ${sandboxes.length} stale E2B sandboxes (tag: ${AGENT_COMPOSE_TAG})`);
339
+ },
340
+ getActiveCount: async () => (await SANDBOX_PROVIDERS.e2b.listOwned!({})).length,
341
+ listOwned: async () => {
342
+ const paginator = Sandbox.list({ query: { metadata: { executor: AGENT_COMPOSE_TAG }, state: ["running"] } });
343
+ const out: OwnedSandbox[] = [];
344
+ while (paginator.hasNext) {
345
+ const items = await pRetry(() => paginator.nextItems(), { retries: 3, minTimeout: 500, factor: 2 });
346
+ for (const sb of items) {
347
+ out.push({
348
+ sandboxId: sb.sandboxId,
349
+ createdAt: new Date(sb.startedAt),
350
+ metadata: sb.metadata ?? {},
351
+ });
352
+ }
353
+ }
354
+ return out;
355
+ },
356
+ },
357
+ "e2b-desktop": {
358
+ requiredEnv: { E2B_API_KEY: "E2B API key — e2b.dev/dashboard" },
359
+ create: async ({ template, ...sandboxOpts }) => {
360
+ if (!template) throw new Error("E2B Desktop provider requires an explicit `template` (Dockerfile-based — no default base image)");
361
+ return makeDesktopSandboxProvider(await Desktop.create(template, sandboxOpts));
362
+ },
363
+ },
364
+ };
365
+
366
+ /** Provision a sandbox for the named provider. */
367
+ export async function createSandbox(provider: string, opts: SandboxCreateOpts): Promise<SandboxProvider> {
368
+ const def = SANDBOX_PROVIDERS[provider];
369
+ if (!def) throw new Error(`Unknown sandbox provider: "${provider}". Known: ${Object.keys(SANDBOX_PROVIDERS).join(", ")}`);
370
+ const missing = Object.entries(def.requiredEnv)
371
+ .filter(([key]) => !process.env[key])
372
+ .map(([key, desc]) => ` ${key} — ${desc}`);
373
+ if (missing.length > 0) throw new Error(`Sandbox provider "${provider}" requires env vars:\n${missing.join("\n")}`);
374
+ return def.create(opts, Object.fromEntries(Object.keys(def.requiredEnv).map(k => [k, process.env[k]!])));
375
+ }
376
+
377
+ /** Reconnect to an existing sandbox by provider + provider-native sandbox ID. */
378
+ export async function reconnectSandbox(provider: string, sandboxId: string): Promise<SandboxProvider> {
379
+ const def = SANDBOX_PROVIDERS[provider];
380
+ if (!def?.reconnect) throw new Error(`Provider "${provider}" does not support reconnect`);
381
+ return def.reconnect(sandboxId);
382
+ }
383
+
384
+ /** Delete a snapshot by id on the named provider. No live sandbox needed. */
385
+ export async function deleteSandboxSnapshot(provider: string, snapshotId: string): Promise<void> {
386
+ const def = SANDBOX_PROVIDERS[provider];
387
+ if (!def?.deleteSnapshot) throw new Error(`Provider "${provider}" does not support deleteSnapshot`);
388
+ const missing = Object.entries(def.requiredEnv)
389
+ .filter(([k]) => !process.env[k])
390
+ .map(([k, desc]) => ` ${k} — ${desc}`);
391
+ if (missing.length > 0) throw new Error(`Sandbox provider "${provider}" requires env vars:\n${missing.join("\n")}`);
392
+ return def.deleteSnapshot(snapshotId, Object.fromEntries(Object.keys(def.requiredEnv).map(k => [k, process.env[k]!])));
393
+ }
394
+
395
+ /**
396
+ * Current active-sandbox count per configured provider, for quota gauges.
397
+ * Skips providers whose required env isn't set or which don't implement
398
+ * `getActiveCount`. Errors are surfaced per-provider so one flaky provider
399
+ * doesn't silence the rest.
400
+ */
401
+ export async function getSandboxQuotas(): Promise<Record<string, number | Error>> {
402
+ const out: Record<string, number | Error> = {};
403
+ await Promise.all(
404
+ Object.entries(SANDBOX_PROVIDERS).map(async ([name, def]) => {
405
+ if (!def.getActiveCount) return;
406
+ if (Object.keys(def.requiredEnv).some(k => !process.env[k])) return;
407
+ const env = Object.fromEntries(Object.keys(def.requiredEnv).map(k => [k, process.env[k]!]));
408
+ try { out[name] = await def.getActiveCount(env); }
409
+ catch (err) { out[name] = err instanceof Error ? err : new Error(String(err)); }
410
+ }),
411
+ );
412
+ return out;
413
+ }
414
+
415
+ /**
416
+ * List every alive sandbox owned by this fleet, across all configured
417
+ * providers — the orphan-reconciler SoT. Providers without `listOwned`
418
+ * or missing env are skipped. Errors propagate per-provider so one flaky
419
+ * provider doesn't silence the rest.
420
+ */
421
+ export async function listOwnedSandboxes(): Promise<Record<string, OwnedSandbox[] | Error>> {
422
+ const out: Record<string, OwnedSandbox[] | Error> = {};
423
+ await Promise.all(
424
+ Object.entries(SANDBOX_PROVIDERS).map(async ([name, def]) => {
425
+ if (!def.listOwned) return;
426
+ if (Object.keys(def.requiredEnv).some(k => !process.env[k])) return;
427
+ const env = Object.fromEntries(Object.keys(def.requiredEnv).map(k => [k, process.env[k]!]));
428
+ try { out[name] = await def.listOwned(env); }
429
+ catch (err) { out[name] = err instanceof Error ? err : new Error(String(err)); }
430
+ }),
431
+ );
432
+ return out;
433
+ }
434
+
435
+ /** Kill a sandbox by provider + native ID. Used by the orphan reconciler. */
436
+ export async function killSandboxById(provider: string, sandboxId: string): Promise<void> {
437
+ const def = SANDBOX_PROVIDERS[provider];
438
+ if (!def?.reconnect) throw new Error(`Provider "${provider}" does not support reconnect (required for kill-by-id)`);
439
+ const sb = await def.reconnect(sandboxId);
440
+ await sb.kill();
441
+ }
442
+
443
+ /** Kill all sandboxes across all registered providers. Call at startup to clean up after crashes. */
444
+ export async function killAllSandboxes(
445
+ onError?: (provider: string, err: unknown) => void,
446
+ ): Promise<void> {
447
+ await Promise.allSettled(
448
+ Object.entries(SANDBOX_PROVIDERS)
449
+ .filter(([, def]) => def.killAll)
450
+ .map(([name, def]) => {
451
+ const env = Object.fromEntries(Object.keys(def.requiredEnv).map(k => [k, process.env[k] ?? ""]));
452
+ if (Object.keys(def.requiredEnv).some(k => !process.env[k])) return Promise.resolve();
453
+ return def.killAll!(env).catch(err => onError?.(name, err));
454
+ }),
455
+ );
456
+ }
457
+
458
+ export { SANDBOX_PROVIDERS };
package/src/sse.ts ADDED
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Generic Server-Sent Events (SSE) parser.
3
+ *
4
+ * Consumes a `ReadableStream<Uint8Array>` and yields parsed events one at a
5
+ * time. Each event carries:
6
+ * - `id`: numeric (from `id:` line; `0` if absent)
7
+ * - `event`: event name (from `event:` line; `""` if absent)
8
+ * - `data`: parsed JSON payload (the SDK's stream events are always JSON)
9
+ *
10
+ * Malformed payloads are silently skipped — same behaviour as the previous
11
+ * CLI-side parser. Caller drives the loop via `for await (...)` and is
12
+ * responsible for breaking on terminal events.
13
+ *
14
+ * Output type stays loose (`Record<string, unknown>`) on purpose: typed
15
+ * unions like `RunEvent` aren't structurally narrowable from
16
+ * `Record<string, unknown>`, so callers that want a discriminated union
17
+ * cast at the consumption site (see `streamRunLogs`). Keeping the parser
18
+ * payload-agnostic also means it's reusable for future SSE endpoints with
19
+ * different shapes.
20
+ */
21
+ export async function* parseSseStream(
22
+ body: ReadableStream<Uint8Array>,
23
+ ): AsyncGenerator<{ id: number; event: string; data: Record<string, unknown> }> {
24
+ const reader = body.getReader();
25
+ const decoder = new TextDecoder();
26
+ let buffer = "";
27
+ let event = "";
28
+ let seq = 0;
29
+ let dataLines: string[] = [];
30
+
31
+ try {
32
+ while (true) {
33
+ const { done, value } = await reader.read();
34
+ if (done) break;
35
+
36
+ buffer += decoder.decode(value, { stream: true });
37
+ const lines = buffer.split("\n");
38
+ buffer = lines.pop() ?? "";
39
+
40
+ for (const line of lines) {
41
+ if (line.startsWith("id:")) { seq = Number(line.slice(3).trim()); continue; }
42
+ if (line.startsWith("event:")) { event = line.slice(6).trim(); continue; }
43
+ if (line.startsWith("data:")) { dataLines.push(line.slice(5).trim()); continue; }
44
+ if (line === "" && dataLines.length > 0) {
45
+ try {
46
+ const data = JSON.parse(dataLines.join("\n")) as Record<string, unknown>;
47
+ yield { id: seq, event, data };
48
+ } catch { /* malformed — skip */ }
49
+ event = ""; seq = 0; dataLines = [];
50
+ }
51
+ }
52
+ }
53
+ } finally {
54
+ reader.releaseLock();
55
+ }
56
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * RunEvent — the canonical streaming contract for agent-compose SSE streams.
3
+ *
4
+ * Emitted by the server via pg_notify and forwarded to clients over
5
+ * GET /api/v1/workflows/:id/stream. Every event carries `runId`, `at` (unix ms),
6
+ * and `seq` (the DB row id for Last-Event-ID resumption).
7
+ *
8
+ * The `event` field doubles as the SSE `event:` name.
9
+ */
10
+ export type RunEvent =
11
+ | {
12
+ event: "agent_event";
13
+ runId: string;
14
+ at: number;
15
+ seq?: number;
16
+ msgType: string;
17
+ toolName?: string;
18
+ toolDetail?: string; // truncated key info: Bash command, file path, URL
19
+ isError?: boolean;
20
+ }
21
+ | {
22
+ event: "agent_spawned";
23
+ runId: string;
24
+ at: number;
25
+ seq?: number;
26
+ agentIndex: number;
27
+ agentId: string;
28
+ label: string;
29
+ sandboxId: string;
30
+ agentName: string;
31
+ parentAgentId: string | null;
32
+ }
33
+ | {
34
+ event: "agent_settled";
35
+ runId: string;
36
+ at: number;
37
+ seq?: number;
38
+ agentIndex: number;
39
+ agentId: string;
40
+ label: string;
41
+ outcome: "success" | "failed";
42
+ durationMs: number;
43
+ failureReason?: string;
44
+ }
45
+ | { event: "step_started"; runId: string; at: number; seq?: number; step: string }
46
+ | { event: "step_completed"; runId: string; at: number; seq?: number; step: string; durationMs: number }
47
+ | { event: "step_failed"; runId: string; at: number; seq?: number; step: string; durationMs: number; reason: string }
48
+ | { event: "run_complete"; runId: string; at: number; seq?: number }
49
+ | { event: "run_failed"; runId: string; at: number; seq?: number; reason: string }
50
+ | { event: "run_canceled"; runId: string; at: number; seq?: number }
51
+ | { event: "workflow_log"; runId: string; at: number; seq?: number; level: "info" | "warn" | "error"; msg: string };
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Agent message protocol — the stream of events emitted by a runtime's ModelExecutionContract.
3
+ */
4
+
5
+ interface AgentMessageBase {
6
+ timestamp: string;
7
+ }
8
+
9
+ export interface AgentMessageInit extends AgentMessageBase {
10
+ type: "init";
11
+ sessionId: string;
12
+ }
13
+
14
+ export interface AgentMessageText extends AgentMessageBase {
15
+ type: "text";
16
+ text: string;
17
+ }
18
+
19
+ export interface AgentMessageThinking extends AgentMessageBase {
20
+ type: "thinking";
21
+ text: string;
22
+ }
23
+
24
+ export interface AgentMessageToolUse extends AgentMessageBase {
25
+ type: "tool_use";
26
+ toolName: string;
27
+ toolInput: Record<string, unknown>;
28
+ toolUseId: string;
29
+ }
30
+
31
+ export interface AgentMessageToolResult extends AgentMessageBase {
32
+ type: "tool_result";
33
+ toolUseId: string;
34
+ output: string;
35
+ isError: boolean;
36
+ }
37
+
38
+ export interface AgentMessageDone extends AgentMessageBase {
39
+ type: "done";
40
+ sessionId: string;
41
+ }
42
+
43
+ export interface AgentMessageError extends AgentMessageBase {
44
+ type: "error";
45
+ text: string;
46
+ }
47
+
48
+ export interface AgentMessageUsage extends AgentMessageBase {
49
+ type: "usage";
50
+ inputTokens: number;
51
+ outputTokens: number;
52
+ cacheReadTokens: number;
53
+ cacheCreationTokens: number;
54
+ durationMs: number;
55
+ numTurns: number;
56
+ }
57
+
58
+ export type AgentMessage =
59
+ | AgentMessageInit
60
+ | AgentMessageText
61
+ | AgentMessageThinking
62
+ | AgentMessageToolUse
63
+ | AgentMessageToolResult
64
+ | AgentMessageDone
65
+ | AgentMessageError
66
+ | AgentMessageUsage;
67
+
68
+ /** Status block the agent emits to signal iteration completion or blockers. */
69
+ export interface AgentStatus {
70
+ summary: string;
71
+ completed: string[];
72
+ blockers: string[];
73
+ exit_signal: boolean;
74
+ }