@desplega.ai/agent-swarm 1.87.0 → 1.89.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/README.md +5 -1
- package/openapi.json +53 -1
- package/package.json +6 -5
- package/plugin/skills/composio/SKILL.md +98 -0
- package/src/be/db.ts +374 -9
- package/src/be/migrations/080_skill_system_defaults.sql +8 -0
- package/src/be/migrations/081_metrics.sql +39 -0
- package/src/be/migrations/082_user_audit_fields.sql +120 -0
- package/src/be/modelsdev-cache.json +3825 -2417
- package/src/be/seed/registry.ts +3 -2
- package/src/be/seed-skills/index.ts +179 -0
- package/src/cli.tsx +51 -4
- package/src/commands/e2b-stack-wizard.tsx +394 -0
- package/src/commands/e2b.ts +1352 -53
- package/src/commands/onboard/dashboard-url.ts +29 -0
- package/src/commands/onboard/steps/post-dashboard.tsx +3 -1
- package/src/commands/onboard.tsx +3 -1
- package/src/commands/runner.ts +154 -22
- package/src/commands/x.ts +118 -0
- package/src/e2b/dispatch.ts +234 -18
- package/src/github/handlers.ts +40 -1
- package/src/heartbeat/heartbeat.ts +26 -5
- package/src/http/active-sessions.ts +32 -1
- package/src/http/auth.ts +36 -0
- package/src/http/core.ts +20 -16
- package/src/http/db-query.ts +20 -0
- package/src/http/index.ts +2 -0
- package/src/http/memory.ts +13 -1
- package/src/http/metrics.ts +447 -0
- package/src/http/operator-actor.ts +9 -0
- package/src/http/poll.ts +11 -1
- package/src/http/skills.ts +53 -0
- package/src/http/tasks.ts +4 -1
- package/src/http/webhooks.ts +75 -0
- package/src/http/workflows.ts +5 -1
- package/src/integrations/kapso/client.ts +82 -0
- package/src/memory/automatic-task-gate.ts +47 -0
- package/src/metrics/version.ts +26 -0
- package/src/prompts/base-prompt.ts +24 -1
- package/src/prompts/session-templates.ts +74 -0
- package/src/providers/claude-adapter.ts +19 -0
- package/src/providers/codex-adapter.ts +22 -0
- package/src/providers/ctx-mode-env.ts +10 -0
- package/src/providers/opencode-adapter.ts +72 -7
- package/src/server.ts +10 -1
- package/src/slack/blocks.ts +12 -4
- package/src/slack/watcher.ts +3 -3
- package/src/telemetry.ts +14 -1
- package/src/templates.d.ts +4 -0
- package/src/tests/base-prompt.test.ts +76 -0
- package/src/tests/budget-claim-gate.test.ts +26 -0
- package/src/tests/claude-adapter.test.ts +86 -1
- package/src/tests/codex-adapter.test.ts +89 -0
- package/src/tests/core-auth.test.ts +8 -1
- package/src/tests/e2b-dispatch.test.ts +603 -11
- package/src/tests/events-http.test.ts +6 -2
- package/src/tests/github-handlers-cancel-config.test.ts +262 -0
- package/src/tests/heartbeat.test.ts +84 -3
- package/src/tests/http-api-integration.test.ts +116 -1
- package/src/tests/kapso-client.test.ts +74 -1
- package/src/tests/kapso-inbound.test.ts +60 -2
- package/src/tests/metrics-http.test.ts +247 -0
- package/src/tests/opencode-adapter.test.ts +185 -30
- package/src/tests/prompt-template-session.test.ts +4 -2
- package/src/tests/runner-repo-autostash.test.ts +117 -0
- package/src/tests/runner-requester-profile.test.ts +25 -0
- package/src/tests/runner-skills-refresh.test.ts +1 -1
- package/src/tests/self-improvement.test.ts +89 -0
- package/src/tests/skill-update-scope.test.ts +88 -1
- package/src/tests/slack-blocks.test.ts +15 -0
- package/src/tests/swarm-x-tool.test.ts +90 -0
- package/src/tests/system-default-skills.test.ts +122 -0
- package/src/tests/telemetry-init.test.ts +86 -0
- package/src/tests/ui-logs-parser.test.ts +271 -0
- package/src/tests/user-token-rest-auth.test.ts +129 -0
- package/src/tests/workflow-async-v2.test.ts +23 -0
- package/src/tests/x-composio.test.ts +122 -0
- package/src/tools/create-metric.ts +191 -0
- package/src/tools/skills/skill-delete.ts +14 -0
- package/src/tools/skills/skill-update.ts +14 -0
- package/src/tools/store-progress.ts +19 -5
- package/src/tools/swarm-x.ts +116 -0
- package/src/tools/tool-config.ts +6 -0
- package/src/types.ts +121 -0
- package/src/utils/request-auth-context.ts +28 -0
- package/src/utils/skills-refresh.ts +2 -2
- package/src/workflows/engine.ts +24 -2
- package/src/workflows/executors/agent-task.ts +2 -0
- package/src/x/composio.ts +295 -0
- package/templates/skills/artifacts/config.json +1 -0
- package/templates/skills/attio-interaction/SKILL.md +279 -0
- package/templates/skills/attio-interaction/config.json +14 -0
- package/templates/skills/attio-interaction/content.md +272 -0
- package/templates/skills/kv-storage/config.json +1 -0
- package/templates/skills/pages/config.json +1 -0
- package/templates/skills/scheduled-task-resilience/config.json +1 -0
- package/templates/skills/swarm-scripts/SKILL.md +91 -0
- package/templates/skills/swarm-scripts/config.json +14 -0
- package/templates/skills/swarm-scripts/content.md +86 -0
- package/templates/skills/workflow-iterate/config.json +1 -0
- package/templates/skills/workflow-structured-output/config.json +1 -0
- package/tsconfig.json +2 -1
package/src/e2b/dispatch.ts
CHANGED
|
@@ -14,6 +14,26 @@ export type E2BSandboxInfo = {
|
|
|
14
14
|
startedAt?: string;
|
|
15
15
|
endAt?: string;
|
|
16
16
|
metadata?: Record<string, string>;
|
|
17
|
+
// Client-side fallback for the sandbox expiry. The raw `POST /sandboxes`
|
|
18
|
+
// create response uses E2B's `Sandbox` schema, which (unlike `ListedSandbox`
|
|
19
|
+
// / `SandboxDetail`) does NOT include `endAt`. We populate this from
|
|
20
|
+
// `now + timeoutSec*1000` at create time so `ttlRemaining` can report expiry
|
|
21
|
+
// immediately after a launch without an extra round-trip. `endAt` (when
|
|
22
|
+
// present, e.g. from `listSandboxes`) is always authoritative over this.
|
|
23
|
+
expiresAt?: string;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export type TtlRemaining = {
|
|
27
|
+
expiresAt?: string;
|
|
28
|
+
secondsLeft?: number;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export type SetSandboxTimeoutOptions = {
|
|
32
|
+
sandboxId: string;
|
|
33
|
+
apiKey: string;
|
|
34
|
+
apiBase?: string;
|
|
35
|
+
e2bEnv?: EnvMap;
|
|
36
|
+
timeoutMs: number;
|
|
17
37
|
};
|
|
18
38
|
|
|
19
39
|
export type E2BCommandResult = {
|
|
@@ -80,6 +100,25 @@ export type StartDetachedOptions = {
|
|
|
80
100
|
cwd?: string;
|
|
81
101
|
};
|
|
82
102
|
|
|
103
|
+
export type StreamSandboxLogOptions = {
|
|
104
|
+
sandboxId: string;
|
|
105
|
+
role: E2BRole;
|
|
106
|
+
apiKey: string;
|
|
107
|
+
apiBase?: string;
|
|
108
|
+
e2bEnv?: EnvMap;
|
|
109
|
+
/** Number of trailing history lines to emit before following (default 200). */
|
|
110
|
+
tailLines?: number;
|
|
111
|
+
/** When true, keep streaming new output (`tail -f`) until the caller aborts. */
|
|
112
|
+
follow?: boolean;
|
|
113
|
+
/**
|
|
114
|
+
* Egress sink for each chunk. The caller MUST scrub here — log output is
|
|
115
|
+
* untrusted entrypoint stdout and can embed tokens/secrets.
|
|
116
|
+
*/
|
|
117
|
+
onChunk: (chunk: string) => void;
|
|
118
|
+
/** Abort signal to stop a `--follow` stream (e.g. on SIGINT). */
|
|
119
|
+
signal?: AbortSignal;
|
|
120
|
+
};
|
|
121
|
+
|
|
83
122
|
type E2BSdkConnectionOptions = {
|
|
84
123
|
apiKey: string;
|
|
85
124
|
apiUrl?: string;
|
|
@@ -98,15 +137,24 @@ function e2bHeaders(apiKey: string): Record<string, string> {
|
|
|
98
137
|
};
|
|
99
138
|
}
|
|
100
139
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
140
|
+
/**
|
|
141
|
+
* Build the shell payload for the envd-tracked entrypoint launch. Phase 5: the
|
|
142
|
+
* entrypoint is no longer detached via `nohup … >file &` (a grandchild envd
|
|
143
|
+
* never sees). Instead it runs as the SDK background command itself (envd owns
|
|
144
|
+
* and streams it; it survives client disconnect). We still `tee` to a
|
|
145
|
+
* deterministic file so `swarms logs` can retrieve FULL history later: the SDK's
|
|
146
|
+
* `commands.connect(pid)` only streams output going forward from the connect
|
|
147
|
+
* instant — it does NOT replay stdout produced while disconnected (verified
|
|
148
|
+
* against the e2b SDK types + docs) — so the file copy is the only reliable
|
|
149
|
+
* full-history source.
|
|
150
|
+
*
|
|
151
|
+
* `set -o pipefail` makes the pipeline's exit code reflect the ENTRYPOINT rather
|
|
152
|
+
* than `tee` (tee exits 0 on EOF even if the entrypoint crashed), so the early
|
|
153
|
+
* `exitCode` poll in {@link startDetachedProcess} can detect a launch failure.
|
|
154
|
+
* Invoked via `bash -lc` (both the api + worker images ship bash) for pipefail.
|
|
155
|
+
*/
|
|
156
|
+
export function buildTrackedShell(command: string, logPath: string): string {
|
|
157
|
+
return `set -o pipefail; ${command} 2>&1 | tee ${logPath}`;
|
|
110
158
|
}
|
|
111
159
|
|
|
112
160
|
export function e2bSdkConnectionOptions(
|
|
@@ -184,7 +232,10 @@ export async function e2bFetchJson<T>(
|
|
|
184
232
|
}
|
|
185
233
|
|
|
186
234
|
export async function createSandbox(opts: CreateSandboxOptions): Promise<E2BSandboxInfo> {
|
|
187
|
-
|
|
235
|
+
// Capture the wall-clock create instant BEFORE the request so the client-side
|
|
236
|
+
// expiry fallback reflects when the TTL countdown begins.
|
|
237
|
+
const createdAt = Date.now();
|
|
238
|
+
const sandbox = await e2bFetchJson<E2BSandboxInfo>(
|
|
188
239
|
"/sandboxes",
|
|
189
240
|
opts.apiKey,
|
|
190
241
|
{
|
|
@@ -200,6 +251,61 @@ export async function createSandbox(opts: CreateSandboxOptions): Promise<E2BSand
|
|
|
200
251
|
},
|
|
201
252
|
opts.apiBase,
|
|
202
253
|
);
|
|
254
|
+
// Pre-flight check (resolved against node_modules/e2b types): the create
|
|
255
|
+
// response is E2B's `Sandbox` schema, which omits `endAt`. Compute a
|
|
256
|
+
// client-side expiry fallback so `ttlRemaining` works right after launch.
|
|
257
|
+
if (!sandbox.endAt && !sandbox.expiresAt) {
|
|
258
|
+
sandbox.expiresAt = new Date(createdAt + opts.timeoutSec * 1000).toISOString();
|
|
259
|
+
}
|
|
260
|
+
return sandbox;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Compute the remaining time-to-live for a sandbox. Prefers the authoritative
|
|
265
|
+
* `endAt` (present on listed/detail responses); falls back to the client-side
|
|
266
|
+
* `expiresAt` stamped by `createSandbox`. Returns an empty object when neither
|
|
267
|
+
* is available (e.g. a dry-run fake sandbox). `secondsLeft` is clamped at 0 so
|
|
268
|
+
* an already-expired sandbox never reports negative time.
|
|
269
|
+
*/
|
|
270
|
+
export function ttlRemaining(sandbox: E2BSandboxInfo): TtlRemaining {
|
|
271
|
+
const expiresAt = sandbox.endAt ?? sandbox.expiresAt;
|
|
272
|
+
if (!expiresAt) return {};
|
|
273
|
+
const expiryMs = Date.parse(expiresAt);
|
|
274
|
+
if (Number.isNaN(expiryMs)) return {};
|
|
275
|
+
const secondsLeft = Math.max(0, Math.round((expiryMs - Date.now()) / 1000));
|
|
276
|
+
return { expiresAt, secondsLeft };
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Extend (or reduce) a live sandbox's TTL via the SDK and read back the actual
|
|
281
|
+
* `endAt` E2B applied (the server clamps to the tier max, so the requested
|
|
282
|
+
* timeout is not always honored verbatim). Connecting to a dead/expired sandbox
|
|
283
|
+
* throws; we translate that into a redacted "not found / already expired"
|
|
284
|
+
* error so a stale sandbox ID never leaks the controller key into logs.
|
|
285
|
+
*/
|
|
286
|
+
export async function setSandboxTimeout(opts: SetSandboxTimeoutOptions): Promise<TtlRemaining> {
|
|
287
|
+
const { Sandbox } = await import("e2b");
|
|
288
|
+
let sandbox: Awaited<ReturnType<typeof Sandbox.connect>>;
|
|
289
|
+
try {
|
|
290
|
+
sandbox = await Sandbox.connect(
|
|
291
|
+
opts.sandboxId,
|
|
292
|
+
e2bSdkConnectionOptions(opts.apiKey, opts.e2bEnv ?? {}, opts.apiBase),
|
|
293
|
+
);
|
|
294
|
+
} catch {
|
|
295
|
+
// Do not surface the underlying error verbatim — it can embed the
|
|
296
|
+
// controller API key / connection URL. Emit a fixed redacted message.
|
|
297
|
+
throw new Error(`sandbox ${opts.sandboxId} not found / already expired`);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
await sandbox.setTimeout(opts.timeoutMs);
|
|
301
|
+
// `setTimeout` returns void; re-read the info to learn the clamped expiry.
|
|
302
|
+
const info = await sandbox.getInfo();
|
|
303
|
+
const expiresAt = info.endAt instanceof Date ? info.endAt.toISOString() : String(info.endAt);
|
|
304
|
+
return ttlRemaining({
|
|
305
|
+
sandboxID: opts.sandboxId,
|
|
306
|
+
templateID: info.templateId,
|
|
307
|
+
endAt: expiresAt,
|
|
308
|
+
});
|
|
203
309
|
}
|
|
204
310
|
|
|
205
311
|
export async function killSandbox(
|
|
@@ -222,27 +328,137 @@ export async function listSandboxes(
|
|
|
222
328
|
return e2bFetchJson<E2BSandboxInfo[]>("/sandboxes", apiKey, {}, apiBase);
|
|
223
329
|
}
|
|
224
330
|
|
|
331
|
+
/**
|
|
332
|
+
* The deterministic per-role log path the entrypoint tees to. `swarms logs`
|
|
333
|
+
* recomputes it from the role alone (no PID bookkeeping needed) to `tail`/`cat`
|
|
334
|
+
* full history or `tail -f` for `--follow`.
|
|
335
|
+
*/
|
|
336
|
+
export function sandboxLogPath(role: E2BRole): string {
|
|
337
|
+
return `/tmp/agent-swarm-e2b-${role}.log`;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Launch the entrypoint as an envd-tracked BACKGROUND command (Phase 5). Returns
|
|
342
|
+
* the PID immediately. Replaces the old `nohup … >file & sleep 2; kill -0` hack:
|
|
343
|
+
* the SDK's background handle exposes `exitCode` (undefined while running), so we
|
|
344
|
+
* poll it once after a short grace period — a non-zero exit by then means the
|
|
345
|
+
* entrypoint died at launch, which we surface as a launch failure (reading the
|
|
346
|
+
* tee'd log for context). The `tee` preserves a file copy for full-history
|
|
347
|
+
* retrieval regardless of envd stdout-replay semantics.
|
|
348
|
+
*/
|
|
225
349
|
export async function startDetachedProcess(opts: StartDetachedOptions): Promise<string> {
|
|
226
|
-
const logPath =
|
|
227
|
-
const
|
|
228
|
-
const shell = buildDetachedShell(opts.command, logPath, pidPath);
|
|
350
|
+
const logPath = sandboxLogPath(opts.role);
|
|
351
|
+
const shell = buildTrackedShell(opts.command, logPath);
|
|
229
352
|
|
|
230
353
|
const { Sandbox } = await import("e2b");
|
|
231
354
|
const sandbox = await Sandbox.connect(
|
|
232
355
|
opts.sandbox.sandboxID,
|
|
233
356
|
e2bSdkConnectionOptions(opts.apiKey, opts.e2bEnv ?? {}, opts.apiBase),
|
|
234
357
|
);
|
|
235
|
-
|
|
358
|
+
// `bash -lc` (not `sh`) so `set -o pipefail` is honored on both images.
|
|
359
|
+
const handle = await sandbox.commands.run(`bash -lc ${shellQuote(shell)}`, {
|
|
236
360
|
user: opts.user ?? "root",
|
|
237
361
|
cwd: opts.cwd ?? "/",
|
|
238
362
|
envs: opts.env,
|
|
239
|
-
|
|
363
|
+
background: true,
|
|
240
364
|
});
|
|
241
365
|
|
|
242
|
-
|
|
243
|
-
|
|
366
|
+
// Early liveness poll: give the entrypoint a moment to fault, then check the
|
|
367
|
+
// handle's exit code. `undefined` = still running (the expected happy path).
|
|
368
|
+
await Bun.sleep(2_000);
|
|
369
|
+
if (typeof handle.exitCode === "number" && handle.exitCode !== 0) {
|
|
370
|
+
// The pipeline already exited non-zero — surface stderr/stdout (redacted, as
|
|
371
|
+
// entrypoint output can embed tokens) as a launch failure.
|
|
372
|
+
const detail = redactWithEnv(`${handle.stdout}\n${handle.stderr}`.trim(), opts.env);
|
|
373
|
+
throw new Error(`E2B start command exited ${handle.exitCode} at launch: ${detail}`);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
return String(handle.pid);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/** Single-quote a string for safe embedding in a `bash -lc '<...>'` invocation. */
|
|
380
|
+
function shellQuote(value: string): string {
|
|
381
|
+
return `'${value.split("'").join(`'\\''`)}'`;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* Stream a sandbox's tee'd entrypoint log to the caller's `onChunk` sink.
|
|
386
|
+
*
|
|
387
|
+
* Design (Phase 5): we read from the deterministic per-role {@link sandboxLogPath}
|
|
388
|
+
* the entrypoint tees to — NOT from a tracked PID — so no PID bookkeeping is
|
|
389
|
+
* needed and history survives reconnect / a fresh CLI process. The SDK's
|
|
390
|
+
* `commands.connect(pid)` only streams forward from connect (no historical
|
|
391
|
+
* replay, verified against the SDK), so the file is the source of truth for
|
|
392
|
+
* full history.
|
|
393
|
+
*
|
|
394
|
+
* - History (no `--follow`): `tail -n <N> <logPath>` once (a CommandResult).
|
|
395
|
+
* - Follow: `tail -n <N> -F <logPath>` as a BACKGROUND command, piping each
|
|
396
|
+
* `onStdout`/`onStderr` chunk to `onChunk` until the abort signal fires
|
|
397
|
+
* (`-F` keeps following across truncation/rotation; tolerates a not-yet-created
|
|
398
|
+
* file). The caller scrubs inside `onChunk`.
|
|
399
|
+
*
|
|
400
|
+
* Output is emitted RAW here; the caller is responsible for scrubbing in
|
|
401
|
+
* `onChunk` (it sees both this function's stdout and stderr).
|
|
402
|
+
*/
|
|
403
|
+
export async function streamSandboxLog(opts: StreamSandboxLogOptions): Promise<void> {
|
|
404
|
+
const logPath = sandboxLogPath(opts.role);
|
|
405
|
+
const tailLines = opts.tailLines ?? 200;
|
|
406
|
+
|
|
407
|
+
const { Sandbox } = await import("e2b");
|
|
408
|
+
const sandbox = await Sandbox.connect(
|
|
409
|
+
opts.sandboxId,
|
|
410
|
+
e2bSdkConnectionOptions(opts.apiKey, opts.e2bEnv ?? {}, opts.apiBase),
|
|
411
|
+
);
|
|
412
|
+
|
|
413
|
+
if (!opts.follow) {
|
|
414
|
+
// History only: a single `tail`. If the file does not exist yet (entrypoint
|
|
415
|
+
// hasn't written), `tail` exits non-zero with a message on stderr — we emit
|
|
416
|
+
// that to the sink rather than throwing, so a freshly-launched swarm reads as
|
|
417
|
+
// "no logs yet" instead of a hard error.
|
|
418
|
+
const result = await sandbox.commands.run(
|
|
419
|
+
`bash -lc ${shellQuote(`tail -n ${tailLines} ${logPath} 2>&1 || true`)}`,
|
|
420
|
+
{ user: "root", timeoutMs: 30_000 },
|
|
421
|
+
);
|
|
422
|
+
if (result.stdout) opts.onChunk(result.stdout);
|
|
423
|
+
if (result.stderr) opts.onChunk(result.stderr);
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// Follow: background `tail -F` streaming forward. `-F` (vs `-f`) re-opens the
|
|
428
|
+
// file if it is rotated/recreated and waits for a not-yet-existing file.
|
|
429
|
+
const handle = await sandbox.commands.run(
|
|
430
|
+
`bash -lc ${shellQuote(`tail -n ${tailLines} -F ${logPath}`)}`,
|
|
431
|
+
{
|
|
432
|
+
user: "root",
|
|
433
|
+
background: true,
|
|
434
|
+
onStdout: (data) => opts.onChunk(data),
|
|
435
|
+
onStderr: (data) => opts.onChunk(data),
|
|
436
|
+
},
|
|
437
|
+
);
|
|
438
|
+
|
|
439
|
+
const stop = async () => {
|
|
440
|
+
try {
|
|
441
|
+
await handle.kill();
|
|
442
|
+
} catch {
|
|
443
|
+
// The command may already be gone (sandbox killed/expired); ignore.
|
|
444
|
+
}
|
|
445
|
+
};
|
|
446
|
+
if (opts.signal) {
|
|
447
|
+
if (opts.signal.aborted) {
|
|
448
|
+
await stop();
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
451
|
+
opts.signal.addEventListener("abort", stop, { once: true });
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
try {
|
|
455
|
+
// `wait()` resolves when the stream ends (sandbox death) or the handle is
|
|
456
|
+
// killed by the abort listener above. `tail -F` otherwise runs indefinitely.
|
|
457
|
+
await handle.wait();
|
|
458
|
+
} catch {
|
|
459
|
+
// A kill / disconnect surfaces as a rejected wait — that is the expected exit
|
|
460
|
+
// path for `--follow`, not an error to propagate.
|
|
244
461
|
}
|
|
245
|
-
return result.stdout.trim();
|
|
246
462
|
}
|
|
247
463
|
|
|
248
464
|
export async function waitForAgentRegistration(
|
package/src/github/handlers.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { failTask, findTaskByVcs, getAllAgents, incrKv, upsertKv } from "../be/db";
|
|
1
|
+
import { failTask, findTaskByVcs, getAllAgents, getSwarmConfigs, incrKv, upsertKv } from "../be/db";
|
|
2
2
|
import { findUserByExternalId } from "../be/users";
|
|
3
3
|
import { resolveTemplate } from "../prompts/resolver";
|
|
4
4
|
import { githubContextKey } from "../tasks/context-key";
|
|
@@ -46,6 +46,19 @@ function buildGithubContextKey(
|
|
|
46
46
|
}
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
+
/**
|
|
50
|
+
* Runtime-config guards for cancel-on-unassign and cancel-on-review-request-removed.
|
|
51
|
+
* Absent key / any value other than "false" → true (cancel, current behavior).
|
|
52
|
+
* Value "false" → false (skip cancel, leave task untouched).
|
|
53
|
+
*/
|
|
54
|
+
function cancelFlagEnabled(key: string): boolean {
|
|
55
|
+
const row = getSwarmConfigs({ scope: "global", key })[0];
|
|
56
|
+
return row?.value !== "false";
|
|
57
|
+
}
|
|
58
|
+
const cancelOnUnassignEnabled = () => cancelFlagEnabled("github.cancelOnUnassign");
|
|
59
|
+
const cancelOnReviewRequestRemovedEnabled = () =>
|
|
60
|
+
cancelFlagEnabled("github.cancelOnReviewRequestRemoved");
|
|
61
|
+
|
|
49
62
|
/**
|
|
50
63
|
* Get review state emoji and label
|
|
51
64
|
*/
|
|
@@ -278,6 +291,14 @@ export async function handlePullRequest(
|
|
|
278
291
|
return { created: false };
|
|
279
292
|
}
|
|
280
293
|
|
|
294
|
+
// Config gate: skip cancel if disabled
|
|
295
|
+
if (!cancelOnUnassignEnabled()) {
|
|
296
|
+
console.log(
|
|
297
|
+
`[GitHub] unassign cancel disabled by config — leaving task untouched (PR #${pr.number})`,
|
|
298
|
+
);
|
|
299
|
+
return { created: false };
|
|
300
|
+
}
|
|
301
|
+
|
|
281
302
|
// Find the related task
|
|
282
303
|
const task = findTaskByVcs(repository.full_name, pr.number);
|
|
283
304
|
if (!task) {
|
|
@@ -378,6 +399,14 @@ export async function handlePullRequest(
|
|
|
378
399
|
return { created: false };
|
|
379
400
|
}
|
|
380
401
|
|
|
402
|
+
// Config gate: skip cancel if disabled
|
|
403
|
+
if (!cancelOnReviewRequestRemovedEnabled()) {
|
|
404
|
+
console.log(
|
|
405
|
+
`[GitHub] review-request-removed cancel disabled by config — leaving task untouched (PR #${pr.number})`,
|
|
406
|
+
);
|
|
407
|
+
return { created: false };
|
|
408
|
+
}
|
|
409
|
+
|
|
381
410
|
// Find the related task
|
|
382
411
|
const task = findTaskByVcs(repository.full_name, pr.number);
|
|
383
412
|
if (!task) {
|
|
@@ -533,6 +562,7 @@ export async function handlePullRequest(
|
|
|
533
562
|
vcsUrl: pr.html_url,
|
|
534
563
|
vcsInstallationId: installation?.id,
|
|
535
564
|
contextKey: buildGithubContextKey(repository.full_name, "pr", pr.number),
|
|
565
|
+
requestedByUserId,
|
|
536
566
|
});
|
|
537
567
|
|
|
538
568
|
if (lead) {
|
|
@@ -638,6 +668,14 @@ export async function handleIssue(
|
|
|
638
668
|
return { created: false };
|
|
639
669
|
}
|
|
640
670
|
|
|
671
|
+
// Config gate: skip cancel if disabled
|
|
672
|
+
if (!cancelOnUnassignEnabled()) {
|
|
673
|
+
console.log(
|
|
674
|
+
`[GitHub] unassign cancel disabled by config — leaving task untouched (issue #${issue.number})`,
|
|
675
|
+
);
|
|
676
|
+
return { created: false };
|
|
677
|
+
}
|
|
678
|
+
|
|
641
679
|
// Find the related task
|
|
642
680
|
const task = findTaskByVcs(repository.full_name, issue.number);
|
|
643
681
|
if (!task) {
|
|
@@ -771,6 +809,7 @@ export async function handleIssue(
|
|
|
771
809
|
vcsUrl: issue.html_url,
|
|
772
810
|
vcsInstallationId: installation?.id,
|
|
773
811
|
contextKey: buildGithubContextKey(repository.full_name, "issue", issue.number),
|
|
812
|
+
requestedByUserId,
|
|
774
813
|
});
|
|
775
814
|
|
|
776
815
|
if (lead) {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import {
|
|
2
|
-
|
|
2
|
+
assignUnassignedTaskPending,
|
|
3
3
|
cleanupStaleSessions,
|
|
4
4
|
createTaskExtended,
|
|
5
5
|
deleteActiveSession,
|
|
@@ -461,7 +461,7 @@ function checkWorkerHealth(findings: HeartbeatFindings): void {
|
|
|
461
461
|
|
|
462
462
|
/**
|
|
463
463
|
* Auto-assign unassigned pool tasks to idle workers with capacity.
|
|
464
|
-
*
|
|
464
|
+
* Leaves tasks pending so the assigned worker's normal poll dispatches them.
|
|
465
465
|
*/
|
|
466
466
|
function autoAssignPoolTasks(findings: HeartbeatFindings): void {
|
|
467
467
|
getDb().transaction(() => {
|
|
@@ -472,16 +472,37 @@ function autoAssignPoolTasks(findings: HeartbeatFindings): void {
|
|
|
472
472
|
if (poolTasks.length === 0) return;
|
|
473
473
|
|
|
474
474
|
let workerIndex = 0;
|
|
475
|
+
const reservedByWorker = new Map<string, number>();
|
|
476
|
+
const reservedForWorker = (agentId: string): number => {
|
|
477
|
+
const cached = reservedByWorker.get(agentId);
|
|
478
|
+
if (cached !== undefined) return cached;
|
|
479
|
+
const row = getDb()
|
|
480
|
+
.prepare<{ count: number }, [string]>(
|
|
481
|
+
"SELECT COUNT(*) as count FROM agent_tasks WHERE agentId = ? AND status IN ('pending', 'in_progress')",
|
|
482
|
+
)
|
|
483
|
+
.get(agentId);
|
|
484
|
+
const reserved = row?.count ?? 0;
|
|
485
|
+
reservedByWorker.set(agentId, reserved);
|
|
486
|
+
return reserved;
|
|
487
|
+
};
|
|
488
|
+
|
|
475
489
|
for (const task of poolTasks) {
|
|
476
490
|
if (workerIndex >= idleWorkers.length) break;
|
|
477
491
|
|
|
478
492
|
const worker = idleWorkers[workerIndex]!;
|
|
479
|
-
const
|
|
493
|
+
const maxTasks = worker.maxTasks ?? 1;
|
|
494
|
+
if (reservedForWorker(worker.id) >= maxTasks) {
|
|
495
|
+
workerIndex++;
|
|
496
|
+
continue;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
const assigned = assignUnassignedTaskPending(task.id, worker.id);
|
|
480
500
|
|
|
481
|
-
if (
|
|
501
|
+
if (assigned) {
|
|
482
502
|
findings.autoAssigned.push({ taskId: task.id, agentId: worker.id });
|
|
503
|
+
reservedByWorker.set(worker.id, reservedForWorker(worker.id) + 1);
|
|
483
504
|
// Check if this worker still has capacity for more
|
|
484
|
-
const remaining =
|
|
505
|
+
const remaining = maxTasks - reservedForWorker(worker.id);
|
|
485
506
|
if (remaining <= 0) {
|
|
486
507
|
workerIndex++;
|
|
487
508
|
}
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
getActiveSessions,
|
|
9
9
|
heartbeatActiveSession,
|
|
10
10
|
insertActiveSession,
|
|
11
|
+
resetOrphanedInProgressTasksForAgent,
|
|
11
12
|
updateActiveSessionProviderSessionId,
|
|
12
13
|
} from "../be/db";
|
|
13
14
|
import { route } from "./route-def";
|
|
@@ -115,6 +116,21 @@ const cleanupSessions = route({
|
|
|
115
116
|
},
|
|
116
117
|
});
|
|
117
118
|
|
|
119
|
+
const recoverOrphanedTasks = route({
|
|
120
|
+
method: "post",
|
|
121
|
+
path: "/api/active-sessions/recover-orphaned-tasks",
|
|
122
|
+
pattern: ["api", "active-sessions", "recover-orphaned-tasks"],
|
|
123
|
+
summary: "Recover orphaned in-progress tasks for an agent",
|
|
124
|
+
tags: ["Active Sessions"],
|
|
125
|
+
body: z.object({
|
|
126
|
+
agentId: z.string().min(1),
|
|
127
|
+
minAgeSeconds: z.number().int().positive().optional(),
|
|
128
|
+
}),
|
|
129
|
+
responses: {
|
|
130
|
+
200: { description: "Recovery result" },
|
|
131
|
+
},
|
|
132
|
+
});
|
|
133
|
+
|
|
118
134
|
// ─── Handler ─────────────────────────────────────────────────────────────────
|
|
119
135
|
|
|
120
136
|
export async function handleActiveSessions(
|
|
@@ -122,7 +138,7 @@ export async function handleActiveSessions(
|
|
|
122
138
|
res: ServerResponse,
|
|
123
139
|
pathSegments: string[],
|
|
124
140
|
queryParams: URLSearchParams,
|
|
125
|
-
|
|
141
|
+
myAgentId: string | undefined,
|
|
126
142
|
): Promise<boolean> {
|
|
127
143
|
if (listActiveSessions.match(req.method, pathSegments)) {
|
|
128
144
|
const parsed = await listActiveSessions.parse(req, res, pathSegments, queryParams);
|
|
@@ -195,5 +211,20 @@ export async function handleActiveSessions(
|
|
|
195
211
|
return true;
|
|
196
212
|
}
|
|
197
213
|
|
|
214
|
+
if (recoverOrphanedTasks.match(req.method, pathSegments)) {
|
|
215
|
+
const parsed = await recoverOrphanedTasks.parse(req, res, pathSegments, queryParams);
|
|
216
|
+
if (!parsed) return true;
|
|
217
|
+
if (!myAgentId || parsed.body.agentId !== myAgentId) {
|
|
218
|
+
json(res, { error: "Can only recover orphaned tasks for the calling agent" }, 403);
|
|
219
|
+
return true;
|
|
220
|
+
}
|
|
221
|
+
const tasks = resetOrphanedInProgressTasksForAgent(
|
|
222
|
+
parsed.body.agentId,
|
|
223
|
+
parsed.body.minAgeSeconds ?? 60,
|
|
224
|
+
);
|
|
225
|
+
json(res, { recovered: tasks.length, tasks });
|
|
226
|
+
return true;
|
|
227
|
+
}
|
|
228
|
+
|
|
198
229
|
return false;
|
|
199
230
|
}
|
package/src/http/auth.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { IncomingMessage } from "node:http";
|
|
2
|
+
import { fingerprintApiKey, resolveUserByToken } from "../be/users";
|
|
3
|
+
import type { User } from "../types";
|
|
4
|
+
import type { HttpRequestAuth } from "../utils/request-auth-context";
|
|
5
|
+
|
|
6
|
+
function extractBearer(req: IncomingMessage): string | null {
|
|
7
|
+
const raw = req.headers.authorization;
|
|
8
|
+
const header = Array.isArray(raw) ? raw[0] : raw;
|
|
9
|
+
if (!header?.startsWith("Bearer ")) return null;
|
|
10
|
+
return header.slice("Bearer ".length).trim();
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function resolveHttpRequestAuth(
|
|
14
|
+
req: IncomingMessage,
|
|
15
|
+
apiKey: string | undefined,
|
|
16
|
+
): HttpRequestAuth | null {
|
|
17
|
+
const bearer = extractBearer(req);
|
|
18
|
+
if (!bearer) return null;
|
|
19
|
+
|
|
20
|
+
if (apiKey && bearer === apiKey) {
|
|
21
|
+
return { kind: "operator", fingerprint: fingerprintApiKey(bearer) };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
if (bearer.startsWith("aswt_")) {
|
|
25
|
+
const user = resolveUserByToken(bearer);
|
|
26
|
+
if (isActiveUser(user)) {
|
|
27
|
+
return { kind: "user", userId: user.id, user };
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function isActiveUser(user: User | null): user is User {
|
|
35
|
+
return !!user && user.status === "active";
|
|
36
|
+
}
|
package/src/http/core.ts
CHANGED
|
@@ -15,7 +15,9 @@ import { initJira, resetJira } from "../jira";
|
|
|
15
15
|
import { initLinear, resetLinear } from "../linear";
|
|
16
16
|
import { startSlackApp, stopSlackApp } from "../slack";
|
|
17
17
|
import type { AgentStatus } from "../types";
|
|
18
|
+
import { setRequestAuth } from "../utils/request-auth-context";
|
|
18
19
|
import { refreshSecretScrubberCache } from "../utils/secret-scrubber";
|
|
20
|
+
import { resolveHttpRequestAuth } from "./auth";
|
|
19
21
|
import { generateOpenApiSpec, SCALAR_HTML } from "./openapi";
|
|
20
22
|
import { isPublicRoute } from "./route-def";
|
|
21
23
|
import { agentWithCapacity, getPathSegments, parseQueryParams } from "./utils";
|
|
@@ -234,25 +236,27 @@ export async function handleCore(
|
|
|
234
236
|
return true;
|
|
235
237
|
}
|
|
236
238
|
|
|
237
|
-
// API-key authentication
|
|
239
|
+
// API-key authentication. Routes that opt out via
|
|
238
240
|
// `route({ auth: { apiKey: false } })` — webhooks, OAuth provider callbacks,
|
|
239
241
|
// etc. — are skipped based on the central `routeRegistry`. Unknown paths
|
|
240
|
-
// fall through to the bearer check (fail-closed).
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
}
|
|
242
|
+
// fall through to the bearer check (fail-closed). Normal API calls may use
|
|
243
|
+
// either the global swarm key or an active user-bound `aswt_` token.
|
|
244
|
+
const pathSegments = getPathSegments(req.url || "");
|
|
245
|
+
const isUserMcpRoute = req.url === "/mcp-user";
|
|
246
|
+
// `/mcp-user` runs its own `aswt_`-token auth in `handleMcpUser`; the swarm
|
|
247
|
+
// API key must not gate it.
|
|
248
|
+
if (isUserMcpRoute || isPublicRoute(req.method, pathSegments)) {
|
|
249
|
+
setRequestAuth(req, null);
|
|
250
|
+
} else {
|
|
251
|
+
const auth = resolveHttpRequestAuth(req, apiKey);
|
|
252
|
+
|
|
253
|
+
if (!auth) {
|
|
254
|
+
setRequestAuth(req, null);
|
|
255
|
+
res.writeHead(401, { "Content-Type": "application/json" });
|
|
256
|
+
res.end(JSON.stringify({ error: "Unauthorized" }));
|
|
257
|
+
return true;
|
|
255
258
|
}
|
|
259
|
+
setRequestAuth(req, auth);
|
|
256
260
|
}
|
|
257
261
|
|
|
258
262
|
// POST /internal/reload-config — re-read swarm_config into process.env and re-init integrations
|
package/src/http/db-query.ts
CHANGED
|
@@ -11,6 +11,25 @@ export interface DbQueryResult {
|
|
|
11
11
|
total: number;
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
+
function stripTrailingSemicolon(sql: string): string {
|
|
15
|
+
return sql.trim().replace(/;\s*$/, "").trim();
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function assertSingleStatement(sql: string): void {
|
|
19
|
+
const stripped = stripTrailingSemicolon(sql);
|
|
20
|
+
if (stripped.includes(";")) {
|
|
21
|
+
throw new Error("Only one SQL statement is allowed");
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function assertSelectOnlyQuery(sql: string): void {
|
|
26
|
+
assertSingleStatement(sql);
|
|
27
|
+
const normalized = stripTrailingSemicolon(sql).toLowerCase();
|
|
28
|
+
if (!normalized.startsWith("select ") && !normalized.startsWith("with ")) {
|
|
29
|
+
throw new Error("Metric queries must start with SELECT or WITH");
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
14
33
|
/**
|
|
15
34
|
* Execute a read-only SQL query against the swarm database.
|
|
16
35
|
* Detects write statements via bun:sqlite's columnNames (empty for INSERT/UPDATE/DELETE/DROP).
|
|
@@ -20,6 +39,7 @@ export function executeReadOnlyQuery(
|
|
|
20
39
|
params: unknown[] = [],
|
|
21
40
|
maxRows?: number,
|
|
22
41
|
): DbQueryResult {
|
|
42
|
+
assertSingleStatement(sql);
|
|
23
43
|
const stmt = getDb().prepare(sql);
|
|
24
44
|
|
|
25
45
|
// bun:sqlite: columnNames is empty for write statements, populated for SELECT/PRAGMA/EXPLAIN
|
package/src/http/index.ts
CHANGED
|
@@ -46,6 +46,7 @@ import { handleMcpOAuth, startMcpOAuthPendingGc, stopMcpOAuthPendingGc } from ".
|
|
|
46
46
|
import { handleMcpServers } from "./mcp-servers";
|
|
47
47
|
import { handleMcpUser } from "./mcp-user";
|
|
48
48
|
import { handleMemory } from "./memory";
|
|
49
|
+
import { handleMetrics } from "./metrics";
|
|
49
50
|
import { handlePageProxy } from "./page-proxy";
|
|
50
51
|
import { handlePages } from "./pages";
|
|
51
52
|
import { handlePagesPublic } from "./pages-public";
|
|
@@ -229,6 +230,7 @@ const httpServer = createHttpServer(async (req, res) => {
|
|
|
229
230
|
() => handleIntegrations(req, res, pathSegments),
|
|
230
231
|
() => handlePromptTemplates(req, res, pathSegments, queryParams),
|
|
231
232
|
() => handleDbQuery(req, res, pathSegments, queryParams),
|
|
233
|
+
() => handleMetrics(req, res, pathSegments, queryParams, myAgentId),
|
|
232
234
|
() => handleRepos(req, res, pathSegments, queryParams),
|
|
233
235
|
() => handleSkills(req, res, pathSegments, queryParams, myAgentId),
|
|
234
236
|
() => handleScripts(req, res, pathSegments, queryParams, myAgentId),
|