@ra3orblade/swarm 0.8.0 → 0.10.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 +8 -1
- package/dist/swarm-hook.js +5 -1
- package/dist/swarm-mcp.js +35 -6
- package/dist/swarm.js +355 -11
- package/dist/swarmd.js +2126 -88
- package/package.json +1 -1
- package/web/app.js +333 -28
- package/web/index.html +64 -12
- package/web/release-notes.js +1 -1
- package/web/viz.js +67 -4
package/README.md
CHANGED
|
@@ -221,4 +221,11 @@ bun run dev # daemon with hot reload
|
|
|
221
221
|
bun run test # the suite CI runs
|
|
222
222
|
```
|
|
223
223
|
|
|
224
|
-
Apache-2.0
|
|
224
|
+
Apache-2.0 — everything that runs on one machine: daemon, dashboard, CLI, MCP server, hooks, all
|
|
225
|
+
agent adapters. No telemetry, no account, and that never changes.
|
|
226
|
+
|
|
227
|
+
The one exception is [`packages/team`](packages/team) — the self-hosted **team daemon** (multi-machine,
|
|
228
|
+
paid tier), which is source-available under
|
|
229
|
+
[FSL-1.1-ALv2](packages/team/LICENSE.md): free for internal use, education, research and
|
|
230
|
+
professional services, converting to Apache-2.0 two years after each release. The boundary is
|
|
231
|
+
simple ([OQ-15](docs/07-open-questions.md)): one machine free, a second person is the product.
|
package/dist/swarm-hook.js
CHANGED
|
@@ -77,7 +77,9 @@ var AUDIT_TYPES = new Set([
|
|
|
77
77
|
"permission.resolved",
|
|
78
78
|
"incident.opened",
|
|
79
79
|
"incident.acked",
|
|
80
|
-
"run.result"
|
|
80
|
+
"run.result",
|
|
81
|
+
"workflow.started",
|
|
82
|
+
"workflow.finished"
|
|
81
83
|
]);
|
|
82
84
|
var AUDIT_TYPES_SQL = [...AUDIT_TYPES].map((t) => `'${t}'`).join(", ");
|
|
83
85
|
// packages/core/src/budget.ts
|
|
@@ -230,6 +232,8 @@ function guardWrite(target, current, claims, modes = DEFAULT_MODES, kind = "file
|
|
|
230
232
|
}
|
|
231
233
|
// packages/core/src/ledger.ts
|
|
232
234
|
var EDIT_TOOLS = new Set(["Edit", "Write", "MultiEdit", "NotebookEdit"]);
|
|
235
|
+
// packages/core/src/outcomes.ts
|
|
236
|
+
var DEFAULT_BRANCHES = new Set(["main", "master", "develop", "trunk"]);
|
|
233
237
|
// packages/core/src/policy.ts
|
|
234
238
|
import { createHash } from "crypto";
|
|
235
239
|
var POLICY_CACHE_VERSION = 1;
|
package/dist/swarm-mcp.js
CHANGED
|
@@ -19952,15 +19952,44 @@ ${lines.join(`
|
|
|
19952
19952
|
return ok(`asked as question #${r.question?.id}. A human will see it on the dashboard; the answer arrives as [swarm] context on a later tool call, or via swarm_inbox. Continue with what doesn't depend on it, or stop and say you're waiting.`, r.question);
|
|
19953
19953
|
});
|
|
19954
19954
|
server.registerTool("swarm_inbox", {
|
|
19955
|
-
title: "
|
|
19956
|
-
description: "Answers
|
|
19955
|
+
title: "What's waiting for you",
|
|
19956
|
+
description: "Answers to your swarm_ask questions and messages other agents or the human sent you (swarm_send) that you haven't received yet.",
|
|
19957
19957
|
inputSchema: {}
|
|
19958
19958
|
}, async () => {
|
|
19959
19959
|
const qs = await api2(`/v1/inbox?session=${encodeURIComponent(SESSION ?? "")}`);
|
|
19960
|
-
|
|
19961
|
-
|
|
19962
|
-
|
|
19963
|
-
|
|
19960
|
+
const ms = await api2(`/v1/messages/inbox?session=${encodeURIComponent(SESSION ?? "")}`);
|
|
19961
|
+
if (!qs.length && !ms.length)
|
|
19962
|
+
return ok("nothing new", []);
|
|
19963
|
+
const lines = [
|
|
19964
|
+
...qs.map((q) => `answer #${q.id} "${q.text}" \u2192 ${q.answeredBy ?? "a human"}: ${q.answer}`),
|
|
19965
|
+
...ms.map((m) => `message #${m.id} from ${m.from ?? "unknown"}${m.task ? ` (re ${m.task})` : ""}: ${m.text}`)
|
|
19966
|
+
];
|
|
19967
|
+
return ok(lines.join(`
|
|
19968
|
+
`), { answers: qs, messages: ms });
|
|
19969
|
+
});
|
|
19970
|
+
server.registerTool("swarm_send", {
|
|
19971
|
+
title: "Message another agent (or the human)",
|
|
19972
|
+
description: `Send a short message to another session (session id), to whoever holds a task (task id), or to "lead" \u2014 the human's interactive session in this project. Delivery is on their next tool call (or immediately to a spawned run); replies come back via swarm_inbox.`,
|
|
19973
|
+
inputSchema: {
|
|
19974
|
+
to: exports_external.string().describe('session id, task id, or "lead"'),
|
|
19975
|
+
text: exports_external.string().max(4000)
|
|
19976
|
+
}
|
|
19977
|
+
}, async ({ to, text }) => {
|
|
19978
|
+
const pid = await projectId();
|
|
19979
|
+
const r = await api2("/v1/messages", {
|
|
19980
|
+
method: "POST",
|
|
19981
|
+
headers: { "content-type": "application/json" },
|
|
19982
|
+
body: JSON.stringify({
|
|
19983
|
+
projectId: pid,
|
|
19984
|
+
to,
|
|
19985
|
+
text,
|
|
19986
|
+
sessionId: SESSION,
|
|
19987
|
+
from: OWNER === "agent" && SESSION ? `agent ${SESSION.slice(0, 8)}` : OWNER
|
|
19988
|
+
})
|
|
19989
|
+
});
|
|
19990
|
+
if (!r.ok)
|
|
19991
|
+
return fail(r.error ?? "send failed");
|
|
19992
|
+
return ok(`sent #${r.message?.id}${r.message?.sessionId ? "" : " (queued until the target appears)"}`, r.message);
|
|
19964
19993
|
});
|
|
19965
19994
|
server.registerTool("swarm_dispatch", {
|
|
19966
19995
|
title: "Dispatch tasks to spawned agents",
|
package/dist/swarm.js
CHANGED
|
@@ -2,7 +2,15 @@
|
|
|
2
2
|
// @bun
|
|
3
3
|
|
|
4
4
|
// packages/cli/src/bin.ts
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
copyFileSync,
|
|
7
|
+
existsSync as existsSync5,
|
|
8
|
+
mkdirSync as mkdirSync3,
|
|
9
|
+
readdirSync,
|
|
10
|
+
unlinkSync,
|
|
11
|
+
writeFileSync as writeFileSync3
|
|
12
|
+
} from "fs";
|
|
13
|
+
import { join as join5, resolve as resolve3 } from "path";
|
|
6
14
|
|
|
7
15
|
// packages/client/src/daemon.ts
|
|
8
16
|
import { existsSync as existsSync2, mkdirSync, readFileSync, rmSync, writeFileSync } from "fs";
|
|
@@ -189,7 +197,9 @@ var AUDIT_TYPES = new Set([
|
|
|
189
197
|
"permission.resolved",
|
|
190
198
|
"incident.opened",
|
|
191
199
|
"incident.acked",
|
|
192
|
-
"run.result"
|
|
200
|
+
"run.result",
|
|
201
|
+
"workflow.started",
|
|
202
|
+
"workflow.finished"
|
|
193
203
|
]);
|
|
194
204
|
var AUDIT_TYPES_SQL = [...AUDIT_TYPES].map((t) => `'${t}'`).join(", ");
|
|
195
205
|
var DEFAULT_PRIVACY = {
|
|
@@ -202,14 +212,65 @@ var BUDGET_ASK_TOOLS = new Set(["Bash", "Edit", "Write", "MultiEdit", "NotebookE
|
|
|
202
212
|
// packages/core/src/config.ts
|
|
203
213
|
import { existsSync as existsSync3, readFileSync as readFileSync2 } from "fs";
|
|
204
214
|
import { join as join2 } from "path";
|
|
215
|
+
|
|
216
|
+
// packages/core/src/workflows.ts
|
|
217
|
+
var NAME_RE = /^[a-z0-9][a-z0-9_.-]{0,39}$/i;
|
|
218
|
+
function isRecord(v) {
|
|
219
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
220
|
+
}
|
|
221
|
+
function parseWorkflows(raw) {
|
|
222
|
+
const out = {};
|
|
223
|
+
if (!Array.isArray(raw))
|
|
224
|
+
return out;
|
|
225
|
+
for (const w of raw) {
|
|
226
|
+
if (!isRecord(w) || typeof w.name !== "string" || !NAME_RE.test(w.name))
|
|
227
|
+
continue;
|
|
228
|
+
if (!Array.isArray(w.steps) || !w.steps.length)
|
|
229
|
+
continue;
|
|
230
|
+
const prompts = isRecord(w.prompts) ? w.prompts : {};
|
|
231
|
+
const steps = [];
|
|
232
|
+
for (const s of w.steps) {
|
|
233
|
+
if (typeof s !== "string" || !s.trim()) {
|
|
234
|
+
steps.length = 0;
|
|
235
|
+
break;
|
|
236
|
+
}
|
|
237
|
+
const t = s.trim();
|
|
238
|
+
if (t === "pr")
|
|
239
|
+
steps.push({ kind: "pr" });
|
|
240
|
+
else if (t.startsWith("gate:")) {
|
|
241
|
+
const gate = t.slice(5);
|
|
242
|
+
if (!NAME_RE.test(gate)) {
|
|
243
|
+
steps.length = 0;
|
|
244
|
+
break;
|
|
245
|
+
}
|
|
246
|
+
steps.push({ kind: "gate", gate });
|
|
247
|
+
} else if (NAME_RE.test(t)) {
|
|
248
|
+
const p = prompts[t];
|
|
249
|
+
steps.push({
|
|
250
|
+
kind: "run",
|
|
251
|
+
name: t,
|
|
252
|
+
prompt: typeof p === "string" && p.trim() ? p.trim() : null
|
|
253
|
+
});
|
|
254
|
+
} else {
|
|
255
|
+
steps.length = 0;
|
|
256
|
+
break;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
if (steps.length)
|
|
260
|
+
out[w.name] = { name: w.name, steps };
|
|
261
|
+
}
|
|
262
|
+
return out;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// packages/core/src/config.ts
|
|
205
266
|
var DEFAULT_GATE_TIMEOUT_S = 900;
|
|
206
267
|
var AUTO_MODES = ["session-end", "stop", "off"];
|
|
207
268
|
function parseGateDefs(gates) {
|
|
208
269
|
const out = {};
|
|
209
|
-
if (!
|
|
270
|
+
if (!isRecord2(gates))
|
|
210
271
|
return out;
|
|
211
272
|
for (const [name, v] of Object.entries(gates)) {
|
|
212
|
-
if (!
|
|
273
|
+
if (!isRecord2(v))
|
|
213
274
|
continue;
|
|
214
275
|
const builtin = v.builtin === "review" ? "review" : null;
|
|
215
276
|
const cmd = typeof v.cmd === "string" ? v.cmd.trim() : "";
|
|
@@ -232,7 +293,11 @@ var DEFAULT_CONFIG = {
|
|
|
232
293
|
daemon: { port: 7777, auth: "loopback-optional" },
|
|
233
294
|
tasks: { source: null, labels: [], team: null },
|
|
234
295
|
gates: { required: [], auto: "session-end", defs: {} },
|
|
296
|
+
workflows: {},
|
|
235
297
|
budget: { daily: null, weekly: null, warn_at: 0.8, on_exceed: "warn" },
|
|
298
|
+
models: { allow: [] },
|
|
299
|
+
notify: { webhook: null },
|
|
300
|
+
team: { url: null, forward: ["ledger", "cost"], interval: 5 },
|
|
236
301
|
events: { retain_days: 30 },
|
|
237
302
|
audit: { retain_days: 0 },
|
|
238
303
|
privacy: DEFAULT_PRIVACY,
|
|
@@ -256,11 +321,11 @@ var DEFAULT_CONFIG = {
|
|
|
256
321
|
}
|
|
257
322
|
};
|
|
258
323
|
var MODES = ["ask", "deny", "off"];
|
|
259
|
-
function
|
|
324
|
+
function isRecord2(v) {
|
|
260
325
|
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
261
326
|
}
|
|
262
327
|
function merge(a, b) {
|
|
263
|
-
if (!
|
|
328
|
+
if (!isRecord2(a) || !isRecord2(b))
|
|
264
329
|
return b === undefined ? a : b;
|
|
265
330
|
const out = { ...a };
|
|
266
331
|
for (const [k, v] of Object.entries(b))
|
|
@@ -327,6 +392,27 @@ function validate(c) {
|
|
|
327
392
|
warn_at: Number.isFinite(warnAt) && warnAt > 0 && warnAt < 1 ? warnAt : 0.8,
|
|
328
393
|
on_exceed: b.on_exceed === "ask" || b.on_exceed === "stop" ? b.on_exceed : "warn"
|
|
329
394
|
},
|
|
395
|
+
workflows: parseWorkflows(c.workflows),
|
|
396
|
+
notify: {
|
|
397
|
+
webhook: (() => {
|
|
398
|
+
const w = c.notify?.webhook;
|
|
399
|
+
return typeof w === "string" && /^https?:\/\//.test(w.trim()) ? w.trim() : null;
|
|
400
|
+
})()
|
|
401
|
+
},
|
|
402
|
+
models: {
|
|
403
|
+
allow: Array.isArray(c.models?.allow) ? c.models.allow.filter((m) => typeof m === "string" && m.trim() !== "") : []
|
|
404
|
+
},
|
|
405
|
+
team: (() => {
|
|
406
|
+
const t = c.team ?? {};
|
|
407
|
+
const url = typeof t.url === "string" && /^https?:\/\//.test(t.url.trim()) ? t.url.trim().replace(/\/+$/, "") : null;
|
|
408
|
+
const iv = Number(t.interval);
|
|
409
|
+
const KINDS = ["ledger", "cost", "transcripts"];
|
|
410
|
+
return {
|
|
411
|
+
url,
|
|
412
|
+
forward: Array.isArray(t.forward) ? t.forward.filter((k) => typeof k === "string" && KINDS.includes(k)) : ["ledger", "cost"],
|
|
413
|
+
interval: Number.isFinite(iv) && iv >= 1 && iv <= 300 ? Math.round(iv) : 5
|
|
414
|
+
};
|
|
415
|
+
})(),
|
|
330
416
|
events: {
|
|
331
417
|
retain_days: days(c.events?.retain_days, 30)
|
|
332
418
|
},
|
|
@@ -366,7 +452,7 @@ function validate(c) {
|
|
|
366
452
|
};
|
|
367
453
|
}
|
|
368
454
|
function leafPaths(v, prefix = "") {
|
|
369
|
-
if (!
|
|
455
|
+
if (!isRecord2(v))
|
|
370
456
|
return prefix ? [prefix] : [];
|
|
371
457
|
const keys = Object.keys(v);
|
|
372
458
|
if (keys.length === 0)
|
|
@@ -376,7 +462,7 @@ function leafPaths(v, prefix = "") {
|
|
|
376
462
|
function getPath(v, path) {
|
|
377
463
|
let cur = v;
|
|
378
464
|
for (const seg of path.split(".")) {
|
|
379
|
-
if (!
|
|
465
|
+
if (!isRecord2(cur))
|
|
380
466
|
return;
|
|
381
467
|
cur = cur[seg];
|
|
382
468
|
}
|
|
@@ -386,7 +472,7 @@ function setPath(obj, path, value) {
|
|
|
386
472
|
const segs = path.split(".");
|
|
387
473
|
let cur = obj;
|
|
388
474
|
for (const seg of segs.slice(0, -1)) {
|
|
389
|
-
if (!
|
|
475
|
+
if (!isRecord2(cur[seg]))
|
|
390
476
|
cur[seg] = {};
|
|
391
477
|
cur = cur[seg];
|
|
392
478
|
}
|
|
@@ -446,6 +532,8 @@ var LIVE_WINDOW_MS = 10 * 60000;
|
|
|
446
532
|
var WRITE_TOOLS = new Set(["Write", "Edit", "MultiEdit", "NotebookEdit"]);
|
|
447
533
|
// packages/core/src/ledger.ts
|
|
448
534
|
var EDIT_TOOLS = new Set(["Edit", "Write", "MultiEdit", "NotebookEdit"]);
|
|
535
|
+
// packages/core/src/outcomes.ts
|
|
536
|
+
var DEFAULT_BRANCHES = new Set(["main", "master", "develop", "trunk"]);
|
|
449
537
|
// packages/core/src/policy.ts
|
|
450
538
|
var HOOK_MARK = "swarm-hook";
|
|
451
539
|
var hookIsOurs = (h) => typeof h.command === "string" && (h.command.includes(HOOK_MARK) || h.command.includes("/packages/hook/src/bin.ts"));
|
|
@@ -702,6 +790,26 @@ function status() {
|
|
|
702
790
|
otherAgents
|
|
703
791
|
};
|
|
704
792
|
}
|
|
793
|
+
function setTeamUrl(home, url) {
|
|
794
|
+
const path = join3(home, "config.toml");
|
|
795
|
+
const text = existsSync4(path) ? readFileSync3(path, "utf8") : "";
|
|
796
|
+
let out;
|
|
797
|
+
const section = text.match(/(^|\n)\[team\]([\s\S]*?)(?=\n\[|$)/);
|
|
798
|
+
if (section) {
|
|
799
|
+
const body = section[2] ?? "";
|
|
800
|
+
const newBody = /^\s*url\s*=/m.test(body) ? body.replace(/^\s*url\s*=.*$/m, `url = "${url}"`) : `
|
|
801
|
+
url = "${url}"${body}`;
|
|
802
|
+
out = text.replace(section[0], `${section[1]}[team]${newBody}`);
|
|
803
|
+
} else {
|
|
804
|
+
out = `${text}${text && !text.endsWith(`
|
|
805
|
+
`) ? `
|
|
806
|
+
` : ""}
|
|
807
|
+
[team]
|
|
808
|
+
url = "${url}"
|
|
809
|
+
`;
|
|
810
|
+
}
|
|
811
|
+
writeFileSync2(path, out);
|
|
812
|
+
}
|
|
705
813
|
|
|
706
814
|
// packages/cli/src/procs.ts
|
|
707
815
|
import { mkdirSync as mkdirSync2, openSync } from "fs";
|
|
@@ -818,7 +926,7 @@ var help = `swarm \u2014 control plane for AI-agent development
|
|
|
818
926
|
setup start the daemon, install hooks, open the dashboard (do this first)
|
|
819
927
|
start | stop | restart manage the background daemon
|
|
820
928
|
status [-p] live sessions (whole machine, or one project)
|
|
821
|
-
doctor
|
|
929
|
+
doctor [--migrate] check everything and print the fix for each gap (--migrate: apply pending db migrations)
|
|
822
930
|
|
|
823
931
|
add <path> [--name n] register (pin) a project
|
|
824
932
|
ls list projects
|
|
@@ -851,9 +959,15 @@ var help = `swarm \u2014 control plane for AI-agent development
|
|
|
851
959
|
stats [-p] [--json] all-time totals, streak, records (the dashboard's Stats view)
|
|
852
960
|
search <query\u2026> [-p] [--kind handoff|incident|gate|session] [--json] memory over Swarm's own data (handoffs, incidents, gates, what sessions said)
|
|
853
961
|
rules dryrun [--set rule=mode,\u2026] [--limit n] [--json] replay this repo's history under rule modes; shows what would fire + flaky signals
|
|
962
|
+
workflow <name> <task> | workflow ls | workflow stop <task> run a [[workflows]] sequence on a task (M7.8)
|
|
963
|
+
msg send <to> <text\u2026> [-p] message a session id, a task's holder, or "lead" (M7.6)
|
|
964
|
+
msg ls [-p] [--json] recent messages
|
|
965
|
+
demo open a seeded demo dashboard (own home + port; your real data is untouched)
|
|
854
966
|
audit export [--since 30d|ISO] [-p] [--type claim.acquired] [--format jsonl|csv|json] [--limit n] the audit log (ledger changes + decisions, with actor) to stdout
|
|
967
|
+
login [url] [--token t] log in to the team daemon ([team].url) and register this machine (M8.3c)
|
|
968
|
+
backup [dest] | restore <src> snapshot ~/.swarm (VACUUM INTO, zero downtime) / restore it (daemon stopped)
|
|
855
969
|
|
|
856
|
-
install | uninstall add/remove Swarm hooks in ~/.claude/settings.json
|
|
970
|
+
install [--config-url <team url>] | uninstall add/remove Swarm hooks in ~/.claude/settings.json (--config-url also points this machine at a team daemon)
|
|
857
971
|
|
|
858
972
|
Env: SWARM_URL, SWARM_PORT (default 7777), SWARM_HOME (~/.swarm)`;
|
|
859
973
|
async function api(path, init) {
|
|
@@ -915,6 +1029,15 @@ Open the dashboard: ${base}`);
|
|
|
915
1029
|
case "install": {
|
|
916
1030
|
const evs = install();
|
|
917
1031
|
console.log(`installed hooks for ${evs.length} events in ${status().path}`);
|
|
1032
|
+
const cuIdx = rest.indexOf("--config-url");
|
|
1033
|
+
const configUrl = cuIdx >= 0 ? rest[cuIdx + 1] : null;
|
|
1034
|
+
if (configUrl) {
|
|
1035
|
+
if (!/^https?:\/\//.test(configUrl))
|
|
1036
|
+
throw new Error("--config-url must be an http(s) URL to the team daemon");
|
|
1037
|
+
setTeamUrl(swarmHome(), configUrl.replace(/\/+$/, ""));
|
|
1038
|
+
console.log(`[team] url = "${configUrl}" written to ${join5(swarmHome(), "config.toml")}`);
|
|
1039
|
+
console.log("next: swarm login \u2014 to register this machine and pin the org policy key");
|
|
1040
|
+
}
|
|
918
1041
|
console.log("restart any running claude session for it to report in.");
|
|
919
1042
|
break;
|
|
920
1043
|
}
|
|
@@ -922,6 +1045,8 @@ Open the dashboard: ${base}`);
|
|
|
922
1045
|
console.log(`removed ${uninstall()} hook entries`);
|
|
923
1046
|
break;
|
|
924
1047
|
case "doctor": {
|
|
1048
|
+
if (rest.includes("--migrate"))
|
|
1049
|
+
await ensureDaemon({ quiet: true });
|
|
925
1050
|
const st = status();
|
|
926
1051
|
const bun = Bun.which("bun");
|
|
927
1052
|
const claude = Bun.which("claude");
|
|
@@ -961,6 +1086,13 @@ Open the dashboard: ${base}`);
|
|
|
961
1086
|
};
|
|
962
1087
|
forge2("gh", ["auth", "status", "--active", "-h", "github.com"]);
|
|
963
1088
|
forge2("glab", ["auth", "status"]);
|
|
1089
|
+
if (running) {
|
|
1090
|
+
const t = await api("/v1/team").catch(() => null);
|
|
1091
|
+
if (t?.configured) {
|
|
1092
|
+
const lag = t.pending ? `${t.pending} pending${t.oldest ? ` since ${t.oldest}` : ""}` : "in sync";
|
|
1093
|
+
line(!t.lastError, `team forwarding \u2192 ${t.url} (${lag})`, `last error: ${t.lastError} \u2014 check the team daemon and [team].url`);
|
|
1094
|
+
}
|
|
1095
|
+
}
|
|
964
1096
|
if (process.env.GITLAB_TOKEN)
|
|
965
1097
|
console.log("\xB7 glab uses GITLAB_TOKEN from this shell \u2014 a daemon started by the desktop app won't see it; run `glab auth login` to store it instead");
|
|
966
1098
|
if (!running)
|
|
@@ -1660,6 +1792,191 @@ ${flag("--body") ?? d.body}`);
|
|
|
1660
1792
|
session ${h.sessionId}` : ""}`);
|
|
1661
1793
|
break;
|
|
1662
1794
|
}
|
|
1795
|
+
case "workflow": {
|
|
1796
|
+
await ensureDaemon({ quiet: true });
|
|
1797
|
+
const proj = await api("/v1/projects", {
|
|
1798
|
+
method: "POST",
|
|
1799
|
+
headers: { "content-type": "application/json" },
|
|
1800
|
+
body: JSON.stringify({ path: resolve3(".") })
|
|
1801
|
+
});
|
|
1802
|
+
if (rest[0] === "ls" || !rest[0]) {
|
|
1803
|
+
const w = await api(`/v1/workflows?project=${proj.id}`);
|
|
1804
|
+
const names = Object.keys(w.defs);
|
|
1805
|
+
console.log(names.length ? `declared: ${names.join(", ")}` : "no [[workflows]] declared in .swarm.toml");
|
|
1806
|
+
for (const r2 of w.runs.slice(0, 20))
|
|
1807
|
+
console.log(`#${r2.id} ${r2.task} \xB7 ${r2.workflow} \xB7 ${r2.state === "running" ? `${r2.stepLabel} (${r2.step + 1}/${r2.steps.length})` : r2.state}${r2.detail ? ` \u2014 ${r2.detail.slice(0, 80)}` : ""}`);
|
|
1808
|
+
break;
|
|
1809
|
+
}
|
|
1810
|
+
if (rest[0] === "stop") {
|
|
1811
|
+
const task2 = rest[1];
|
|
1812
|
+
if (!task2)
|
|
1813
|
+
throw new Error("usage: swarm workflow stop <task>");
|
|
1814
|
+
const r2 = await api("/v1/workflows/stop", {
|
|
1815
|
+
method: "POST",
|
|
1816
|
+
headers: { "content-type": "application/json" },
|
|
1817
|
+
body: JSON.stringify({ projectId: proj.id, task: task2 })
|
|
1818
|
+
});
|
|
1819
|
+
if (!r2.ok)
|
|
1820
|
+
throw new Error(r2.error ?? "stop failed");
|
|
1821
|
+
console.log(`stopped the workflow on ${task2}`);
|
|
1822
|
+
break;
|
|
1823
|
+
}
|
|
1824
|
+
const [name, task] = rest;
|
|
1825
|
+
if (!name || !task)
|
|
1826
|
+
throw new Error("usage: swarm workflow <name> <task>");
|
|
1827
|
+
const r = await api("/v1/workflows", {
|
|
1828
|
+
method: "POST",
|
|
1829
|
+
headers: { "content-type": "application/json" },
|
|
1830
|
+
body: JSON.stringify({
|
|
1831
|
+
projectId: proj.id,
|
|
1832
|
+
task,
|
|
1833
|
+
workflow: name,
|
|
1834
|
+
owner: process.env.USER ?? "cli"
|
|
1835
|
+
})
|
|
1836
|
+
});
|
|
1837
|
+
if (!r.ok)
|
|
1838
|
+
throw new Error(r.error ?? "workflow failed to start");
|
|
1839
|
+
console.log(`workflow ${name} started on ${task} (#${r.id}) \u2014 watch the Board`);
|
|
1840
|
+
break;
|
|
1841
|
+
}
|
|
1842
|
+
case "msg": {
|
|
1843
|
+
await ensureDaemon({ quiet: true });
|
|
1844
|
+
const proj = await api("/v1/projects", {
|
|
1845
|
+
method: "POST",
|
|
1846
|
+
headers: { "content-type": "application/json" },
|
|
1847
|
+
body: JSON.stringify({ path: resolve3(".") })
|
|
1848
|
+
});
|
|
1849
|
+
if (rest[0] === "send") {
|
|
1850
|
+
const [to, ...words] = rest.slice(1).filter((a) => a !== "-p");
|
|
1851
|
+
if (!to || !words.length)
|
|
1852
|
+
throw new Error('usage: swarm msg send <session|task|"lead"> <text\u2026>');
|
|
1853
|
+
const r = await api("/v1/messages", {
|
|
1854
|
+
method: "POST",
|
|
1855
|
+
headers: { "content-type": "application/json" },
|
|
1856
|
+
body: JSON.stringify({
|
|
1857
|
+
projectId: proj.id,
|
|
1858
|
+
to,
|
|
1859
|
+
text: words.join(" "),
|
|
1860
|
+
from: process.env.USER ?? "me"
|
|
1861
|
+
})
|
|
1862
|
+
});
|
|
1863
|
+
if (!r.ok)
|
|
1864
|
+
throw new Error(r.error ?? "send failed");
|
|
1865
|
+
console.log(`sent #${r.message?.id}${r.message?.sessionId ? "" : " (queued until the target appears)"}`);
|
|
1866
|
+
break;
|
|
1867
|
+
}
|
|
1868
|
+
if (rest[0] === "ls" || !rest[0]) {
|
|
1869
|
+
const ms = await api(`/v1/messages?project=${proj.id}&limit=50`);
|
|
1870
|
+
if (rest.includes("--json")) {
|
|
1871
|
+
console.log(JSON.stringify(ms, null, 2));
|
|
1872
|
+
break;
|
|
1873
|
+
}
|
|
1874
|
+
if (!ms.length) {
|
|
1875
|
+
console.log("no messages");
|
|
1876
|
+
break;
|
|
1877
|
+
}
|
|
1878
|
+
for (const m of ms)
|
|
1879
|
+
console.log(`#${m.id} ${m.deliveredAt ? "\u2713" : "\xB7"} ${m.from ?? "?"} \u2192 ${m.task ?? m.toKind}: ${m.text.slice(0, 100)}`);
|
|
1880
|
+
break;
|
|
1881
|
+
}
|
|
1882
|
+
throw new Error("usage: swarm msg send <to> <text\u2026> | swarm msg ls");
|
|
1883
|
+
}
|
|
1884
|
+
case "backup": {
|
|
1885
|
+
await ensureDaemon({ quiet: true });
|
|
1886
|
+
const dest = resolve3(arg() ?? join5(swarmHome(), "backups", new Date().toISOString().slice(0, 10)));
|
|
1887
|
+
const r = await api("/v1/backup", {
|
|
1888
|
+
method: "POST",
|
|
1889
|
+
headers: { "content-type": "application/json" },
|
|
1890
|
+
body: JSON.stringify({ dest })
|
|
1891
|
+
});
|
|
1892
|
+
console.log(`backed up ${r.files.join(", ")} \u2192 ${r.dest}`);
|
|
1893
|
+
console.log(`restore on any machine with: swarm restore ${r.dest} (daemon stopped)`);
|
|
1894
|
+
break;
|
|
1895
|
+
}
|
|
1896
|
+
case "restore": {
|
|
1897
|
+
const src = arg();
|
|
1898
|
+
if (!src)
|
|
1899
|
+
throw new Error("usage: swarm restore <backup dir> (with the daemon stopped)");
|
|
1900
|
+
if (await daemonRunning())
|
|
1901
|
+
throw new Error("the daemon is running \u2014 stop it first: swarm stop");
|
|
1902
|
+
const home = swarmHome();
|
|
1903
|
+
mkdirSync3(home, { recursive: true });
|
|
1904
|
+
let n = 0;
|
|
1905
|
+
for (const f of readdirSync(resolve3(src))) {
|
|
1906
|
+
if (f === "daemon.json")
|
|
1907
|
+
continue;
|
|
1908
|
+
copyFileSync(join5(resolve3(src), f), join5(home, f));
|
|
1909
|
+
n++;
|
|
1910
|
+
}
|
|
1911
|
+
for (const suffix of ["-wal", "-shm"]) {
|
|
1912
|
+
const p = join5(home, `swarm.db${suffix}`);
|
|
1913
|
+
if (existsSync5(p))
|
|
1914
|
+
unlinkSync(p);
|
|
1915
|
+
}
|
|
1916
|
+
console.log(`restored ${n} files into ${home} \u2014 run: swarm start`);
|
|
1917
|
+
break;
|
|
1918
|
+
}
|
|
1919
|
+
case "login": {
|
|
1920
|
+
await ensureDaemon({ quiet: true });
|
|
1921
|
+
const t = await api("/v1/team");
|
|
1922
|
+
const teamUrl = (arg() ?? t.url)?.replace(/\/+$/, "");
|
|
1923
|
+
if (!teamUrl)
|
|
1924
|
+
throw new Error("no team daemon configured \u2014 set [team] url or run: swarm login <url>");
|
|
1925
|
+
const cfg = await (await authedFetch(`${teamUrl}/t1/auth/config`)).json();
|
|
1926
|
+
const tokIdx = rest.indexOf("--token");
|
|
1927
|
+
let human = null;
|
|
1928
|
+
if (cfg.mode === "oidc") {
|
|
1929
|
+
const flow = await (await authedFetch(`${teamUrl}/t1/auth/device`, { method: "POST" })).json();
|
|
1930
|
+
if (!flow.handle)
|
|
1931
|
+
throw new Error(`login failed: ${flow.error ?? "no device flow"}`);
|
|
1932
|
+
console.log(`visit ${flow.verificationUriComplete ?? flow.verificationUri} and enter code: ${flow.userCode}`);
|
|
1933
|
+
for (;; ) {
|
|
1934
|
+
await new Promise((r) => setTimeout(r, (flow.interval ?? 5) * 1000));
|
|
1935
|
+
const poll = await (await authedFetch(`${teamUrl}/t1/auth/token`, {
|
|
1936
|
+
method: "POST",
|
|
1937
|
+
headers: { "content-type": "application/json" },
|
|
1938
|
+
body: JSON.stringify({ handle: flow.handle })
|
|
1939
|
+
})).json();
|
|
1940
|
+
if (poll.status === "pending")
|
|
1941
|
+
continue;
|
|
1942
|
+
if (poll.status !== "ok" || !poll.token)
|
|
1943
|
+
throw new Error(`login failed: ${poll.error ?? "denied"}`);
|
|
1944
|
+
human = poll.token;
|
|
1945
|
+
writeFileSync3(join5(swarmHome(), "team-token"), JSON.stringify({
|
|
1946
|
+
url: teamUrl,
|
|
1947
|
+
token: poll.token,
|
|
1948
|
+
subject: poll.subject,
|
|
1949
|
+
role: poll.role
|
|
1950
|
+
}), { mode: 384 });
|
|
1951
|
+
console.log(`logged in as ${poll.subject} (${poll.role})`);
|
|
1952
|
+
break;
|
|
1953
|
+
}
|
|
1954
|
+
} else if (cfg.mode === "token") {
|
|
1955
|
+
human = tokIdx >= 0 ? rest[tokIdx + 1] ?? null : null;
|
|
1956
|
+
if (!human)
|
|
1957
|
+
throw new Error("this team daemon uses a shared token \u2014 run: swarm login --token <secret>");
|
|
1958
|
+
} else {
|
|
1959
|
+
console.log("team daemon runs open (no auth configured) \u2014 registering machine directly");
|
|
1960
|
+
}
|
|
1961
|
+
const reg = await (await authedFetch(`${teamUrl}/t1/machines/register`, {
|
|
1962
|
+
method: "POST",
|
|
1963
|
+
headers: {
|
|
1964
|
+
"content-type": "application/json",
|
|
1965
|
+
...human ? { authorization: `Bearer ${human}` } : {}
|
|
1966
|
+
},
|
|
1967
|
+
body: JSON.stringify({ id: t.machine.id, name: t.machine.name })
|
|
1968
|
+
})).json();
|
|
1969
|
+
const machineToken = reg.token ?? (cfg.mode === "token" ? human : null);
|
|
1970
|
+
if (!machineToken)
|
|
1971
|
+
throw new Error(`machine registration failed: ${reg.error ?? "unknown"}`);
|
|
1972
|
+
await api("/v1/team/credentials", {
|
|
1973
|
+
method: "POST",
|
|
1974
|
+
headers: { "content-type": "application/json" },
|
|
1975
|
+
body: JSON.stringify({ token: machineToken, policyPublicKey: cfg.policyPublicKey })
|
|
1976
|
+
});
|
|
1977
|
+
console.log(`machine ${t.machine.name} (${t.machine.id.slice(0, 8)}) registered \u2014 forwarding is authed`);
|
|
1978
|
+
break;
|
|
1979
|
+
}
|
|
1663
1980
|
case "audit": {
|
|
1664
1981
|
if (rest[0] !== "export")
|
|
1665
1982
|
throw new Error("usage: swarm audit export [--since 30d] [-p] [--type t] [--format jsonl|csv|json] [--limit n]");
|
|
@@ -2004,6 +2321,33 @@ would have fired (newest last):`);
|
|
|
2004
2321
|
}
|
|
2005
2322
|
break;
|
|
2006
2323
|
}
|
|
2324
|
+
case "demo": {
|
|
2325
|
+
const home = join5(swarmHome(), "demo");
|
|
2326
|
+
const port = "7799";
|
|
2327
|
+
const [cmd2, ...args] = daemonCommand();
|
|
2328
|
+
if (!cmd2)
|
|
2329
|
+
throw new Error("could not resolve the daemon command");
|
|
2330
|
+
const env = { ...process.env, SWARM_HOME: home, SWARM_PORT: port, SWARM_DEMO: "1" };
|
|
2331
|
+
const up = await authedFetch(`http://127.0.0.1:${port}/v1/health`).then((r) => r.ok).catch(() => false);
|
|
2332
|
+
if (!up) {
|
|
2333
|
+
Bun.spawn([cmd2, ...args], {
|
|
2334
|
+
stdin: "ignore",
|
|
2335
|
+
stdout: "ignore",
|
|
2336
|
+
stderr: "ignore",
|
|
2337
|
+
env
|
|
2338
|
+
}).unref();
|
|
2339
|
+
for (let i = 0;i < 40; i++) {
|
|
2340
|
+
await new Promise((r) => setTimeout(r, 250));
|
|
2341
|
+
if (await authedFetch(`http://127.0.0.1:${port}/v1/health`).then((r) => r.ok).catch(() => false))
|
|
2342
|
+
break;
|
|
2343
|
+
}
|
|
2344
|
+
}
|
|
2345
|
+
const tok = readToken(home);
|
|
2346
|
+
const url = `http://127.0.0.1:${port}/${tok ? `?token=${tok}` : ""}`;
|
|
2347
|
+
Bun.spawn(["open", url]).unref?.();
|
|
2348
|
+
console.log(`demo dashboard: http://127.0.0.1:${port} (home ${home} \u2014 delete it to reset)`);
|
|
2349
|
+
break;
|
|
2350
|
+
}
|
|
2007
2351
|
case "ui": {
|
|
2008
2352
|
const base = await ensureDaemon();
|
|
2009
2353
|
const tok = readToken();
|