@desplega.ai/agent-swarm 1.86.0 → 1.87.0
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/openapi.json +72 -1
- package/package.json +3 -1
- package/src/be/db-queries/tracker.ts +21 -0
- package/src/be/db.ts +235 -14
- package/src/be/migrations/079_task_followup_config.sql +1 -0
- package/src/be/modelsdev-cache.json +77663 -74073
- package/src/cli.tsx +26 -0
- package/src/commands/context-preamble.ts +272 -0
- package/src/commands/e2b.ts +728 -0
- package/src/commands/resume-session.ts +35 -78
- package/src/commands/runner.ts +125 -13
- package/src/e2b/dispatch.ts +429 -0
- package/src/e2b/env.ts +206 -0
- package/src/heartbeat/heartbeat.ts +145 -30
- package/src/heartbeat/templates.ts +11 -7
- package/src/http/session-data.ts +8 -1
- package/src/http/tasks.ts +152 -3
- package/src/jira/sync.ts +4 -4
- package/src/linear/sync.ts +6 -5
- package/src/providers/claude-adapter.ts +10 -76
- package/src/providers/claude-managed-adapter.ts +61 -75
- package/src/providers/codex-adapter.ts +15 -18
- package/src/providers/codex-oauth/auth-json.ts +18 -1
- package/src/providers/codex-oauth/flow.ts +24 -1
- package/src/providers/types.ts +6 -0
- package/src/tasks/worker-follow-up.ts +162 -2
- package/src/telemetry.ts +11 -1
- package/src/tests/claude-adapter.test.ts +5 -27
- package/src/tests/claude-managed-adapter.test.ts +38 -52
- package/src/tests/codex-adapter.test.ts +6 -31
- package/src/tests/codex-oauth.test.ts +149 -3
- package/src/tests/codex-pool.test.ts +14 -3
- package/src/tests/e2b-dispatch.test.ts +330 -0
- package/src/tests/heartbeat-supersede-resume.test.ts +285 -0
- package/src/tests/heartbeat.test.ts +26 -16
- package/src/tests/prompt-template-remaining.test.ts +4 -0
- package/src/tests/resume-session.test.ts +42 -50
- package/src/tests/structured-output.test.ts +69 -0
- package/src/tests/task-completion-idempotency.test.ts +185 -2
- package/src/tests/task-supersede-resume.test.ts +722 -0
- package/src/tests/telemetry-init.test.ts +69 -0
- package/src/tests/vcs-tracking.test.ts +39 -0
- package/src/tools/send-task.ts +12 -1
- package/src/tools/store-progress.ts +2 -2
- package/src/tools/templates.ts +14 -2
- package/src/types.ts +46 -1
- package/src/workflows/executors/agent-task.ts +3 -0
|
@@ -0,0 +1,728 @@
|
|
|
1
|
+
import { dirname, resolve } from "node:path";
|
|
2
|
+
import {
|
|
3
|
+
buildImageTemplate,
|
|
4
|
+
buildTemplate,
|
|
5
|
+
createSandbox,
|
|
6
|
+
deleteTemplate,
|
|
7
|
+
type E2BSandboxInfo,
|
|
8
|
+
killSandbox,
|
|
9
|
+
listSandboxes,
|
|
10
|
+
sandboxPortUrl,
|
|
11
|
+
setTemplateVisibility,
|
|
12
|
+
startDetachedProcess,
|
|
13
|
+
waitForAgentRegistration,
|
|
14
|
+
waitForHttpOk,
|
|
15
|
+
} from "../e2b/dispatch";
|
|
16
|
+
import {
|
|
17
|
+
absolutePath,
|
|
18
|
+
DEFAULT_E2B_API_BASE,
|
|
19
|
+
DEFAULT_E2B_FORWARD_KEYS,
|
|
20
|
+
DEFAULT_E2B_TEMPLATE_NAMES,
|
|
21
|
+
type EnvMap,
|
|
22
|
+
maybeReadDotenvFile,
|
|
23
|
+
parseKeyValue,
|
|
24
|
+
readDotenvFile,
|
|
25
|
+
redactObjectWithEnv,
|
|
26
|
+
redactWithEnv,
|
|
27
|
+
resolveSwarmApiKey,
|
|
28
|
+
type SwarmRole,
|
|
29
|
+
selectEnv,
|
|
30
|
+
splitKeys,
|
|
31
|
+
} from "../e2b/env";
|
|
32
|
+
|
|
33
|
+
type ParsedFlags = {
|
|
34
|
+
command?: string;
|
|
35
|
+
positionals: string[];
|
|
36
|
+
values: Map<string, string[]>;
|
|
37
|
+
booleans: Set<string>;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
type StartedRole = {
|
|
41
|
+
role: SwarmRole;
|
|
42
|
+
sandbox: E2BSandboxInfo;
|
|
43
|
+
url?: string;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const DEFAULT_API_PORT = 3013;
|
|
47
|
+
const BOOLEAN_FLAGS = new Set(["dry-run", "json", "no-cache", "no-wait"]);
|
|
48
|
+
|
|
49
|
+
function parseFlags(argv: string[]): ParsedFlags {
|
|
50
|
+
const [command, ...rest] = argv;
|
|
51
|
+
const positionals: string[] = [];
|
|
52
|
+
const values = new Map<string, string[]>();
|
|
53
|
+
const booleans = new Set<string>();
|
|
54
|
+
|
|
55
|
+
for (let i = 0; i < rest.length; i++) {
|
|
56
|
+
const arg = rest[i];
|
|
57
|
+
if (!arg) continue;
|
|
58
|
+
if (arg === "--") {
|
|
59
|
+
positionals.push(...rest.slice(i + 1));
|
|
60
|
+
break;
|
|
61
|
+
}
|
|
62
|
+
if (!arg.startsWith("--")) {
|
|
63
|
+
positionals.push(arg);
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const eq = arg.indexOf("=");
|
|
68
|
+
if (eq > 2) {
|
|
69
|
+
const key = arg.slice(2, eq);
|
|
70
|
+
const value = arg.slice(eq + 1);
|
|
71
|
+
values.set(key, [...(values.get(key) ?? []), value]);
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const key = arg.slice(2);
|
|
76
|
+
if (BOOLEAN_FLAGS.has(key)) {
|
|
77
|
+
booleans.add(key);
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const next = rest[i + 1];
|
|
82
|
+
if (next && !next.startsWith("--")) {
|
|
83
|
+
values.set(key, [...(values.get(key) ?? []), next]);
|
|
84
|
+
i++;
|
|
85
|
+
} else {
|
|
86
|
+
booleans.add(key);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return { command, positionals, values, booleans };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function value(flags: ParsedFlags, key: string, fallback = ""): string {
|
|
94
|
+
return flags.values.get(key)?.at(-1) ?? fallback;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function values(flags: ParsedFlags, key: string): string[] {
|
|
98
|
+
return flags.values.get(key) ?? [];
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function booleanFlag(flags: ParsedFlags, key: string): boolean {
|
|
102
|
+
return flags.booleans.has(key);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function integerFlag(flags: ParsedFlags, key: string, fallback: number): number {
|
|
106
|
+
const raw = value(flags, key);
|
|
107
|
+
if (!raw) return fallback;
|
|
108
|
+
const parsed = Number.parseInt(raw, 10);
|
|
109
|
+
if (!Number.isFinite(parsed) || parsed <= 0) {
|
|
110
|
+
throw new Error(`--${key} must be a positive integer`);
|
|
111
|
+
}
|
|
112
|
+
return parsed;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function commandOutput(args: string[], cwd: string): Promise<string | null> {
|
|
116
|
+
const child = Bun.spawn(args, { cwd, stdout: "pipe", stderr: "pipe" });
|
|
117
|
+
const [stdout, exitCode] = await Promise.all([new Response(child.stdout).text(), child.exited]);
|
|
118
|
+
if (exitCode !== 0) return null;
|
|
119
|
+
return stdout.trim();
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async function gitCommonRoot(cwd: string): Promise<string | null> {
|
|
123
|
+
const commonDir = await commandOutput(
|
|
124
|
+
["git", "rev-parse", "--path-format=absolute", "--git-common-dir"],
|
|
125
|
+
cwd,
|
|
126
|
+
);
|
|
127
|
+
if (!commonDir) return null;
|
|
128
|
+
return commonDir.endsWith("/.git") ? dirname(commonDir) : dirname(dirname(commonDir));
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async function loadE2BControllerEnv(
|
|
132
|
+
flags: ParsedFlags,
|
|
133
|
+
cwd: string,
|
|
134
|
+
opts: { requireApiKey?: boolean } = {},
|
|
135
|
+
): Promise<EnvMap> {
|
|
136
|
+
const requireApiKey = opts.requireApiKey ?? true;
|
|
137
|
+
if (booleanFlag(flags, "dry-run")) {
|
|
138
|
+
return { E2B_API_KEY: "dry-run" };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const explicit = value(flags, "e2b-api-key");
|
|
142
|
+
const fromFile = value(flags, "e2b-api-key-file");
|
|
143
|
+
const candidates: string[] = [];
|
|
144
|
+
const commonRoot = await gitCommonRoot(cwd);
|
|
145
|
+
|
|
146
|
+
if (commonRoot && commonRoot !== cwd) {
|
|
147
|
+
candidates.push(resolve(commonRoot, ".env"));
|
|
148
|
+
candidates.push(resolve(commonRoot, ".env.e2b"));
|
|
149
|
+
}
|
|
150
|
+
candidates.push(resolve(cwd, ".env"));
|
|
151
|
+
candidates.push(resolve(cwd, ".env.e2b"));
|
|
152
|
+
|
|
153
|
+
const loaded: EnvMap = {};
|
|
154
|
+
for (const env of await Promise.all(
|
|
155
|
+
candidates.map((candidate) => maybeReadDotenvFile(candidate)),
|
|
156
|
+
)) {
|
|
157
|
+
Object.assign(loaded, env);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
let apiKey = explicit || process.env.E2B_API_KEY || loaded.E2B_API_KEY || "";
|
|
161
|
+
if (fromFile) {
|
|
162
|
+
apiKey = (await Bun.file(absolutePath(fromFile, cwd)).text()).trim();
|
|
163
|
+
}
|
|
164
|
+
if (!apiKey && requireApiKey) {
|
|
165
|
+
throw new Error(
|
|
166
|
+
"Missing E2B_API_KEY. Set it in env, pass --e2b-api-key-file, or put it in .env.e2b/.env.",
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const env: EnvMap = {};
|
|
171
|
+
if (apiKey) env.E2B_API_KEY = apiKey;
|
|
172
|
+
const domain = process.env.E2B_DOMAIN || loaded.E2B_DOMAIN;
|
|
173
|
+
if (domain) env.E2B_DOMAIN = domain;
|
|
174
|
+
const apiUrl =
|
|
175
|
+
value(flags, "e2b-api-base") ||
|
|
176
|
+
process.env.E2B_API_URL ||
|
|
177
|
+
loaded.E2B_API_URL ||
|
|
178
|
+
(domain ? `https://api.${domain}` : "");
|
|
179
|
+
if (apiUrl) env.E2B_API_URL = apiUrl;
|
|
180
|
+
const sandboxUrl = process.env.E2B_SANDBOX_URL || loaded.E2B_SANDBOX_URL;
|
|
181
|
+
if (sandboxUrl) env.E2B_SANDBOX_URL = sandboxUrl;
|
|
182
|
+
return env;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function e2bControllerApiKey(env: EnvMap): string {
|
|
186
|
+
const apiKey = env.E2B_API_KEY;
|
|
187
|
+
if (!apiKey) {
|
|
188
|
+
throw new Error("Missing E2B_API_KEY");
|
|
189
|
+
}
|
|
190
|
+
return apiKey;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function e2bApiBase(flags: ParsedFlags, controllerEnv: EnvMap): string {
|
|
194
|
+
return value(flags, "e2b-api-base") || controllerEnv.E2B_API_URL || DEFAULT_E2B_API_BASE;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
async function loadRuntimeEnv(
|
|
198
|
+
flags: ParsedFlags,
|
|
199
|
+
role: SwarmRole,
|
|
200
|
+
apiUrl?: string,
|
|
201
|
+
): Promise<EnvMap> {
|
|
202
|
+
const envFiles = values(flags, "env-file").map((path) => absolutePath(path));
|
|
203
|
+
const fileEnv: EnvMap = {};
|
|
204
|
+
for (const env of await Promise.all(envFiles.map((path) => readDotenvFile(path)))) {
|
|
205
|
+
Object.assign(fileEnv, env);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const inheritKeys = [...DEFAULT_E2B_FORWARD_KEYS, ...splitKeys(values(flags, "inherit-env"))];
|
|
209
|
+
const inherited = selectEnv(process.env, inheritKeys);
|
|
210
|
+
const runtime: EnvMap = { ...inherited, ...fileEnv };
|
|
211
|
+
|
|
212
|
+
for (const raw of values(flags, "secret")) {
|
|
213
|
+
const [key, secretValue] = parseKeyValue(raw, "--secret");
|
|
214
|
+
runtime[key] = secretValue;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
let swarmApiKey: string;
|
|
218
|
+
try {
|
|
219
|
+
swarmApiKey = resolveSwarmApiKey(runtime, value(flags, "api-key"));
|
|
220
|
+
} catch (err) {
|
|
221
|
+
if (!booleanFlag(flags, "dry-run")) throw err;
|
|
222
|
+
swarmApiKey = "dry-run-api-key";
|
|
223
|
+
}
|
|
224
|
+
runtime.API_KEY = swarmApiKey;
|
|
225
|
+
runtime.AGENT_SWARM_API_KEY = swarmApiKey;
|
|
226
|
+
const startupScriptStrict = value(
|
|
227
|
+
flags,
|
|
228
|
+
"startup-script-strict",
|
|
229
|
+
runtime.STARTUP_SCRIPT_STRICT || "",
|
|
230
|
+
);
|
|
231
|
+
if (startupScriptStrict) runtime.STARTUP_SCRIPT_STRICT = startupScriptStrict;
|
|
232
|
+
|
|
233
|
+
if (role === "api") {
|
|
234
|
+
runtime.PORT = value(flags, "port", String(DEFAULT_API_PORT));
|
|
235
|
+
runtime.DATABASE_PATH = value(flags, "database-path", "/app/data/agent-swarm-db.sqlite");
|
|
236
|
+
runtime.MIGRATIONS_DIR = value(flags, "migrations-dir", "/app/migrations");
|
|
237
|
+
runtime.SQLITE_VEC_EXTENSION_PATH = value(
|
|
238
|
+
flags,
|
|
239
|
+
"sqlite-vec-extension-path",
|
|
240
|
+
"/app/extensions/vec0.so",
|
|
241
|
+
);
|
|
242
|
+
runtime.SCRIPT_RUNTIME_DIR = value(flags, "script-runtime-dir", "/app/scripts-runtime");
|
|
243
|
+
runtime.TS_LIB_DIR = value(flags, "ts-lib-dir", "/app/typescript-lib");
|
|
244
|
+
runtime.SCRIPT_TYPES_DIR = value(flags, "script-types-dir", "/app/script-types");
|
|
245
|
+
} else {
|
|
246
|
+
if (!apiUrl) {
|
|
247
|
+
throw new Error("Worker startup requires --api-url, or use start-stack to create API first.");
|
|
248
|
+
}
|
|
249
|
+
runtime.MCP_BASE_URL = apiUrl;
|
|
250
|
+
runtime.AGENT_ROLE = value(flags, "agent-role", "worker");
|
|
251
|
+
runtime.HARNESS_PROVIDER = value(flags, "provider", runtime.HARNESS_PROVIDER || "claude");
|
|
252
|
+
runtime.WORKER_YOLO = value(flags, "worker-yolo", "false");
|
|
253
|
+
runtime.WORKER_LOG_DIR = value(flags, "worker-log-dir", "/logs");
|
|
254
|
+
runtime.LEAD_LOG_DIR = value(flags, "lead-log-dir", "/logs");
|
|
255
|
+
runtime.HOME = value(flags, "home", runtime.HOME || "/home/worker");
|
|
256
|
+
runtime.BUN_INSTALL = value(flags, "bun-install", runtime.BUN_INSTALL || "/home/worker/.bun");
|
|
257
|
+
runtime.COREPACK_HOME = value(
|
|
258
|
+
flags,
|
|
259
|
+
"corepack-home",
|
|
260
|
+
runtime.COREPACK_HOME || "/home/worker/.corepack",
|
|
261
|
+
);
|
|
262
|
+
runtime.PLAYWRIGHT_BROWSERS_PATH = value(
|
|
263
|
+
flags,
|
|
264
|
+
"playwright-browsers-path",
|
|
265
|
+
runtime.PLAYWRIGHT_BROWSERS_PATH || "/opt/playwright",
|
|
266
|
+
);
|
|
267
|
+
runtime.PI_PACKAGE_DIR = value(
|
|
268
|
+
flags,
|
|
269
|
+
"pi-package-dir",
|
|
270
|
+
runtime.PI_PACKAGE_DIR || "/usr/lib/node_modules/@earendil-works/pi-coding-agent",
|
|
271
|
+
);
|
|
272
|
+
runtime.CODEX_PATH_OVERRIDE = value(
|
|
273
|
+
flags,
|
|
274
|
+
"codex-path-override",
|
|
275
|
+
runtime.CODEX_PATH_OVERRIDE || "/usr/bin/codex",
|
|
276
|
+
);
|
|
277
|
+
runtime.PATH = value(
|
|
278
|
+
flags,
|
|
279
|
+
"path",
|
|
280
|
+
runtime.PATH ||
|
|
281
|
+
[
|
|
282
|
+
"/home/worker/.local/bin",
|
|
283
|
+
"/home/worker/.opencode/bin",
|
|
284
|
+
"/home/worker/.bun/bin",
|
|
285
|
+
"/usr/local/sbin",
|
|
286
|
+
"/usr/local/bin",
|
|
287
|
+
"/usr/sbin",
|
|
288
|
+
"/usr/bin",
|
|
289
|
+
"/sbin",
|
|
290
|
+
"/bin",
|
|
291
|
+
].join(":"),
|
|
292
|
+
);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
delete runtime.E2B_API_KEY;
|
|
296
|
+
delete runtime.E2B_ACCESS_TOKEN;
|
|
297
|
+
return runtime;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function parseMetadata(flags: ParsedFlags, role: SwarmRole): Record<string, string> {
|
|
301
|
+
const metadata: Record<string, string> = {
|
|
302
|
+
app: "agent-swarm",
|
|
303
|
+
role,
|
|
304
|
+
launcher: "agent-swarm-e2b",
|
|
305
|
+
};
|
|
306
|
+
for (const raw of values(flags, "metadata")) {
|
|
307
|
+
const [key, metadataValue] = parseKeyValue(raw, "--metadata");
|
|
308
|
+
metadata[key] = metadataValue;
|
|
309
|
+
}
|
|
310
|
+
return metadata;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function roleTemplate(flags: ParsedFlags, role: SwarmRole): string {
|
|
314
|
+
return value(
|
|
315
|
+
flags,
|
|
316
|
+
`${role}-template`,
|
|
317
|
+
value(flags, "template", DEFAULT_E2B_TEMPLATE_NAMES[role]),
|
|
318
|
+
);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function localDockerfile(role: SwarmRole): string {
|
|
322
|
+
return role === "api" ? "Dockerfile" : "Dockerfile.worker";
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function printHumanStart(result: StartedRole, env: EnvMap): void {
|
|
326
|
+
console.log(`${result.role} sandbox: ${result.sandbox.sandboxID}`);
|
|
327
|
+
if (result.url) console.log(`${result.role} url: ${result.url}`);
|
|
328
|
+
console.log(
|
|
329
|
+
redactWithEnv(`inspect: e2b sandbox info ${result.sandbox.sandboxID} --format json`, env),
|
|
330
|
+
);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function publicStartedRole(result: StartedRole, env: EnvMap): StartedRole {
|
|
334
|
+
const { envdAccessToken, trafficAccessToken, ...sandbox } = result.sandbox;
|
|
335
|
+
void envdAccessToken;
|
|
336
|
+
void trafficAccessToken;
|
|
337
|
+
return redactObjectWithEnv({ ...result, sandbox }, env);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
async function startRole(
|
|
341
|
+
flags: ParsedFlags,
|
|
342
|
+
cwd: string,
|
|
343
|
+
role: SwarmRole,
|
|
344
|
+
apiUrl?: string,
|
|
345
|
+
): Promise<StartedRole> {
|
|
346
|
+
const controllerEnv = await loadE2BControllerEnv(flags, cwd);
|
|
347
|
+
const runtimeEnv = await loadRuntimeEnv(flags, role, apiUrl);
|
|
348
|
+
const controllerApiKey = e2bControllerApiKey(controllerEnv);
|
|
349
|
+
const template = roleTemplate(flags, role);
|
|
350
|
+
const timeoutSec = integerFlag(flags, "timeout-sec", 3600);
|
|
351
|
+
const apiBase = e2bApiBase(flags, controllerEnv);
|
|
352
|
+
const dryRun = booleanFlag(flags, "dry-run");
|
|
353
|
+
const port = Number.parseInt(runtimeEnv.PORT || String(DEFAULT_API_PORT), 10);
|
|
354
|
+
const metadata = parseMetadata(flags, role);
|
|
355
|
+
|
|
356
|
+
if (dryRun) {
|
|
357
|
+
const fakeSandbox = {
|
|
358
|
+
sandboxID: "dry-run",
|
|
359
|
+
templateID: template,
|
|
360
|
+
envdAccessToken: "dry-run",
|
|
361
|
+
domain: "e2b.app",
|
|
362
|
+
metadata,
|
|
363
|
+
};
|
|
364
|
+
return {
|
|
365
|
+
role,
|
|
366
|
+
sandbox: fakeSandbox,
|
|
367
|
+
url: role === "api" ? sandboxPortUrl(fakeSandbox, port, controllerEnv) : undefined,
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
const sandbox = await createSandbox({
|
|
372
|
+
apiKey: controllerApiKey,
|
|
373
|
+
apiBase,
|
|
374
|
+
template,
|
|
375
|
+
timeoutSec,
|
|
376
|
+
envVars: runtimeEnv,
|
|
377
|
+
metadata,
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
try {
|
|
381
|
+
if (role === "worker" && !runtimeEnv.AGENT_ID) {
|
|
382
|
+
runtimeEnv.AGENT_ID = value(flags, "agent-id", `e2b-${sandbox.sandboxID}`);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
const entrypoint = role === "api" ? "/api-entrypoint.sh" : "/docker-entrypoint.sh";
|
|
386
|
+
await startDetachedProcess({
|
|
387
|
+
sandbox,
|
|
388
|
+
apiKey: controllerApiKey,
|
|
389
|
+
apiBase,
|
|
390
|
+
e2bEnv: controllerEnv,
|
|
391
|
+
env: runtimeEnv,
|
|
392
|
+
command: entrypoint,
|
|
393
|
+
role,
|
|
394
|
+
cwd: role === "api" ? "/app" : "/workspace",
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
const url = role === "api" ? sandboxPortUrl(sandbox, port, controllerEnv) : undefined;
|
|
398
|
+
if (role === "api" && !booleanFlag(flags, "no-wait")) {
|
|
399
|
+
await waitForHttpOk(`${url}/health`, integerFlag(flags, "wait-ms", 90_000));
|
|
400
|
+
}
|
|
401
|
+
if (role === "worker" && !booleanFlag(flags, "no-wait")) {
|
|
402
|
+
const agentId = runtimeEnv.AGENT_ID;
|
|
403
|
+
const swarmApiKey = runtimeEnv.AGENT_SWARM_API_KEY;
|
|
404
|
+
if (!apiUrl || !agentId || !swarmApiKey) {
|
|
405
|
+
throw new Error("Worker startup did not resolve API URL, agent ID, or swarm API key");
|
|
406
|
+
}
|
|
407
|
+
await waitForAgentRegistration(
|
|
408
|
+
apiUrl,
|
|
409
|
+
agentId,
|
|
410
|
+
swarmApiKey,
|
|
411
|
+
integerFlag(flags, "wait-ms", 90_000),
|
|
412
|
+
);
|
|
413
|
+
}
|
|
414
|
+
return { role, sandbox, url };
|
|
415
|
+
} catch (err) {
|
|
416
|
+
try {
|
|
417
|
+
await killSandbox(sandbox.sandboxID, controllerApiKey, apiBase);
|
|
418
|
+
} catch (cleanupErr) {
|
|
419
|
+
const message = cleanupErr instanceof Error ? cleanupErr.message : String(cleanupErr);
|
|
420
|
+
console.warn(
|
|
421
|
+
redactWithEnv(
|
|
422
|
+
`e2b: failed to clean up sandbox ${sandbox.sandboxID} after startup failure: ${message}`,
|
|
423
|
+
controllerEnv,
|
|
424
|
+
),
|
|
425
|
+
);
|
|
426
|
+
}
|
|
427
|
+
throw err;
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
async function buildTemplateCommand(flags: ParsedFlags, cwd: string): Promise<void> {
|
|
432
|
+
const role = (value(flags, "role") || flags.positionals[0]) as SwarmRole;
|
|
433
|
+
if (role !== "api" && role !== "worker") {
|
|
434
|
+
throw new Error("build-template requires --role api|worker");
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
const source = value(flags, "source", "local");
|
|
438
|
+
const templateName = roleTemplate(flags, role);
|
|
439
|
+
const buildArgs: Record<string, string> = {};
|
|
440
|
+
let dockerfile = value(flags, "dockerfile");
|
|
441
|
+
if (!dockerfile) {
|
|
442
|
+
dockerfile = localDockerfile(role);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
if (source === "image") {
|
|
446
|
+
const image = value(flags, "image");
|
|
447
|
+
if (!image) throw new Error("Image-backed template builds require --image <image>");
|
|
448
|
+
const result = await buildImageTemplate({
|
|
449
|
+
role,
|
|
450
|
+
name: templateName,
|
|
451
|
+
image,
|
|
452
|
+
cpuCount: integerFlag(flags, "cpu-count", role === "worker" ? 4 : 2),
|
|
453
|
+
memoryMb: integerFlag(flags, "memory-mb", role === "worker" ? 8192 : 2048),
|
|
454
|
+
noCache: booleanFlag(flags, "no-cache"),
|
|
455
|
+
e2bEnv: await loadE2BControllerEnv(flags, cwd),
|
|
456
|
+
dryRun: booleanFlag(flags, "dry-run"),
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
if (result.stdout) process.stdout.write(result.stdout);
|
|
460
|
+
if (result.stderr) process.stderr.write(result.stderr);
|
|
461
|
+
if (result.exitCode !== 0) {
|
|
462
|
+
throw new Error(`e2b image template build failed with exit code ${result.exitCode}`);
|
|
463
|
+
}
|
|
464
|
+
return;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
if (source !== "local") {
|
|
468
|
+
throw new Error("--source must be local or image");
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
for (const raw of values(flags, "build-arg")) {
|
|
472
|
+
const [key, argValue] = parseKeyValue(raw, "--build-arg");
|
|
473
|
+
buildArgs[key] = argValue;
|
|
474
|
+
}
|
|
475
|
+
if (Object.keys(buildArgs).length > 0) {
|
|
476
|
+
throw new Error("E2B template create does not support --build-arg; use --source image instead");
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
const controllerEnv = await loadE2BControllerEnv(flags, cwd, { requireApiKey: false });
|
|
480
|
+
const result = await buildTemplate({
|
|
481
|
+
role,
|
|
482
|
+
name: templateName,
|
|
483
|
+
dockerfile,
|
|
484
|
+
cwd,
|
|
485
|
+
cpuCount: integerFlag(flags, "cpu-count", role === "worker" ? 4 : 2),
|
|
486
|
+
memoryMb: integerFlag(flags, "memory-mb", role === "worker" ? 8192 : 2048),
|
|
487
|
+
noCache: booleanFlag(flags, "no-cache"),
|
|
488
|
+
e2bEnv: controllerEnv,
|
|
489
|
+
dryRun: booleanFlag(flags, "dry-run"),
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
if (result.stdout) process.stdout.write(result.stdout);
|
|
493
|
+
if (result.stderr) process.stderr.write(result.stderr);
|
|
494
|
+
if (result.exitCode !== 0) {
|
|
495
|
+
throw new Error(`e2b template build failed with exit code ${result.exitCode}`);
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
async function deleteTemplateCommand(flags: ParsedFlags, cwd: string): Promise<void> {
|
|
500
|
+
const names = flags.positionals;
|
|
501
|
+
if (names.length === 0) throw new Error("delete-template requires at least one template name");
|
|
502
|
+
const controllerEnv = await loadE2BControllerEnv(flags, cwd, { requireApiKey: false });
|
|
503
|
+
|
|
504
|
+
for (const name of names) {
|
|
505
|
+
const result = await deleteTemplate({
|
|
506
|
+
name,
|
|
507
|
+
e2bEnv: controllerEnv,
|
|
508
|
+
dryRun: booleanFlag(flags, "dry-run"),
|
|
509
|
+
});
|
|
510
|
+
if (result.stdout) process.stdout.write(result.stdout);
|
|
511
|
+
if (result.stderr) process.stderr.write(result.stderr);
|
|
512
|
+
if (result.exitCode !== 0) {
|
|
513
|
+
throw new Error(`e2b template delete failed for ${name} with exit code ${result.exitCode}`);
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
async function templateVisibilityCommand(
|
|
519
|
+
flags: ParsedFlags,
|
|
520
|
+
cwd: string,
|
|
521
|
+
isPublic: boolean,
|
|
522
|
+
): Promise<void> {
|
|
523
|
+
const names = flags.positionals;
|
|
524
|
+
const action = isPublic ? "publish-template" : "unpublish-template";
|
|
525
|
+
if (names.length === 0) throw new Error(`${action} requires at least one template name`);
|
|
526
|
+
const controllerEnv = await loadE2BControllerEnv(flags, cwd);
|
|
527
|
+
|
|
528
|
+
for (const name of names) {
|
|
529
|
+
const result = await setTemplateVisibility({
|
|
530
|
+
name,
|
|
531
|
+
public: isPublic,
|
|
532
|
+
e2bEnv: controllerEnv,
|
|
533
|
+
dryRun: booleanFlag(flags, "dry-run"),
|
|
534
|
+
});
|
|
535
|
+
if (result.stdout) process.stdout.write(result.stdout);
|
|
536
|
+
if (result.stderr) process.stderr.write(result.stderr);
|
|
537
|
+
if (result.exitCode !== 0) {
|
|
538
|
+
throw new Error(`e2b template visibility update failed for ${name}`);
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
async function startApiCommand(flags: ParsedFlags, cwd: string): Promise<void> {
|
|
544
|
+
const result = await startRole(flags, cwd, "api");
|
|
545
|
+
const runtimeEnv = await loadRuntimeEnv(flags, "api");
|
|
546
|
+
if (booleanFlag(flags, "json")) {
|
|
547
|
+
console.log(JSON.stringify(publicStartedRole(result, runtimeEnv), null, 2));
|
|
548
|
+
} else {
|
|
549
|
+
printHumanStart(result, runtimeEnv);
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
async function startWorkerCommand(flags: ParsedFlags, cwd: string): Promise<void> {
|
|
554
|
+
const apiUrl = value(flags, "api-url");
|
|
555
|
+
const result = await startRole(flags, cwd, "worker", apiUrl);
|
|
556
|
+
const runtimeEnv = await loadRuntimeEnv(flags, "worker", apiUrl);
|
|
557
|
+
if (booleanFlag(flags, "json")) {
|
|
558
|
+
console.log(JSON.stringify(publicStartedRole(result, runtimeEnv), null, 2));
|
|
559
|
+
} else {
|
|
560
|
+
printHumanStart(result, runtimeEnv);
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
async function cleanupStartedRoles(
|
|
565
|
+
flags: ParsedFlags,
|
|
566
|
+
cwd: string,
|
|
567
|
+
started: StartedRole[],
|
|
568
|
+
): Promise<void> {
|
|
569
|
+
if (booleanFlag(flags, "dry-run") || started.length === 0) return;
|
|
570
|
+
|
|
571
|
+
const controllerEnv = await loadE2BControllerEnv(flags, cwd);
|
|
572
|
+
const controllerApiKey = e2bControllerApiKey(controllerEnv);
|
|
573
|
+
const apiBase = e2bApiBase(flags, controllerEnv);
|
|
574
|
+
|
|
575
|
+
for (const role of [...started].reverse()) {
|
|
576
|
+
try {
|
|
577
|
+
await killSandbox(role.sandbox.sandboxID, controllerApiKey, apiBase);
|
|
578
|
+
} catch (cleanupErr) {
|
|
579
|
+
const message = cleanupErr instanceof Error ? cleanupErr.message : String(cleanupErr);
|
|
580
|
+
console.warn(
|
|
581
|
+
redactWithEnv(
|
|
582
|
+
`e2b: failed to clean up ${role.role} sandbox ${role.sandbox.sandboxID} after stack startup failure: ${message}`,
|
|
583
|
+
controllerEnv,
|
|
584
|
+
),
|
|
585
|
+
);
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
async function startStackCommand(flags: ParsedFlags, cwd: string): Promise<void> {
|
|
591
|
+
const started: StartedRole[] = [];
|
|
592
|
+
const workers: StartedRole[] = [];
|
|
593
|
+
|
|
594
|
+
try {
|
|
595
|
+
const api = await startRole(flags, cwd, "api");
|
|
596
|
+
started.push(api);
|
|
597
|
+
if (!api.url) throw new Error("API sandbox did not produce a public URL");
|
|
598
|
+
|
|
599
|
+
const workerCount = integerFlag(flags, "workers", 1);
|
|
600
|
+
for (let i = 0; i < workerCount; i++) {
|
|
601
|
+
const worker = await startRole(flags, cwd, "worker", api.url);
|
|
602
|
+
workers.push(worker);
|
|
603
|
+
started.push(worker);
|
|
604
|
+
}
|
|
605
|
+
const runtimeEnv = await loadRuntimeEnv(flags, "api");
|
|
606
|
+
|
|
607
|
+
if (booleanFlag(flags, "json")) {
|
|
608
|
+
console.log(
|
|
609
|
+
JSON.stringify(
|
|
610
|
+
{
|
|
611
|
+
api: publicStartedRole(api, runtimeEnv),
|
|
612
|
+
workers: workers.map((worker) => publicStartedRole(worker, runtimeEnv)),
|
|
613
|
+
},
|
|
614
|
+
null,
|
|
615
|
+
2,
|
|
616
|
+
),
|
|
617
|
+
);
|
|
618
|
+
} else {
|
|
619
|
+
printHumanStart(api, runtimeEnv);
|
|
620
|
+
for (const worker of workers) {
|
|
621
|
+
printHumanStart(worker, runtimeEnv);
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
} catch (err) {
|
|
625
|
+
await cleanupStartedRoles(flags, cwd, started);
|
|
626
|
+
throw err;
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
async function killCommand(flags: ParsedFlags, cwd: string): Promise<void> {
|
|
631
|
+
const ids = flags.positionals;
|
|
632
|
+
if (ids.length === 0) throw new Error("kill requires at least one sandbox ID");
|
|
633
|
+
const controllerEnv = await loadE2BControllerEnv(flags, cwd);
|
|
634
|
+
const apiBase = e2bApiBase(flags, controllerEnv);
|
|
635
|
+
for (const id of ids) {
|
|
636
|
+
await killSandbox(id, e2bControllerApiKey(controllerEnv), apiBase);
|
|
637
|
+
console.log(`killed ${id}`);
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
async function listCommand(flags: ParsedFlags, cwd: string): Promise<void> {
|
|
642
|
+
const controllerEnv = await loadE2BControllerEnv(flags, cwd);
|
|
643
|
+
const apiBase = e2bApiBase(flags, controllerEnv);
|
|
644
|
+
const sandboxes = await listSandboxes(e2bControllerApiKey(controllerEnv), apiBase);
|
|
645
|
+
if (booleanFlag(flags, "json")) {
|
|
646
|
+
console.log(JSON.stringify(redactObjectWithEnv(sandboxes, controllerEnv), null, 2));
|
|
647
|
+
return;
|
|
648
|
+
}
|
|
649
|
+
for (const sandbox of sandboxes) {
|
|
650
|
+
console.log(
|
|
651
|
+
`${sandbox.sandboxID}\t${sandbox.alias ?? sandbox.templateID}\t${sandbox.metadata?.role ?? ""}`,
|
|
652
|
+
);
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
function printE2BHelp(): void {
|
|
657
|
+
console.log(`
|
|
658
|
+
agent-swarm e2b
|
|
659
|
+
|
|
660
|
+
Usage:
|
|
661
|
+
agent-swarm e2b build-template --role api|worker [--source local|image]
|
|
662
|
+
agent-swarm e2b delete-template <template-name...>
|
|
663
|
+
agent-swarm e2b publish-template <template-name...>
|
|
664
|
+
agent-swarm e2b unpublish-template <template-name...>
|
|
665
|
+
agent-swarm e2b start-api --template <template> [--env-file .env]
|
|
666
|
+
agent-swarm e2b start-worker --template <template> --api-url <https-url> [--env-file .env]
|
|
667
|
+
agent-swarm e2b start-stack --api-template <template> --worker-template <template> [--workers 1]
|
|
668
|
+
agent-swarm e2b list [--json]
|
|
669
|
+
agent-swarm e2b kill <sandbox-id...>
|
|
670
|
+
|
|
671
|
+
Common options:
|
|
672
|
+
--env-file <path> Load runtime env/secrets for API or worker (repeatable)
|
|
673
|
+
--secret KEY=VALUE Add/override one runtime secret (repeatable)
|
|
674
|
+
--inherit-env KEY[,KEY] Forward extra local env vars into the sandbox
|
|
675
|
+
--api-key <key> Swarm API key passed to API/worker (required unless env provides one)
|
|
676
|
+
--agent-id <id> Worker agent ID (default: e2b-<sandbox-id>)
|
|
677
|
+
--timeout-sec <seconds> Sandbox TTL (default 3600)
|
|
678
|
+
--e2b-api-key-file <path> Read the E2B controller API key from a file
|
|
679
|
+
--json Print machine-readable output
|
|
680
|
+
--dry-run Print/derive planned work without touching E2B
|
|
681
|
+
`);
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
export async function runE2BCommand(argv: string[]): Promise<void> {
|
|
685
|
+
const flags = parseFlags(argv);
|
|
686
|
+
const cwd = process.cwd();
|
|
687
|
+
try {
|
|
688
|
+
switch (flags.command) {
|
|
689
|
+
case undefined:
|
|
690
|
+
case "help":
|
|
691
|
+
printE2BHelp();
|
|
692
|
+
return;
|
|
693
|
+
case "build-template":
|
|
694
|
+
await buildTemplateCommand(flags, cwd);
|
|
695
|
+
return;
|
|
696
|
+
case "delete-template":
|
|
697
|
+
await deleteTemplateCommand(flags, cwd);
|
|
698
|
+
return;
|
|
699
|
+
case "publish-template":
|
|
700
|
+
await templateVisibilityCommand(flags, cwd, true);
|
|
701
|
+
return;
|
|
702
|
+
case "unpublish-template":
|
|
703
|
+
await templateVisibilityCommand(flags, cwd, false);
|
|
704
|
+
return;
|
|
705
|
+
case "start-api":
|
|
706
|
+
await startApiCommand(flags, cwd);
|
|
707
|
+
return;
|
|
708
|
+
case "start-worker":
|
|
709
|
+
await startWorkerCommand(flags, cwd);
|
|
710
|
+
return;
|
|
711
|
+
case "start-stack":
|
|
712
|
+
await startStackCommand(flags, cwd);
|
|
713
|
+
return;
|
|
714
|
+
case "list":
|
|
715
|
+
await listCommand(flags, cwd);
|
|
716
|
+
return;
|
|
717
|
+
case "kill":
|
|
718
|
+
await killCommand(flags, cwd);
|
|
719
|
+
return;
|
|
720
|
+
default:
|
|
721
|
+
throw new Error(`Unknown e2b subcommand: ${flags.command}`);
|
|
722
|
+
}
|
|
723
|
+
} catch (err) {
|
|
724
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
725
|
+
console.error(`e2b: ${message}`);
|
|
726
|
+
process.exitCode = 1;
|
|
727
|
+
}
|
|
728
|
+
}
|