@ra3orblade/swarm 0.6.0 → 0.8.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 +63 -10
- package/dist/swarm-hook.js +281 -1
- package/dist/swarm-mcp.js +140 -3
- package/dist/swarm.js +869 -36
- package/dist/swarmd.js +2821 -349
- package/package.json +1 -1
- package/web/app.js +601 -97
- package/web/index.html +73 -3
- package/web/menus.js +1 -1
- package/web/release-notes.js +1 -1
- package/web/table.js +1 -1
package/dist/swarmd.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// @bun
|
|
3
3
|
|
|
4
4
|
// packages/client/src/daemon.ts
|
|
5
|
+
import { randomBytes } from "crypto";
|
|
5
6
|
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "fs";
|
|
6
7
|
import { homedir } from "os";
|
|
7
8
|
import { join } from "path";
|
|
@@ -18,9 +19,65 @@ function writeDaemonInfo(info) {
|
|
|
18
19
|
`);
|
|
19
20
|
return full;
|
|
20
21
|
}
|
|
22
|
+
var tokenFile = (home = swarmHome()) => join(home, "token");
|
|
23
|
+
function readToken(home = swarmHome()) {
|
|
24
|
+
try {
|
|
25
|
+
const t = readFileSync(tokenFile(home), "utf8").trim();
|
|
26
|
+
return /^[a-f0-9]{64}$/.test(t) ? t : null;
|
|
27
|
+
} catch {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
function ensureToken(home = swarmHome()) {
|
|
32
|
+
const cur = readToken(home);
|
|
33
|
+
if (cur)
|
|
34
|
+
return cur;
|
|
35
|
+
mkdirSync(home, { recursive: true });
|
|
36
|
+
const t = randomBytes(32).toString("hex");
|
|
37
|
+
writeFileSync(tokenFile(home), `${t}
|
|
38
|
+
`, { mode: 384 });
|
|
39
|
+
return t;
|
|
40
|
+
}
|
|
21
41
|
function clearDaemonInfo() {
|
|
22
42
|
rmSync(infoFile(), { force: true });
|
|
23
43
|
}
|
|
44
|
+
// packages/core/src/actor.ts
|
|
45
|
+
var HUMAN_ALIASES = new Set(["cli", "dashboard", "me", "desktop", "human"]);
|
|
46
|
+
var DAEMON_ALIASES = new Set(["daemon", "system", "swarm"]);
|
|
47
|
+
function actorFrom(owner, sessionId, opts = {}) {
|
|
48
|
+
const o = (owner ?? "").trim();
|
|
49
|
+
const sid = sessionId?.trim() || undefined;
|
|
50
|
+
if (opts.runId)
|
|
51
|
+
return { kind: "run", id: opts.runId, session: sid };
|
|
52
|
+
if (o.startsWith("auto:"))
|
|
53
|
+
return { kind: "daemon", id: "daemon", session: sid };
|
|
54
|
+
if (DAEMON_ALIASES.has(o))
|
|
55
|
+
return { kind: "daemon", id: "daemon" };
|
|
56
|
+
if (o === "agent" || o.startsWith("agent:") || o.startsWith("session:"))
|
|
57
|
+
return {
|
|
58
|
+
kind: "agent",
|
|
59
|
+
id: sid ?? o.replace(/^(agent|session):/, "") ?? "agent",
|
|
60
|
+
session: sid
|
|
61
|
+
};
|
|
62
|
+
if (!o || HUMAN_ALIASES.has(o)) {
|
|
63
|
+
if (!o && sid)
|
|
64
|
+
return { kind: "agent", id: sid, session: sid };
|
|
65
|
+
return { kind: "human", id: opts.user?.trim() || "me" };
|
|
66
|
+
}
|
|
67
|
+
if (sid && o === sid)
|
|
68
|
+
return { kind: "agent", id: sid, session: sid };
|
|
69
|
+
return { kind: "human", id: o };
|
|
70
|
+
}
|
|
71
|
+
function actorFromColumns(kind, id, session) {
|
|
72
|
+
if (!kind || !id)
|
|
73
|
+
return null;
|
|
74
|
+
if (!["human", "agent", "run", "daemon"].includes(kind))
|
|
75
|
+
return null;
|
|
76
|
+
const a = { kind, id };
|
|
77
|
+
if (session && kind !== "human" && kind !== "daemon")
|
|
78
|
+
a.session = session;
|
|
79
|
+
return a;
|
|
80
|
+
}
|
|
24
81
|
// packages/core/src/adapters/claude-code/transcript.ts
|
|
25
82
|
function parseTranscriptChunk(chunk) {
|
|
26
83
|
const out = {
|
|
@@ -229,6 +286,18 @@ function parseGrokUpdates(chunk) {
|
|
|
229
286
|
return out;
|
|
230
287
|
}
|
|
231
288
|
// packages/core/src/adapters/claude-code/hooks.ts
|
|
289
|
+
var HOOK_EVENTS = [
|
|
290
|
+
"SessionStart",
|
|
291
|
+
"UserPromptSubmit",
|
|
292
|
+
"PreToolUse",
|
|
293
|
+
"PostToolUse",
|
|
294
|
+
"SubagentStart",
|
|
295
|
+
"SubagentStop",
|
|
296
|
+
"Stop",
|
|
297
|
+
"SessionEnd",
|
|
298
|
+
"Notification",
|
|
299
|
+
"PreCompact"
|
|
300
|
+
];
|
|
232
301
|
var MAP = {
|
|
233
302
|
SessionStart: "session.started",
|
|
234
303
|
UserPromptSubmit: "prompt.submitted",
|
|
@@ -322,13 +391,254 @@ function normalizeHook(event, raw, projectId, ts = new Date().toISOString()) {
|
|
|
322
391
|
payload.prompt = raw.prompt;
|
|
323
392
|
return { ts, type, projectId, sessionId: raw.session_id ?? null, payload, raw };
|
|
324
393
|
}
|
|
394
|
+
// packages/core/src/audit.ts
|
|
395
|
+
var AUDIT_TYPES = new Set([
|
|
396
|
+
"session.started",
|
|
397
|
+
"session.ended",
|
|
398
|
+
"tool.denied",
|
|
399
|
+
"claim.acquired",
|
|
400
|
+
"claim.renewed",
|
|
401
|
+
"claim.released",
|
|
402
|
+
"claim.expired",
|
|
403
|
+
"claim.orphaned",
|
|
404
|
+
"worktree.created",
|
|
405
|
+
"worktree.removed",
|
|
406
|
+
"worktree.bootstrapped",
|
|
407
|
+
"pr.opened",
|
|
408
|
+
"question.asked",
|
|
409
|
+
"question.answered",
|
|
410
|
+
"dispatch.queued",
|
|
411
|
+
"dispatch.started",
|
|
412
|
+
"dispatch.finished",
|
|
413
|
+
"resource.acquired",
|
|
414
|
+
"resource.released",
|
|
415
|
+
"resource.reaped",
|
|
416
|
+
"process.started",
|
|
417
|
+
"process.exited",
|
|
418
|
+
"gate.recorded",
|
|
419
|
+
"handoff.recorded",
|
|
420
|
+
"permission.requested",
|
|
421
|
+
"permission.resolved",
|
|
422
|
+
"incident.opened",
|
|
423
|
+
"incident.acked",
|
|
424
|
+
"run.result"
|
|
425
|
+
]);
|
|
426
|
+
var isAuditType = (t) => AUDIT_TYPES.has(t);
|
|
427
|
+
var AUDIT_TYPES_SQL = [...AUDIT_TYPES].map((t) => `'${t}'`).join(", ");
|
|
428
|
+
var DEFAULT_PRIVACY = {
|
|
429
|
+
store_prompts: true,
|
|
430
|
+
store_reasoning: true,
|
|
431
|
+
redact: []
|
|
432
|
+
};
|
|
433
|
+
var BUILTIN_REDACT = [
|
|
434
|
+
/\b(sk|pk|rk|ghp|gho|ghu|ghs|xoxb|xoxp|AKIA)[A-Za-z0-9_-]{16,}\b/g,
|
|
435
|
+
/\bBearer\s+[A-Za-z0-9._-]{20,}/g
|
|
436
|
+
];
|
|
437
|
+
function compileRedactions(patterns) {
|
|
438
|
+
const out = [...BUILTIN_REDACT];
|
|
439
|
+
for (const p of patterns) {
|
|
440
|
+
try {
|
|
441
|
+
out.push(new RegExp(p, "g"));
|
|
442
|
+
} catch {}
|
|
443
|
+
}
|
|
444
|
+
return out;
|
|
445
|
+
}
|
|
446
|
+
function redactValue(v, res) {
|
|
447
|
+
if (!res.length)
|
|
448
|
+
return v;
|
|
449
|
+
if (typeof v === "string")
|
|
450
|
+
return redactString(v, res);
|
|
451
|
+
if (Array.isArray(v)) {
|
|
452
|
+
let changed = false;
|
|
453
|
+
const out = v.map((x) => {
|
|
454
|
+
const r = redactValue(x, res);
|
|
455
|
+
if (r !== x)
|
|
456
|
+
changed = true;
|
|
457
|
+
return r;
|
|
458
|
+
});
|
|
459
|
+
return changed ? out : v;
|
|
460
|
+
}
|
|
461
|
+
if (v && typeof v === "object") {
|
|
462
|
+
let changed = false;
|
|
463
|
+
const out = {};
|
|
464
|
+
for (const [k, x] of Object.entries(v)) {
|
|
465
|
+
const r = redactValue(x, res);
|
|
466
|
+
if (r !== x)
|
|
467
|
+
changed = true;
|
|
468
|
+
out[k] = r;
|
|
469
|
+
}
|
|
470
|
+
return changed ? out : v;
|
|
471
|
+
}
|
|
472
|
+
return v;
|
|
473
|
+
}
|
|
474
|
+
function redactString(s, res) {
|
|
475
|
+
let out = s;
|
|
476
|
+
for (const re of res) {
|
|
477
|
+
re.lastIndex = 0;
|
|
478
|
+
if (re.test(out)) {
|
|
479
|
+
re.lastIndex = 0;
|
|
480
|
+
out = out.replace(re, "[redacted]");
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
return out;
|
|
484
|
+
}
|
|
485
|
+
var AUDIT_COLUMNS = [
|
|
486
|
+
"seq",
|
|
487
|
+
"ts",
|
|
488
|
+
"type",
|
|
489
|
+
"projectId",
|
|
490
|
+
"sessionId",
|
|
491
|
+
"actorKind",
|
|
492
|
+
"actorId",
|
|
493
|
+
"summary",
|
|
494
|
+
"payload"
|
|
495
|
+
];
|
|
496
|
+
function auditRow(e) {
|
|
497
|
+
const p = e.payload ?? {};
|
|
498
|
+
const summary = typeof p.summary === "string" ? p.summary : typeof p.reason === "string" ? p.reason : typeof p.command === "string" ? p.command : typeof p.task === "string" ? String(p.task) : "";
|
|
499
|
+
return {
|
|
500
|
+
seq: e.seq ?? 0,
|
|
501
|
+
ts: e.ts,
|
|
502
|
+
type: e.type,
|
|
503
|
+
projectId: e.projectId,
|
|
504
|
+
sessionId: e.sessionId,
|
|
505
|
+
actorKind: e.actor?.kind ?? null,
|
|
506
|
+
actorId: e.actor?.id ?? null,
|
|
507
|
+
summary: summary.slice(0, 400),
|
|
508
|
+
payload: e.payload ?? null
|
|
509
|
+
};
|
|
510
|
+
}
|
|
511
|
+
var csvCell = (v) => {
|
|
512
|
+
const s = v == null ? "" : typeof v === "string" ? v : JSON.stringify(v);
|
|
513
|
+
return /[",\n\r]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
|
|
514
|
+
};
|
|
515
|
+
function formatAudit(rows, format) {
|
|
516
|
+
if (format === "json")
|
|
517
|
+
return JSON.stringify(rows);
|
|
518
|
+
if (format === "csv") {
|
|
519
|
+
const lines = [
|
|
520
|
+
AUDIT_COLUMNS.join(","),
|
|
521
|
+
...rows.map((r) => AUDIT_COLUMNS.map((c) => csvCell(r[c])).join(","))
|
|
522
|
+
];
|
|
523
|
+
return `${lines.join(`
|
|
524
|
+
`)}
|
|
525
|
+
`;
|
|
526
|
+
}
|
|
527
|
+
return `${rows.map((r) => JSON.stringify(r)).join(`
|
|
528
|
+
`)}${rows.length ? `
|
|
529
|
+
` : ""}`;
|
|
530
|
+
}
|
|
531
|
+
function sinceToIso(since, now = Date.now()) {
|
|
532
|
+
if (!since)
|
|
533
|
+
return null;
|
|
534
|
+
const m = /^(\d+)([dhm])$/.exec(since.trim());
|
|
535
|
+
if (m) {
|
|
536
|
+
const n = Number(m[1]);
|
|
537
|
+
const ms = m[2] === "d" ? 86400000 : m[2] === "h" ? 3600000 : 60000;
|
|
538
|
+
return new Date(now - n * ms).toISOString();
|
|
539
|
+
}
|
|
540
|
+
const t = Date.parse(since);
|
|
541
|
+
return Number.isNaN(t) ? null : new Date(t).toISOString();
|
|
542
|
+
}
|
|
543
|
+
// packages/core/src/budget.ts
|
|
544
|
+
function budgetStatus(spent, cfg) {
|
|
545
|
+
const part = (s, l) => ({
|
|
546
|
+
spent: s,
|
|
547
|
+
limit: l,
|
|
548
|
+
pct: l && l > 0 ? s / l : 0
|
|
549
|
+
});
|
|
550
|
+
const daily = part(spent.today, cfg.daily);
|
|
551
|
+
const weekly = part(spent.week, cfg.weekly);
|
|
552
|
+
const candidates = [
|
|
553
|
+
["daily", daily],
|
|
554
|
+
["weekly", weekly]
|
|
555
|
+
];
|
|
556
|
+
let kind = null;
|
|
557
|
+
let top = { spent: 0, limit: null, pct: 0 };
|
|
558
|
+
for (const [k, v] of candidates)
|
|
559
|
+
if (v.limit && v.pct >= top.pct)
|
|
560
|
+
({ kind, top } = { kind: k, top: v });
|
|
561
|
+
const level = !kind ? "ok" : top.pct >= 1 ? "exceeded" : top.pct >= cfg.warn_at ? "warn" : "ok";
|
|
562
|
+
return { level, kind, spent: top.spent, limit: top.limit, pct: top.pct, daily, weekly };
|
|
563
|
+
}
|
|
564
|
+
var BUDGET_ASK_TOOLS = new Set(["Bash", "Edit", "Write", "MultiEdit", "NotebookEdit"]);
|
|
565
|
+
function budgetMessage(s, project) {
|
|
566
|
+
const usd = (n) => `$${n.toFixed(2)}`;
|
|
567
|
+
if (s.level === "ok" || !s.limit)
|
|
568
|
+
return `${project}: within budget`;
|
|
569
|
+
return `${project} has spent ${usd(s.spent)} of its ${usd(s.limit)} ${s.kind} budget (${Math.round(s.pct * 100)}%)`;
|
|
570
|
+
}
|
|
571
|
+
var RUN_PROFILES = {
|
|
572
|
+
full: {
|
|
573
|
+
name: "full",
|
|
574
|
+
description: "every tool, rules still apply",
|
|
575
|
+
disallowedTools: [],
|
|
576
|
+
allowedTools: []
|
|
577
|
+
},
|
|
578
|
+
"no-edits": {
|
|
579
|
+
name: "no-edits",
|
|
580
|
+
description: "may run commands, may not edit files (review, triage, test runs)",
|
|
581
|
+
disallowedTools: ["Edit", "Write", "MultiEdit", "NotebookEdit"],
|
|
582
|
+
allowedTools: []
|
|
583
|
+
},
|
|
584
|
+
"read-only": {
|
|
585
|
+
name: "read-only",
|
|
586
|
+
description: "read and search only \u2014 no edits, no shell",
|
|
587
|
+
disallowedTools: ["Edit", "Write", "MultiEdit", "NotebookEdit", "Bash"],
|
|
588
|
+
allowedTools: ["Read", "Grep", "Glob", "LS", "WebFetch", "WebSearch"]
|
|
589
|
+
}
|
|
590
|
+
};
|
|
591
|
+
function runProfile(name) {
|
|
592
|
+
if (!name)
|
|
593
|
+
return null;
|
|
594
|
+
return RUN_PROFILES[name] ?? null;
|
|
595
|
+
}
|
|
325
596
|
// packages/core/src/config.ts
|
|
326
597
|
import { existsSync as existsSync2, readFileSync as readFileSync2 } from "fs";
|
|
327
598
|
import { join as join2 } from "path";
|
|
599
|
+
var DEFAULT_GATE_TIMEOUT_S = 900;
|
|
600
|
+
var AUTO_MODES = ["session-end", "stop", "off"];
|
|
601
|
+
function parseGateDefs(gates) {
|
|
602
|
+
const out = {};
|
|
603
|
+
if (!isRecord(gates))
|
|
604
|
+
return out;
|
|
605
|
+
for (const [name, v] of Object.entries(gates)) {
|
|
606
|
+
if (!isRecord(v))
|
|
607
|
+
continue;
|
|
608
|
+
const builtin = v.builtin === "review" ? "review" : null;
|
|
609
|
+
const cmd = typeof v.cmd === "string" ? v.cmd.trim() : "";
|
|
610
|
+
if (!cmd && !builtin)
|
|
611
|
+
continue;
|
|
612
|
+
if (!/^[a-z0-9][a-z0-9_.-]{0,39}$/i.test(name))
|
|
613
|
+
continue;
|
|
614
|
+
const t = Number(v.timeout);
|
|
615
|
+
out[name] = {
|
|
616
|
+
cmd: builtin ? "" : cmd,
|
|
617
|
+
timeout: Number.isFinite(t) && t > 0 ? Math.min(t, 86400) : builtin ? 600 : DEFAULT_GATE_TIMEOUT_S,
|
|
618
|
+
cwd: isRepoRelative(v.cwd) ? v.cwd.trim() : null,
|
|
619
|
+
builtin,
|
|
620
|
+
model: typeof v.model === "string" && v.model.trim() ? v.model.trim() : null
|
|
621
|
+
};
|
|
622
|
+
}
|
|
623
|
+
return out;
|
|
624
|
+
}
|
|
328
625
|
var DEFAULT_CONFIG = {
|
|
329
|
-
daemon: { port: 7777 },
|
|
626
|
+
daemon: { port: 7777, auth: "loopback-optional" },
|
|
330
627
|
tasks: { source: null, labels: [], team: null },
|
|
331
|
-
gates: { required: [] },
|
|
628
|
+
gates: { required: [], auto: "session-end", defs: {} },
|
|
629
|
+
budget: { daily: null, weekly: null, warn_at: 0.8, on_exceed: "warn" },
|
|
630
|
+
events: { retain_days: 30 },
|
|
631
|
+
audit: { retain_days: 0 },
|
|
632
|
+
privacy: DEFAULT_PRIVACY,
|
|
633
|
+
dispatch: {
|
|
634
|
+
max_parallel: 2,
|
|
635
|
+
permission_mode: null,
|
|
636
|
+
model: null,
|
|
637
|
+
max_turns: null,
|
|
638
|
+
require_pr: true,
|
|
639
|
+
profile: null
|
|
640
|
+
},
|
|
641
|
+
worktree: { setup: null, copy: [], open: null },
|
|
332
642
|
rules: {
|
|
333
643
|
shared_tree: "ask",
|
|
334
644
|
destructive_git: "ask",
|
|
@@ -359,18 +669,82 @@ function parseToml(text, source) {
|
|
|
359
669
|
return {};
|
|
360
670
|
}
|
|
361
671
|
}
|
|
672
|
+
function isRepoRelative(f) {
|
|
673
|
+
if (typeof f !== "string")
|
|
674
|
+
return false;
|
|
675
|
+
const t = f.trim();
|
|
676
|
+
if (!t || t.startsWith("/") || t.startsWith("\\") || /^[a-zA-Z]:/.test(t))
|
|
677
|
+
return false;
|
|
678
|
+
return !t.split(/[/\\]/).some((seg) => seg === "..");
|
|
679
|
+
}
|
|
680
|
+
var days = (v, fallback) => {
|
|
681
|
+
const n = Number(v);
|
|
682
|
+
return Number.isInteger(n) && n >= 0 ? Math.min(n, 3650) : fallback;
|
|
683
|
+
};
|
|
362
684
|
function validate(c) {
|
|
363
685
|
const mode = (v, fallback) => MODES.includes(v) ? v : fallback;
|
|
364
686
|
const port = Number(c.daemon?.port);
|
|
365
687
|
const source = c.tasks?.source;
|
|
688
|
+
const setup = c.worktree?.setup;
|
|
689
|
+
const opener = c.worktree?.open;
|
|
690
|
+
const rawGates = c.gates;
|
|
691
|
+
const d = c.dispatch ?? {};
|
|
692
|
+
const mp = Number(d.max_parallel);
|
|
693
|
+
const mt = Number(d.max_turns);
|
|
694
|
+
const str = (v) => typeof v === "string" && v.trim() ? v.trim() : null;
|
|
695
|
+
const b = c.budget ?? {};
|
|
696
|
+
const usd = (v) => {
|
|
697
|
+
const n = Number(v);
|
|
698
|
+
return Number.isFinite(n) && n > 0 ? n : null;
|
|
699
|
+
};
|
|
700
|
+
const warnAt = Number(b.warn_at);
|
|
701
|
+
const auto = rawGates?.auto;
|
|
366
702
|
return {
|
|
367
703
|
...c,
|
|
368
|
-
daemon: {
|
|
704
|
+
daemon: {
|
|
705
|
+
port: Number.isInteger(port) && port > 0 && port < 65536 ? port : 7777,
|
|
706
|
+
auth: c.daemon?.auth === "required" ? "required" : "loopback-optional"
|
|
707
|
+
},
|
|
369
708
|
tasks: {
|
|
370
709
|
source: typeof source === "string" && source.trim() && !source.startsWith("/") ? source.trim() : null,
|
|
371
710
|
labels: Array.isArray(c.tasks?.labels) ? c.tasks.labels.filter((l) => typeof l === "string" && l.trim() !== "") : [],
|
|
372
711
|
team: typeof c.tasks?.team === "string" && c.tasks.team.trim() ? c.tasks.team.trim() : null
|
|
373
712
|
},
|
|
713
|
+
gates: {
|
|
714
|
+
required: Array.isArray(rawGates?.required) ? rawGates.required.filter((g) => typeof g === "string" && g.trim() !== "") : [],
|
|
715
|
+
auto: AUTO_MODES.includes(auto) ? auto : "session-end",
|
|
716
|
+
defs: parseGateDefs(rawGates)
|
|
717
|
+
},
|
|
718
|
+
budget: {
|
|
719
|
+
daily: usd(b.daily),
|
|
720
|
+
weekly: usd(b.weekly),
|
|
721
|
+
warn_at: Number.isFinite(warnAt) && warnAt > 0 && warnAt < 1 ? warnAt : 0.8,
|
|
722
|
+
on_exceed: b.on_exceed === "ask" || b.on_exceed === "stop" ? b.on_exceed : "warn"
|
|
723
|
+
},
|
|
724
|
+
events: {
|
|
725
|
+
retain_days: days(c.events?.retain_days, 30)
|
|
726
|
+
},
|
|
727
|
+
audit: {
|
|
728
|
+
retain_days: days(c.audit?.retain_days, 0)
|
|
729
|
+
},
|
|
730
|
+
privacy: {
|
|
731
|
+
store_prompts: c.privacy?.store_prompts !== false,
|
|
732
|
+
store_reasoning: c.privacy?.store_reasoning !== false,
|
|
733
|
+
redact: Array.isArray(c.privacy?.redact) ? c.privacy.redact.filter((r) => typeof r === "string" && r.length > 0) : []
|
|
734
|
+
},
|
|
735
|
+
dispatch: {
|
|
736
|
+
max_parallel: Number.isInteger(mp) && mp > 0 ? Math.min(mp, 16) : 2,
|
|
737
|
+
permission_mode: str(d.permission_mode),
|
|
738
|
+
model: str(d.model),
|
|
739
|
+
max_turns: Number.isInteger(mt) && mt > 0 ? mt : null,
|
|
740
|
+
require_pr: d.require_pr === undefined ? true : d.require_pr === true,
|
|
741
|
+
profile: ["full", "no-edits", "read-only"].includes(String(d.profile)) ? String(d.profile) : null
|
|
742
|
+
},
|
|
743
|
+
worktree: {
|
|
744
|
+
setup: typeof setup === "string" && setup.trim() ? setup.trim() : null,
|
|
745
|
+
copy: Array.isArray(c.worktree?.copy) ? c.worktree.copy.filter((f) => isRepoRelative(f)) : [],
|
|
746
|
+
open: typeof opener === "string" && opener.trim() ? opener.trim() : null
|
|
747
|
+
},
|
|
374
748
|
rules: {
|
|
375
749
|
...c.rules,
|
|
376
750
|
shared_tree: mode(c.rules?.shared_tree, "ask"),
|
|
@@ -385,18 +759,149 @@ function validate(c) {
|
|
|
385
759
|
}
|
|
386
760
|
};
|
|
387
761
|
}
|
|
388
|
-
function
|
|
762
|
+
function leafPaths(v, prefix = "") {
|
|
763
|
+
if (!isRecord(v))
|
|
764
|
+
return prefix ? [prefix] : [];
|
|
765
|
+
const keys = Object.keys(v);
|
|
766
|
+
if (keys.length === 0)
|
|
767
|
+
return prefix ? [prefix] : [];
|
|
768
|
+
return keys.flatMap((k) => leafPaths(v[k], prefix ? `${prefix}.${k}` : k));
|
|
769
|
+
}
|
|
770
|
+
function getPath(v, path) {
|
|
771
|
+
let cur = v;
|
|
772
|
+
for (const seg of path.split(".")) {
|
|
773
|
+
if (!isRecord(cur))
|
|
774
|
+
return;
|
|
775
|
+
cur = cur[seg];
|
|
776
|
+
}
|
|
777
|
+
return cur;
|
|
778
|
+
}
|
|
779
|
+
function setPath(obj, path, value) {
|
|
780
|
+
const segs = path.split(".");
|
|
781
|
+
let cur = obj;
|
|
782
|
+
for (const seg of segs.slice(0, -1)) {
|
|
783
|
+
if (!isRecord(cur[seg]))
|
|
784
|
+
cur[seg] = {};
|
|
785
|
+
cur = cur[seg];
|
|
786
|
+
}
|
|
787
|
+
cur[segs[segs.length - 1]] = value;
|
|
788
|
+
}
|
|
789
|
+
var isLockedBy = (path, lock) => path === lock || path.startsWith(`${lock}.`);
|
|
790
|
+
function readLayer(path) {
|
|
791
|
+
return existsSync2(path) ? parseToml(readFileSync2(path, "utf8"), path) : null;
|
|
792
|
+
}
|
|
793
|
+
function loadConfigDetailed(opts = {}) {
|
|
389
794
|
const home = opts.home ?? process.env.SWARM_HOME ?? join2(process.env.HOME ?? "", ".swarm");
|
|
795
|
+
const policyPath = opts.policy ?? process.env.SWARM_POLICY ?? join2(home, "policy.toml");
|
|
796
|
+
const policyRaw = readLayer(policyPath);
|
|
797
|
+
const locked = Array.isArray(policyRaw?.locked) ? policyRaw.locked.filter((k) => typeof k === "string" && /^[a-z0-9_.-]+$/i.test(k)) : [];
|
|
798
|
+
const policy = { ...policyRaw ?? {} };
|
|
799
|
+
delete policy.locked;
|
|
800
|
+
const layers = [
|
|
801
|
+
["policy", policyRaw ? policy : null],
|
|
802
|
+
["global", readLayer(join2(home, "config.toml"))],
|
|
803
|
+
["repo", opts.repoRoot ? readLayer(join2(opts.repoRoot, ".swarm.toml")) : null]
|
|
804
|
+
];
|
|
805
|
+
const provenance = {};
|
|
806
|
+
for (const p of leafPaths(DEFAULT_CONFIG))
|
|
807
|
+
provenance[p] = "default";
|
|
808
|
+
const overridden = [];
|
|
390
809
|
let cfg = DEFAULT_CONFIG;
|
|
391
|
-
const
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
810
|
+
for (const [layer, raw] of layers) {
|
|
811
|
+
if (!raw)
|
|
812
|
+
continue;
|
|
813
|
+
for (const p of leafPaths(raw)) {
|
|
814
|
+
const lock = layer !== "policy" && locked.find((l) => isLockedBy(p, l));
|
|
815
|
+
if (lock)
|
|
816
|
+
overridden.push({ key: p, layer, attempted: getPath(raw, p) });
|
|
817
|
+
else
|
|
818
|
+
provenance[p] = layer;
|
|
819
|
+
}
|
|
820
|
+
cfg = merge(cfg, raw);
|
|
821
|
+
}
|
|
822
|
+
if (overridden.length) {
|
|
823
|
+
const out = structuredClone(cfg);
|
|
824
|
+
for (const { key } of overridden) {
|
|
825
|
+
const fromPolicy = getPath(policy, key);
|
|
826
|
+
setPath(out, key, fromPolicy === undefined ? getPath(DEFAULT_CONFIG, key) : fromPolicy);
|
|
827
|
+
provenance[key] = fromPolicy === undefined ? "default" : "policy";
|
|
828
|
+
}
|
|
829
|
+
cfg = out;
|
|
830
|
+
}
|
|
831
|
+
return {
|
|
832
|
+
config: validate(cfg),
|
|
833
|
+
provenance,
|
|
834
|
+
overridden,
|
|
835
|
+
policy: { path: policyRaw ? policyPath : null, locked }
|
|
836
|
+
};
|
|
837
|
+
}
|
|
838
|
+
function loadConfig(opts = {}) {
|
|
839
|
+
return loadConfigDetailed(opts).config;
|
|
840
|
+
}
|
|
841
|
+
// packages/core/src/dispatch.ts
|
|
842
|
+
function planDispatch(tasks, wanted, opts) {
|
|
843
|
+
const byId = new Map(tasks.map((t) => [t.id, t]));
|
|
844
|
+
const queued = new Set(opts.alreadyQueued ?? []);
|
|
845
|
+
const rejected = [];
|
|
846
|
+
const picked = [];
|
|
847
|
+
const ids = wanted === "ready" ? tasks.filter((t) => t.ready).map((t) => t.id) : wanted;
|
|
848
|
+
for (const id of ids) {
|
|
849
|
+
const t = byId.get(id);
|
|
850
|
+
if (!t)
|
|
851
|
+
rejected.push({ id, reason: "not in the task source" });
|
|
852
|
+
else if (queued.has(id))
|
|
853
|
+
rejected.push({ id, reason: "already queued" });
|
|
854
|
+
else if (t.claimedBy)
|
|
855
|
+
rejected.push({ id, reason: `held by ${t.claimedBy}` });
|
|
856
|
+
else if (t.status === "done")
|
|
857
|
+
rejected.push({ id, reason: "already done" });
|
|
858
|
+
else if (!t.ready)
|
|
859
|
+
rejected.push({
|
|
860
|
+
id,
|
|
861
|
+
reason: t.status === "active" ? "in progress" : "blocked by dependencies"
|
|
862
|
+
});
|
|
863
|
+
else if (picked.some((p) => p.id === id))
|
|
864
|
+
rejected.push({ id, reason: "listed twice" });
|
|
865
|
+
else
|
|
866
|
+
picked.push(t);
|
|
398
867
|
}
|
|
399
|
-
|
|
868
|
+
const limit = opts.max && opts.max > 0 ? picked.slice(0, opts.max) : picked;
|
|
869
|
+
for (const t of picked.slice(limit.length))
|
|
870
|
+
rejected.push({ id: t.id, reason: `beyond --max ${opts.max}` });
|
|
871
|
+
const slots = Math.max(0, opts.maxParallel - opts.running);
|
|
872
|
+
return { start: limit.slice(0, slots), queued: limit.slice(slots), rejected };
|
|
873
|
+
}
|
|
874
|
+
function taskPrompt(task, ctx = {
|
|
875
|
+
requiredGates: [],
|
|
876
|
+
executableGates: [],
|
|
877
|
+
openPr: true
|
|
878
|
+
}) {
|
|
879
|
+
const manual = ctx.requiredGates.filter((g) => !ctx.executableGates.includes(g));
|
|
880
|
+
const exec = ctx.requiredGates.filter((g) => ctx.executableGates.includes(g));
|
|
881
|
+
const steps = [
|
|
882
|
+
"Work only inside this worktree; never touch the main checkout or another worktree.",
|
|
883
|
+
"Commit as you go with clear messages. Do not edit the task list or flip the task's status \u2014 Swarm derives it.",
|
|
884
|
+
exec.length ? `Run the executable gates with swarm_gate_run (${exec.join(", ")}) and fix what fails.` : null,
|
|
885
|
+
manual.length ? `Record the remaining required gates with swarm_gate_record and an honest rubric (${manual.join(", ")}).` : null,
|
|
886
|
+
"Call swarm_handoff with what was done, what remains, the files touched and how to verify.",
|
|
887
|
+
ctx.openPr ? "Then push and open the pull request with swarm_pr_open." : "Push the branch.",
|
|
888
|
+
"If you are blocked on a decision only a human can make, say so in the handoff and stop."
|
|
889
|
+
].filter(Boolean);
|
|
890
|
+
return `Task ${task.id}: ${task.title}
|
|
891
|
+
|
|
892
|
+
${steps.map((s, i) => `${i + 1}. ${s}`).join(`
|
|
893
|
+
`)}`;
|
|
894
|
+
}
|
|
895
|
+
function dispatchOutcome(facts) {
|
|
896
|
+
if (facts.stopped)
|
|
897
|
+
return "stopped";
|
|
898
|
+
if (facts.exitCode !== 0 || facts.isError)
|
|
899
|
+
return "crashed";
|
|
900
|
+
if (!facts.gatesSatisfied)
|
|
901
|
+
return "gates-failed";
|
|
902
|
+
if (facts.requirePr && !facts.prOpen)
|
|
903
|
+
return "no-pr";
|
|
904
|
+
return "done";
|
|
400
905
|
}
|
|
401
906
|
// packages/core/src/rules.ts
|
|
402
907
|
var LIVE_WINDOW_MS = 10 * 60000;
|
|
@@ -706,6 +1211,68 @@ function normalizeGitlab(raw, repo) {
|
|
|
706
1211
|
};
|
|
707
1212
|
});
|
|
708
1213
|
}
|
|
1214
|
+
function parseNumstat(numstat, nameStatus = "") {
|
|
1215
|
+
const status = new Map;
|
|
1216
|
+
for (const line of nameStatus.split(`
|
|
1217
|
+
`)) {
|
|
1218
|
+
const [st, ...rest] = line.split("\t");
|
|
1219
|
+
if (!st || !rest.length)
|
|
1220
|
+
continue;
|
|
1221
|
+
status.set(rest[rest.length - 1], st[0]);
|
|
1222
|
+
}
|
|
1223
|
+
const out = [];
|
|
1224
|
+
for (const line of numstat.split(`
|
|
1225
|
+
`)) {
|
|
1226
|
+
const [a, d, ...rest] = line.split("\t");
|
|
1227
|
+
if (a === undefined || d === undefined || !rest.length)
|
|
1228
|
+
continue;
|
|
1229
|
+
const raw = rest.join("\t");
|
|
1230
|
+
const path = raw.includes(" => ") ? raw.replace(/\{?([^{}]*) => ([^{}]*)\}?/, "$2") : raw;
|
|
1231
|
+
out.push({
|
|
1232
|
+
path,
|
|
1233
|
+
added: a === "-" ? -1 : Number(a),
|
|
1234
|
+
deleted: d === "-" ? -1 : Number(d),
|
|
1235
|
+
status: status.get(path) ?? "M"
|
|
1236
|
+
});
|
|
1237
|
+
}
|
|
1238
|
+
return out;
|
|
1239
|
+
}
|
|
1240
|
+
function prDraft(i) {
|
|
1241
|
+
const title = ((i.title?.trim()) ? `${i.task}: ${i.title.trim()}` : i.task).slice(0, 120);
|
|
1242
|
+
const b = [];
|
|
1243
|
+
b.push("## Summary");
|
|
1244
|
+
if (i.handoff?.done.trim())
|
|
1245
|
+
b.push(i.handoff.done.trim());
|
|
1246
|
+
else if (i.commits?.length)
|
|
1247
|
+
b.push(i.commits.map((c) => `- ${c}`).join(`
|
|
1248
|
+
`));
|
|
1249
|
+
else
|
|
1250
|
+
b.push(`Work on ${i.task}.`);
|
|
1251
|
+
if (i.handoff?.remaining.trim() && !/^(nothing|none|\u2014|-)\.?$/i.test(i.handoff.remaining.trim()))
|
|
1252
|
+
b.push(`
|
|
1253
|
+
## Remaining
|
|
1254
|
+
${i.handoff.remaining.trim()}`);
|
|
1255
|
+
if (i.gates?.length) {
|
|
1256
|
+
b.push(`
|
|
1257
|
+
## Gates`);
|
|
1258
|
+
b.push(i.gates.map((g) => `- ${g.verdict === "pass" ? "[x]" : "[ ]"} ${g.gate}${g.verdict === "fail" ? " \u2014 failed" : g.verdict ? "" : " \u2014 not run"}`).join(`
|
|
1259
|
+
`));
|
|
1260
|
+
}
|
|
1261
|
+
if (i.handoff?.verify?.trim())
|
|
1262
|
+
b.push(`
|
|
1263
|
+
## Verify
|
|
1264
|
+
${i.handoff.verify.trim()}`);
|
|
1265
|
+
if (i.files?.length) {
|
|
1266
|
+
const shown = i.files.slice(0, 30);
|
|
1267
|
+
b.push(`
|
|
1268
|
+
## Files (${i.files.length})
|
|
1269
|
+
${shown.map((f) => `- \`${f.path}\`${f.added >= 0 ? ` +${f.added} \u2212${f.deleted}` : " (binary)"}`).join(`
|
|
1270
|
+
`)}${i.files.length > shown.length ? `
|
|
1271
|
+
- \u2026 ${i.files.length - shown.length} more` : ""}`);
|
|
1272
|
+
}
|
|
1273
|
+
return { title, body: b.join(`
|
|
1274
|
+
`) };
|
|
1275
|
+
}
|
|
709
1276
|
// packages/core/src/gates.ts
|
|
710
1277
|
var NAME_RE = /^[a-z0-9][a-z0-9_.-]{0,39}$/i;
|
|
711
1278
|
function validateGateRun(input) {
|
|
@@ -749,6 +1316,25 @@ function gatesSatisfied(runs, declared) {
|
|
|
749
1316
|
const st = gateStatus(runs, declared);
|
|
750
1317
|
return declared.every((g) => st.find((s) => s.gate === g)?.verdict === "pass");
|
|
751
1318
|
}
|
|
1319
|
+
function evidenceTail(output, max = 2000) {
|
|
1320
|
+
const t = output.trimEnd();
|
|
1321
|
+
if (t.length <= max)
|
|
1322
|
+
return t;
|
|
1323
|
+
const cut = t.slice(-max);
|
|
1324
|
+
const nl = cut.indexOf(`
|
|
1325
|
+
`);
|
|
1326
|
+
return `\u2026${nl >= 0 && nl < 200 ? cut.slice(nl + 1) : cut}`;
|
|
1327
|
+
}
|
|
1328
|
+
function executedGateInput(task, gate, cmd, outcome) {
|
|
1329
|
+
const how = outcome.timedOut === true ? "timed out" : outcome.exitCode === null ? "could not start" : `exit ${outcome.exitCode}`;
|
|
1330
|
+
return {
|
|
1331
|
+
task,
|
|
1332
|
+
gate,
|
|
1333
|
+
verdict: outcome.exitCode === 0 && !outcome.timedOut ? "pass" : "fail",
|
|
1334
|
+
rubric: `ran \`${cmd}\` \u2014 ${how} in ${(outcome.durationMs / 1000).toFixed(1)}s`,
|
|
1335
|
+
evidence: evidenceTail(outcome.output) || null
|
|
1336
|
+
};
|
|
1337
|
+
}
|
|
752
1338
|
// packages/core/src/ledger.ts
|
|
753
1339
|
var DEFAULT_LEASE_MINUTES = 45;
|
|
754
1340
|
function isExpired(claim, now) {
|
|
@@ -1053,6 +1639,92 @@ function parseMemoryQuery(q) {
|
|
|
1053
1639
|
}
|
|
1054
1640
|
return { match: terms.join(" "), kind, task };
|
|
1055
1641
|
}
|
|
1642
|
+
// packages/core/src/policy.ts
|
|
1643
|
+
import { createHash } from "crypto";
|
|
1644
|
+
var HOOK_MARK = "swarm-hook";
|
|
1645
|
+
var hookIsOurs = (h) => typeof h.command === "string" && (h.command.includes(HOOK_MARK) || h.command.includes("/packages/hook/src/bin.ts"));
|
|
1646
|
+
var MIN_HOOK_TIMEOUT_S = 5;
|
|
1647
|
+
function hookCoverage(settings) {
|
|
1648
|
+
const hooks = settings && typeof settings === "object" && !Array.isArray(settings) ? settings.hooks : undefined;
|
|
1649
|
+
const missing = [];
|
|
1650
|
+
const short = [];
|
|
1651
|
+
for (const ev of HOOK_EVENTS) {
|
|
1652
|
+
const groups = Array.isArray(hooks?.[ev]) ? hooks?.[ev] : [];
|
|
1653
|
+
const ours = groups.flatMap((g) => {
|
|
1654
|
+
const list = g?.hooks;
|
|
1655
|
+
return Array.isArray(list) ? list.filter((h) => hookIsOurs(h)) : [];
|
|
1656
|
+
});
|
|
1657
|
+
if (!ours.length)
|
|
1658
|
+
missing.push(ev);
|
|
1659
|
+
else if (ours.every((h) => typeof h.timeout === "number" && h.timeout < MIN_HOOK_TIMEOUT_S))
|
|
1660
|
+
short.push(ev);
|
|
1661
|
+
}
|
|
1662
|
+
return { missing, short, complete: !missing.length && !short.length };
|
|
1663
|
+
}
|
|
1664
|
+
var hasLockedRules = (loaded) => loaded.policy.locked.some((k) => k === "rules" || k.startsWith("rules."));
|
|
1665
|
+
function policyFindings(input) {
|
|
1666
|
+
const out = [];
|
|
1667
|
+
const repo = input.repoRoot ?? "";
|
|
1668
|
+
for (const o of input.loaded.overridden)
|
|
1669
|
+
out.push({
|
|
1670
|
+
key: `override:${repo}:${o.layer}:${o.key}`,
|
|
1671
|
+
subject: `${o.layer === "repo" ? ".swarm.toml" : "config.toml"} ${o.key}`,
|
|
1672
|
+
reason: `locked by policy; ${o.layer} config tried to set ${JSON.stringify(o.attempted)}`
|
|
1673
|
+
});
|
|
1674
|
+
const cov = input.coverage;
|
|
1675
|
+
if (cov && !cov.complete) {
|
|
1676
|
+
if (cov.missing.length)
|
|
1677
|
+
out.push({
|
|
1678
|
+
key: `hooks:missing:${cov.missing.join(",")}`,
|
|
1679
|
+
subject: `hooks ${cov.missing.join(", ")}`,
|
|
1680
|
+
reason: "swarm hook entry removed from settings.json \u2014 run: swarm install"
|
|
1681
|
+
});
|
|
1682
|
+
if (cov.short.length)
|
|
1683
|
+
out.push({
|
|
1684
|
+
key: `hooks:short:${cov.short.join(",")}`,
|
|
1685
|
+
subject: `hooks ${cov.short.join(", ")}`,
|
|
1686
|
+
reason: `hook timeout below ${MIN_HOOK_TIMEOUT_S}s \u2014 run: swarm install`
|
|
1687
|
+
});
|
|
1688
|
+
}
|
|
1689
|
+
if (input.guardOff && hasLockedRules(input.loaded))
|
|
1690
|
+
out.push({
|
|
1691
|
+
key: "guard:off",
|
|
1692
|
+
subject: "SWARM_GUARD=off",
|
|
1693
|
+
reason: "policy locks rules; SWARM_GUARD=off is ignored for locked rules"
|
|
1694
|
+
});
|
|
1695
|
+
return out;
|
|
1696
|
+
}
|
|
1697
|
+
var POLICY_CACHE_VERSION = 1;
|
|
1698
|
+
var POLICY_CACHE_FILE = "policy.cache.json";
|
|
1699
|
+
var RULE_KEYS = [
|
|
1700
|
+
"shared_tree",
|
|
1701
|
+
"destructive_git",
|
|
1702
|
+
"pattern_kill",
|
|
1703
|
+
"protected_ports",
|
|
1704
|
+
"no_foreign_worktree",
|
|
1705
|
+
"claim_required_to_write"
|
|
1706
|
+
];
|
|
1707
|
+
var lockedKey = (locked, key) => locked.some((l) => l === "rules" || key === l || key.startsWith(`${l}.`));
|
|
1708
|
+
function offlineModes(loaded) {
|
|
1709
|
+
const locked = loaded.policy.locked;
|
|
1710
|
+
const out = { ...DEFAULT_MODES, protected: { ports: [] } };
|
|
1711
|
+
for (const k of RULE_KEYS)
|
|
1712
|
+
out[k] = lockedKey(locked, `rules.${k}`) ? loaded.config.rules[k] : "off";
|
|
1713
|
+
if (lockedKey(locked, "rules.protected.ports"))
|
|
1714
|
+
out.protected = { ports: [...loaded.config.rules.protected.ports] };
|
|
1715
|
+
return out;
|
|
1716
|
+
}
|
|
1717
|
+
var digest = (body) => createHash("sha256").update(JSON.stringify(body)).digest("hex");
|
|
1718
|
+
function buildPolicyCache(loaded, sessions, worktrees, now = new Date) {
|
|
1719
|
+
const body = {
|
|
1720
|
+
version: POLICY_CACHE_VERSION,
|
|
1721
|
+
writtenAt: now.toISOString(),
|
|
1722
|
+
modes: offlineModes(loaded),
|
|
1723
|
+
sessions,
|
|
1724
|
+
worktrees
|
|
1725
|
+
};
|
|
1726
|
+
return { ...body, sha256: digest(body) };
|
|
1727
|
+
}
|
|
1056
1728
|
// packages/core/src/pricing.ts
|
|
1057
1729
|
var PRICES = {
|
|
1058
1730
|
"claude-opus-4": { input: 15, output: 75, cacheWrite: 18.75, cacheWrite1h: 30, cacheRead: 1.5 },
|
|
@@ -1151,6 +1823,29 @@ function projectIdentity(opts) {
|
|
|
1151
1823
|
const name = parts[parts.length - 1] ?? opts.root;
|
|
1152
1824
|
return { id: `p_${fnv1a(key)}`, root: opts.root, commonDir: opts.commonDir, name };
|
|
1153
1825
|
}
|
|
1826
|
+
// packages/core/src/questions.ts
|
|
1827
|
+
function validateQuestion(text, options) {
|
|
1828
|
+
const t = typeof text === "string" ? text.trim() : "";
|
|
1829
|
+
if (t.length < 5)
|
|
1830
|
+
return { ok: false, reason: "a question needs at least a few words" };
|
|
1831
|
+
if (t.length > 4000)
|
|
1832
|
+
return { ok: false, reason: "keep the question under 4000 characters" };
|
|
1833
|
+
const opts = Array.isArray(options) ? options.filter((o) => typeof o === "string" && o.trim() !== "").map((o) => o.trim()).slice(0, 8) : [];
|
|
1834
|
+
return { ok: true, text: t, options: opts };
|
|
1835
|
+
}
|
|
1836
|
+
function formatAnswers(qs) {
|
|
1837
|
+
const answered = qs.filter((q) => q.answer !== null);
|
|
1838
|
+
if (!answered.length)
|
|
1839
|
+
return null;
|
|
1840
|
+
return answered.map((q) => `[swarm] answer from ${q.answeredBy ?? "a human"} to your question "${q.text.slice(0, 200)}": ${q.answer}`).join(`
|
|
1841
|
+
`);
|
|
1842
|
+
}
|
|
1843
|
+
function formatOpenQuestions(qs) {
|
|
1844
|
+
const open = qs.filter((q) => q.answer === null);
|
|
1845
|
+
if (!open.length)
|
|
1846
|
+
return null;
|
|
1847
|
+
return `[swarm] waiting on a human for: ${open.map((q) => `#${q.id} "${q.text.slice(0, 120)}"`).join("; ")} \u2014 the answer arrives as context on a later tool call, or via swarm_inbox`;
|
|
1848
|
+
}
|
|
1154
1849
|
// packages/core/src/resources.ts
|
|
1155
1850
|
var DEFAULT_RESOURCE_LEASE_MINUTES = 60;
|
|
1156
1851
|
function isTrackedPid(pid) {
|
|
@@ -1176,6 +1871,114 @@ function acquireRefusalMessage(holder) {
|
|
|
1176
1871
|
const via = isTrackedPid(holder.pid) ? `pid ${holder.pid}` : holder.expiresAt ? `lease until ${holder.expiresAt}` : "unbounded";
|
|
1177
1872
|
return `Resource "${holder.name}" is held by ${holder.owner} (${via}).` + ` Pick another name, coordinate with the holder, or wait for release/reap.`;
|
|
1178
1873
|
}
|
|
1874
|
+
// packages/core/src/review.ts
|
|
1875
|
+
var REVIEW_RUBRIC = "review: no blocker/major findings \u2014 correctness bugs, data loss, security, broken invariants (never kill by pattern, never touch a worktree you don't hold, repo-agnostic), missing tests for changed behaviour";
|
|
1876
|
+
var REVIEW_PATCH_MAX = 120000;
|
|
1877
|
+
function reviewPrompt(input) {
|
|
1878
|
+
const patch = input.patch.length > REVIEW_PATCH_MAX ? `${input.patch.slice(0, REVIEW_PATCH_MAX)}
|
|
1879
|
+
|
|
1880
|
+
[\u2026 patch truncated at ${REVIEW_PATCH_MAX} chars; read the files for the rest]` : input.patch;
|
|
1881
|
+
return [
|
|
1882
|
+
`You are the review gate for task ${input.task}${input.title ? ` \u2014 ${input.title}` : ""}${input.branch ? ` (branch ${input.branch})` : ""}.`,
|
|
1883
|
+
"You are read-only: you may Read, Grep and Glob files in this worktree to understand context. Do not edit anything.",
|
|
1884
|
+
"",
|
|
1885
|
+
"Judge the diff below against this rubric and nothing else:",
|
|
1886
|
+
`- ${REVIEW_RUBRIC}`,
|
|
1887
|
+
"- A finding is a concrete defect with a file and, when possible, a line \u2014 not style, not preference.",
|
|
1888
|
+
"- Severity: blocker (must not merge), major (should not merge), minor (worth fixing), nit.",
|
|
1889
|
+
'- verdict is "fail" if and only if there is at least one blocker or major finding.',
|
|
1890
|
+
"",
|
|
1891
|
+
"Respond with ONLY a JSON object, no prose, no code fence:",
|
|
1892
|
+
'{"verdict":"pass"|"fail","summary":"one sentence","findings":[{"file":"path","line":123,"severity":"blocker|major|minor|nit","summary":"what is wrong and why"}]}',
|
|
1893
|
+
"",
|
|
1894
|
+
"Files changed:",
|
|
1895
|
+
input.stat.trim() || "(no stat)",
|
|
1896
|
+
"",
|
|
1897
|
+
"Diff:",
|
|
1898
|
+
patch.trim() || "(empty diff)"
|
|
1899
|
+
].join(`
|
|
1900
|
+
`);
|
|
1901
|
+
}
|
|
1902
|
+
function reviewArgs(prompt, opts = {}) {
|
|
1903
|
+
const args = [
|
|
1904
|
+
"-p",
|
|
1905
|
+
prompt,
|
|
1906
|
+
"--output-format",
|
|
1907
|
+
"json",
|
|
1908
|
+
"--allowedTools",
|
|
1909
|
+
"Read",
|
|
1910
|
+
"Grep",
|
|
1911
|
+
"Glob",
|
|
1912
|
+
"LS",
|
|
1913
|
+
"--disallowedTools",
|
|
1914
|
+
"Edit",
|
|
1915
|
+
"Write",
|
|
1916
|
+
"MultiEdit",
|
|
1917
|
+
"NotebookEdit",
|
|
1918
|
+
"Bash",
|
|
1919
|
+
"WebFetch",
|
|
1920
|
+
"WebSearch",
|
|
1921
|
+
"--permission-mode",
|
|
1922
|
+
"dontAsk"
|
|
1923
|
+
];
|
|
1924
|
+
if (opts.model)
|
|
1925
|
+
args.push("--model", opts.model);
|
|
1926
|
+
return args;
|
|
1927
|
+
}
|
|
1928
|
+
function parseReviewVerdict(stdout) {
|
|
1929
|
+
let text = stdout.trim();
|
|
1930
|
+
try {
|
|
1931
|
+
const env = JSON.parse(text);
|
|
1932
|
+
if (env && typeof env === "object" && typeof env.result === "string")
|
|
1933
|
+
text = env.result.trim();
|
|
1934
|
+
} catch {}
|
|
1935
|
+
text = text.replace(/^```(?:json)?\s*/i, "").replace(/\s*```$/, "");
|
|
1936
|
+
const start = text.indexOf("{");
|
|
1937
|
+
const end = text.lastIndexOf("}");
|
|
1938
|
+
if (start < 0 || end <= start)
|
|
1939
|
+
return null;
|
|
1940
|
+
let obj;
|
|
1941
|
+
try {
|
|
1942
|
+
obj = JSON.parse(text.slice(start, end + 1));
|
|
1943
|
+
} catch {
|
|
1944
|
+
return null;
|
|
1945
|
+
}
|
|
1946
|
+
if (!obj || typeof obj !== "object")
|
|
1947
|
+
return null;
|
|
1948
|
+
const o = obj;
|
|
1949
|
+
const findings = Array.isArray(o.findings) ? o.findings.filter((f) => !!f && typeof f === "object").map((f) => ({
|
|
1950
|
+
file: String(f.file ?? "").slice(0, 300),
|
|
1951
|
+
line: Number.isInteger(f.line) ? f.line : null,
|
|
1952
|
+
severity: ["blocker", "major", "minor", "nit"].includes(String(f.severity)) ? String(f.severity) : "minor",
|
|
1953
|
+
summary: String(f.summary ?? "").slice(0, 600)
|
|
1954
|
+
})).filter((f) => f.summary) : [];
|
|
1955
|
+
const serious = findings.some((f) => f.severity === "blocker" || f.severity === "major");
|
|
1956
|
+
const verdict = serious ? "fail" : o.verdict === "fail" ? "fail" : "pass";
|
|
1957
|
+
return { verdict, summary: String(o.summary ?? "").slice(0, 400), findings };
|
|
1958
|
+
}
|
|
1959
|
+
function reviewGateInput(task, gate, outcome) {
|
|
1960
|
+
if (outcome.kind === "error")
|
|
1961
|
+
return {
|
|
1962
|
+
task,
|
|
1963
|
+
gate,
|
|
1964
|
+
verdict: "fail",
|
|
1965
|
+
rubric: REVIEW_RUBRIC,
|
|
1966
|
+
evidence: `reviewer did not answer: ${outcome.reason}${outcome.output ? `
|
|
1967
|
+
${outcome.output.slice(-1500)}` : ""}`
|
|
1968
|
+
};
|
|
1969
|
+
const v = outcome.verdict;
|
|
1970
|
+
const lines = v.findings.map((f) => `- [${f.severity}] ${f.file}${f.line ? `:${f.line}` : ""} \u2014 ${f.summary}`);
|
|
1971
|
+
const secs = (outcome.durationMs / 1000).toFixed(0);
|
|
1972
|
+
return {
|
|
1973
|
+
task,
|
|
1974
|
+
gate,
|
|
1975
|
+
verdict: v.verdict,
|
|
1976
|
+
rubric: REVIEW_RUBRIC,
|
|
1977
|
+
evidence: `${v.summary || (v.verdict === "pass" ? "no blocking findings" : "blocking findings")} (${v.findings.length} finding${v.findings.length === 1 ? "" : "s"}, ${secs}s)${lines.length ? `
|
|
1978
|
+
${lines.join(`
|
|
1979
|
+
`)}` : ""}`
|
|
1980
|
+
};
|
|
1981
|
+
}
|
|
1179
1982
|
// packages/core/src/tasks.ts
|
|
1180
1983
|
var ID_RE = /^[A-Za-z][A-Za-z0-9_-]*\d[\w.-]*$/;
|
|
1181
1984
|
var DEP_RE = /[A-Za-z][A-Za-z0-9_-]*\d[\w.]*/g;
|
|
@@ -1335,18 +2138,91 @@ function linearIssuesQuery(teamKey, first = 200) {
|
|
|
1335
2138
|
inverseRelations { nodes { type issue { identifier } } }
|
|
1336
2139
|
} } }`;
|
|
1337
2140
|
}
|
|
2141
|
+
// packages/core/src/worktree.ts
|
|
2142
|
+
import { join as join3 } from "path";
|
|
2143
|
+
function planBootstrap(cfg, repoRoot, worktree) {
|
|
2144
|
+
const seen = new Set;
|
|
2145
|
+
const copies = [];
|
|
2146
|
+
for (const raw of cfg.worktree.copy) {
|
|
2147
|
+
if (!isRepoRelative(raw))
|
|
2148
|
+
continue;
|
|
2149
|
+
const rel = raw.trim().replace(/^\.\//, "");
|
|
2150
|
+
if (seen.has(rel))
|
|
2151
|
+
continue;
|
|
2152
|
+
seen.add(rel);
|
|
2153
|
+
copies.push({ rel, from: join3(repoRoot, rel), to: join3(worktree, rel) });
|
|
2154
|
+
}
|
|
2155
|
+
return { copies, setup: cfg.worktree.setup };
|
|
2156
|
+
}
|
|
2157
|
+
var needsBootstrap = (plan) => plan.copies.length > 0 || plan.setup !== null;
|
|
2158
|
+
function summarizeBootstrap(o) {
|
|
2159
|
+
const parts = [];
|
|
2160
|
+
if (o.copied.length)
|
|
2161
|
+
parts.push(`copied ${o.copied.join(", ")}`);
|
|
2162
|
+
if (o.skipped.length)
|
|
2163
|
+
parts.push(`skipped ${o.skipped.join(", ")} (missing)`);
|
|
2164
|
+
if (o.setup)
|
|
2165
|
+
parts.push(`${o.setup.command} \u2192 ${o.setup.exitCode === 0 ? "ok" : `exit ${o.setup.exitCode}`} in ${(o.setup.durationMs / 1000).toFixed(1)}s`);
|
|
2166
|
+
return parts.join("; ") || "nothing to do";
|
|
2167
|
+
}
|
|
2168
|
+
function canRemoveWorktree(w, heldByClaim, force) {
|
|
2169
|
+
if (w.main)
|
|
2170
|
+
return { ok: false, reason: "main" };
|
|
2171
|
+
if (heldByClaim)
|
|
2172
|
+
return { ok: false, reason: "held" };
|
|
2173
|
+
if (force)
|
|
2174
|
+
return { ok: true };
|
|
2175
|
+
if (w.dirty > 0)
|
|
2176
|
+
return { ok: false, reason: "dirty" };
|
|
2177
|
+
if (w.ahead > 0)
|
|
2178
|
+
return { ok: false, reason: "unpushed" };
|
|
2179
|
+
return { ok: true };
|
|
2180
|
+
}
|
|
2181
|
+
function removeRefusalMessage(reason, path, task) {
|
|
2182
|
+
switch (reason) {
|
|
2183
|
+
case "main":
|
|
2184
|
+
return `${path} is the main checkout \u2014 it is never removed`;
|
|
2185
|
+
case "held":
|
|
2186
|
+
return `${path} is held by claim ${task ?? "?"} \u2014 release the claim instead`;
|
|
2187
|
+
case "dirty":
|
|
2188
|
+
return `${path} has uncommitted changes \u2014 commit or stash them, or --force to discard`;
|
|
2189
|
+
case "unpushed":
|
|
2190
|
+
return `${path} has unpushed commits \u2014 push them, or --force to discard`;
|
|
2191
|
+
}
|
|
2192
|
+
}
|
|
2193
|
+
function planGc(worktrees, claims) {
|
|
2194
|
+
const held = new Map(claims.filter((c) => c.state === "held").map((c) => [c.worktree, c.task]));
|
|
2195
|
+
const stale = new Set(claims.filter((c) => c.state !== "held").map((c) => c.worktree));
|
|
2196
|
+
const out = [];
|
|
2197
|
+
for (const w of worktrees) {
|
|
2198
|
+
if (w.main || held.has(w.path))
|
|
2199
|
+
continue;
|
|
2200
|
+
const why = w.merged ? "merged" : stale.has(w.path) ? "released-claim" : null;
|
|
2201
|
+
if (!why)
|
|
2202
|
+
continue;
|
|
2203
|
+
const can = canRemoveWorktree(w, null, false);
|
|
2204
|
+
out.push({
|
|
2205
|
+
path: w.path,
|
|
2206
|
+
branch: w.branch,
|
|
2207
|
+
why,
|
|
2208
|
+
removable: can.ok,
|
|
2209
|
+
blocker: can.ok ? null : can.reason
|
|
2210
|
+
});
|
|
2211
|
+
}
|
|
2212
|
+
return out;
|
|
2213
|
+
}
|
|
1338
2214
|
// packages/daemon/src/app.ts
|
|
1339
|
-
import { existsSync as
|
|
2215
|
+
import { existsSync as existsSync6, readdirSync as readdirSync2, readFileSync as readFileSync4, realpathSync as realpathSync3 } from "fs";
|
|
1340
2216
|
import { homedir as homedir4 } from "os";
|
|
1341
|
-
import { dirname as
|
|
2217
|
+
import { dirname as dirname3, join as join9 } from "path";
|
|
1342
2218
|
import { fileURLToPath } from "url";
|
|
1343
2219
|
|
|
1344
2220
|
// node_modules/.bun/hono@4.13.3/node_modules/hono/dist/compose.js
|
|
1345
2221
|
var compose = (middleware, onError, onNotFound) => {
|
|
1346
2222
|
return (context, next) => {
|
|
1347
2223
|
let index = -1;
|
|
1348
|
-
return
|
|
1349
|
-
async function
|
|
2224
|
+
return dispatch2(0);
|
|
2225
|
+
async function dispatch2(i) {
|
|
1350
2226
|
if (i <= index) {
|
|
1351
2227
|
throw new Error("next() called multiple times");
|
|
1352
2228
|
}
|
|
@@ -1362,7 +2238,7 @@ var compose = (middleware, onError, onNotFound) => {
|
|
|
1362
2238
|
}
|
|
1363
2239
|
if (handler) {
|
|
1364
2240
|
try {
|
|
1365
|
-
res = await handler(context, () =>
|
|
2241
|
+
res = await handler(context, () => dispatch2(i + 1));
|
|
1366
2242
|
} catch (err) {
|
|
1367
2243
|
if (err instanceof Error && onError) {
|
|
1368
2244
|
context.error = err;
|
|
@@ -1547,7 +2423,7 @@ var tryDecode = (str, decoder) => {
|
|
|
1547
2423
|
}
|
|
1548
2424
|
};
|
|
1549
2425
|
var tryDecodeURI = (str) => tryDecode(str, decodeURI);
|
|
1550
|
-
var
|
|
2426
|
+
var getPath2 = (request) => {
|
|
1551
2427
|
const url = request.url;
|
|
1552
2428
|
const start = url.indexOf("/", url.indexOf(":") + 4);
|
|
1553
2429
|
let i = start;
|
|
@@ -1566,7 +2442,7 @@ var getPath = (request) => {
|
|
|
1566
2442
|
return url.slice(start, i);
|
|
1567
2443
|
};
|
|
1568
2444
|
var getPathNoStrict = (request) => {
|
|
1569
|
-
const result =
|
|
2445
|
+
const result = getPath2(request);
|
|
1570
2446
|
return result.length > 1 && result.at(-1) === "/" ? result.slice(0, -1) : result;
|
|
1571
2447
|
};
|
|
1572
2448
|
var mergePath = (base, sub, ...rest) => {
|
|
@@ -2088,7 +2964,7 @@ var Hono = class _Hono {
|
|
|
2088
2964
|
};
|
|
2089
2965
|
const { strict, ...optionsWithoutStrict } = options;
|
|
2090
2966
|
Object.assign(this, optionsWithoutStrict);
|
|
2091
|
-
this.getPath = strict ?? true ? options.getPath ??
|
|
2967
|
+
this.getPath = strict ?? true ? options.getPath ?? getPath2 : getPathNoStrict;
|
|
2092
2968
|
}
|
|
2093
2969
|
#clone() {
|
|
2094
2970
|
const clone = new _Hono({
|
|
@@ -2958,75 +3834,328 @@ var streamSSE = (c, cb, onError) => {
|
|
|
2958
3834
|
return c.newResponse(stream.responseReadable);
|
|
2959
3835
|
};
|
|
2960
3836
|
|
|
2961
|
-
// packages/daemon/src/
|
|
2962
|
-
|
|
2963
|
-
import { homedir as homedir2 } from "os";
|
|
2964
|
-
import { join as join3 } from "path";
|
|
2965
|
-
var EXTRA_BIN_DIRS = [
|
|
2966
|
-
"/opt/homebrew/bin",
|
|
2967
|
-
"/usr/local/bin",
|
|
2968
|
-
"/home/linuxbrew/.linuxbrew/bin",
|
|
2969
|
-
join3(homedir2(), ".local", "bin"),
|
|
2970
|
-
join3(homedir2(), "bin")
|
|
2971
|
-
];
|
|
2972
|
-
function findBin(name) {
|
|
2973
|
-
if (!name)
|
|
2974
|
-
return null;
|
|
2975
|
-
const onPath = Bun.which(name, { PATH: process.env.PATH ?? "" });
|
|
2976
|
-
if (onPath)
|
|
2977
|
-
return onPath;
|
|
2978
|
-
for (const d of EXTRA_BIN_DIRS) {
|
|
2979
|
-
const p = join3(d, name);
|
|
2980
|
-
if (existsSync3(p))
|
|
2981
|
-
return p;
|
|
2982
|
-
}
|
|
2983
|
-
return null;
|
|
2984
|
-
}
|
|
2985
|
-
var GH_FIELDS = "number,title,headRefName,url,author,isDraft,mergeable,reviewDecision,statusCheckRollup,createdAt";
|
|
2986
|
-
|
|
2987
|
-
class ForgeService {
|
|
3837
|
+
// packages/daemon/src/dispatcher.ts
|
|
3838
|
+
class Dispatcher {
|
|
2988
3839
|
store;
|
|
2989
|
-
|
|
2990
|
-
|
|
2991
|
-
|
|
3840
|
+
runner;
|
|
3841
|
+
forge;
|
|
3842
|
+
entries = new Map;
|
|
3843
|
+
opts = new Map;
|
|
3844
|
+
constructor(store, runner, forge2) {
|
|
2992
3845
|
this.store = store;
|
|
3846
|
+
this.runner = runner;
|
|
3847
|
+
this.forge = forge2;
|
|
3848
|
+
runner.onEnd((run2) => void this.onRunEnd(run2));
|
|
2993
3849
|
}
|
|
2994
|
-
|
|
2995
|
-
this.
|
|
2996
|
-
|
|
2997
|
-
|
|
2998
|
-
|
|
2999
|
-
|
|
3000
|
-
|
|
3001
|
-
await Promise.all(projects.map(async (p) => {
|
|
3002
|
-
const hit = this.cache.get(p.id);
|
|
3003
|
-
if (hit && Date.now() - hit.at < maxAgeMs)
|
|
3004
|
-
return;
|
|
3005
|
-
if (this.inflight.has(p.id))
|
|
3006
|
-
return;
|
|
3007
|
-
this.inflight.add(p.id);
|
|
3008
|
-
try {
|
|
3009
|
-
const prs = await this.poll(p.id, p.root);
|
|
3010
|
-
this.cache.set(p.id, { at: Date.now(), prs });
|
|
3011
|
-
} catch {
|
|
3012
|
-
this.cache.set(p.id, { at: Date.now(), prs: this.cache.get(p.id)?.prs ?? [] });
|
|
3013
|
-
} finally {
|
|
3014
|
-
this.inflight.delete(p.id);
|
|
3015
|
-
}
|
|
3016
|
-
}));
|
|
3850
|
+
project(projectId) {
|
|
3851
|
+
let m = this.entries.get(projectId);
|
|
3852
|
+
if (!m) {
|
|
3853
|
+
m = new Map;
|
|
3854
|
+
this.entries.set(projectId, m);
|
|
3855
|
+
}
|
|
3856
|
+
return m;
|
|
3017
3857
|
}
|
|
3018
|
-
|
|
3019
|
-
|
|
3020
|
-
if (r.exitCode !== 0)
|
|
3021
|
-
return null;
|
|
3022
|
-
return parseRemote(new TextDecoder().decode(r.stdout).trim());
|
|
3858
|
+
status(projectId) {
|
|
3859
|
+
return [...this.entries.get(projectId)?.values() ?? []];
|
|
3023
3860
|
}
|
|
3024
|
-
async
|
|
3025
|
-
const
|
|
3026
|
-
if (!
|
|
3027
|
-
return
|
|
3028
|
-
|
|
3029
|
-
|
|
3861
|
+
async dispatch(projectId, wanted, o = {}) {
|
|
3862
|
+
const board = this.store.tasks(projectId);
|
|
3863
|
+
if (!board)
|
|
3864
|
+
return {
|
|
3865
|
+
ok: false,
|
|
3866
|
+
error: "this repo has no task source ([tasks] source in .swarm.toml)"
|
|
3867
|
+
};
|
|
3868
|
+
if (board.error)
|
|
3869
|
+
return { ok: false, error: `task source: ${board.error}` };
|
|
3870
|
+
const cfg = this.store.config(projectId).dispatch;
|
|
3871
|
+
const opts = {
|
|
3872
|
+
owner: o.owner ?? "dispatch",
|
|
3873
|
+
...o,
|
|
3874
|
+
maxParallel: o.maxParallel ?? cfg.max_parallel
|
|
3875
|
+
};
|
|
3876
|
+
this.opts.set(projectId, opts);
|
|
3877
|
+
const m = this.project(projectId);
|
|
3878
|
+
const running = [...m.values()].filter((e) => e.state === "running").length;
|
|
3879
|
+
const plan = planDispatch(board.tasks, wanted, {
|
|
3880
|
+
maxParallel: opts.maxParallel,
|
|
3881
|
+
running,
|
|
3882
|
+
max: o.max,
|
|
3883
|
+
alreadyQueued: [...m.values()].filter((e) => e.state !== "finished").map((e) => e.task)
|
|
3884
|
+
});
|
|
3885
|
+
const now = new Date().toISOString();
|
|
3886
|
+
for (const t of [...plan.start, ...plan.queued]) {
|
|
3887
|
+
m.set(t.id, {
|
|
3888
|
+
task: t.id,
|
|
3889
|
+
title: t.title,
|
|
3890
|
+
state: "queued",
|
|
3891
|
+
runId: null,
|
|
3892
|
+
sessionId: null,
|
|
3893
|
+
queuedAt: now,
|
|
3894
|
+
startedAt: null,
|
|
3895
|
+
endedAt: null,
|
|
3896
|
+
outcome: null,
|
|
3897
|
+
detail: null,
|
|
3898
|
+
costUsd: null
|
|
3899
|
+
});
|
|
3900
|
+
}
|
|
3901
|
+
if (plan.start.length || plan.queued.length)
|
|
3902
|
+
this.store.append({
|
|
3903
|
+
ts: now,
|
|
3904
|
+
type: "dispatch.queued",
|
|
3905
|
+
projectId,
|
|
3906
|
+
sessionId: null,
|
|
3907
|
+
payload: {
|
|
3908
|
+
tasks: [...plan.start, ...plan.queued].map((t) => t.id),
|
|
3909
|
+
maxParallel: opts.maxParallel,
|
|
3910
|
+
summary: `dispatch ${[...plan.start, ...plan.queued].map((t) => t.id).join(", ")}`
|
|
3911
|
+
}
|
|
3912
|
+
});
|
|
3913
|
+
const started = [];
|
|
3914
|
+
const failed = [];
|
|
3915
|
+
for (const t of plan.start) {
|
|
3916
|
+
const r = await this.startOne(projectId, t);
|
|
3917
|
+
if (r.ok)
|
|
3918
|
+
started.push(t.id);
|
|
3919
|
+
else
|
|
3920
|
+
failed.push({ id: t.id, reason: r.reason });
|
|
3921
|
+
}
|
|
3922
|
+
await this.fill(projectId);
|
|
3923
|
+
return {
|
|
3924
|
+
ok: true,
|
|
3925
|
+
started,
|
|
3926
|
+
queued: plan.queued.map((t) => t.id).filter((id) => m.get(id)?.state === "queued"),
|
|
3927
|
+
rejected: [...plan.rejected, ...failed]
|
|
3928
|
+
};
|
|
3929
|
+
}
|
|
3930
|
+
async startOne(projectId, t) {
|
|
3931
|
+
const m = this.project(projectId);
|
|
3932
|
+
const e = m.get(t.id);
|
|
3933
|
+
const opts = this.opts.get(projectId) ?? { owner: "dispatch" };
|
|
3934
|
+
const cfg = this.store.config(projectId);
|
|
3935
|
+
const gates2 = cfg.gates;
|
|
3936
|
+
const prompt = taskPrompt(t, {
|
|
3937
|
+
requiredGates: gates2.required,
|
|
3938
|
+
executableGates: gates2.required.filter((g) => gates2.defs[g]),
|
|
3939
|
+
openPr: cfg.dispatch.require_pr
|
|
3940
|
+
});
|
|
3941
|
+
const r = await this.runner.start({
|
|
3942
|
+
projectId,
|
|
3943
|
+
task: t.id,
|
|
3944
|
+
prompt,
|
|
3945
|
+
owner: opts.owner,
|
|
3946
|
+
permissionMode: opts.permissionMode ?? cfg.dispatch.permission_mode ?? "acceptEdits",
|
|
3947
|
+
model: opts.model ?? cfg.dispatch.model ?? undefined,
|
|
3948
|
+
maxTurns: opts.maxTurns ?? cfg.dispatch.max_turns ?? undefined,
|
|
3949
|
+
profile: opts.profile ?? cfg.dispatch.profile ?? undefined
|
|
3950
|
+
});
|
|
3951
|
+
if (!r.ok) {
|
|
3952
|
+
if (e) {
|
|
3953
|
+
e.state = "finished";
|
|
3954
|
+
e.endedAt = new Date().toISOString();
|
|
3955
|
+
e.outcome = "crashed";
|
|
3956
|
+
e.detail = r.reason;
|
|
3957
|
+
}
|
|
3958
|
+
this.store.append({
|
|
3959
|
+
ts: new Date().toISOString(),
|
|
3960
|
+
type: "dispatch.finished",
|
|
3961
|
+
projectId,
|
|
3962
|
+
sessionId: null,
|
|
3963
|
+
payload: {
|
|
3964
|
+
task: t.id,
|
|
3965
|
+
outcome: "crashed",
|
|
3966
|
+
detail: r.reason,
|
|
3967
|
+
summary: `dispatch ${t.id}: could not start \u2014 ${r.reason}`
|
|
3968
|
+
}
|
|
3969
|
+
});
|
|
3970
|
+
return { ok: false, reason: r.reason };
|
|
3971
|
+
}
|
|
3972
|
+
if (e) {
|
|
3973
|
+
e.state = "running";
|
|
3974
|
+
e.runId = r.run.id;
|
|
3975
|
+
e.sessionId = r.run.sessionId;
|
|
3976
|
+
e.startedAt = r.run.startedAt;
|
|
3977
|
+
}
|
|
3978
|
+
this.store.append({
|
|
3979
|
+
ts: r.run.startedAt,
|
|
3980
|
+
type: "dispatch.started",
|
|
3981
|
+
projectId,
|
|
3982
|
+
sessionId: r.run.sessionId,
|
|
3983
|
+
payload: {
|
|
3984
|
+
task: t.id,
|
|
3985
|
+
runId: r.run.id,
|
|
3986
|
+
worktree: r.run.worktree,
|
|
3987
|
+
summary: `dispatch ${t.id} \u2192 run ${r.run.id}`
|
|
3988
|
+
}
|
|
3989
|
+
});
|
|
3990
|
+
return { ok: true };
|
|
3991
|
+
}
|
|
3992
|
+
async fill(projectId) {
|
|
3993
|
+
const m = this.project(projectId);
|
|
3994
|
+
const cap = this.opts.get(projectId)?.maxParallel ?? this.store.config(projectId).dispatch.max_parallel;
|
|
3995
|
+
for (const e of m.values()) {
|
|
3996
|
+
const running = [...m.values()].filter((x) => x.state === "running").length;
|
|
3997
|
+
if (running >= cap)
|
|
3998
|
+
return;
|
|
3999
|
+
if (e.state !== "queued")
|
|
4000
|
+
continue;
|
|
4001
|
+
await this.startOne(projectId, { id: e.task, title: e.title });
|
|
4002
|
+
}
|
|
4003
|
+
}
|
|
4004
|
+
async onRunEnd(run2) {
|
|
4005
|
+
const m = this.entries.get(run2.projectId);
|
|
4006
|
+
const e = m?.get(run2.task);
|
|
4007
|
+
if (!e || e.runId !== run2.id)
|
|
4008
|
+
return;
|
|
4009
|
+
const cfg = this.store.config(run2.projectId);
|
|
4010
|
+
const required = cfg.gates.required;
|
|
4011
|
+
let runs = this.store.gateRuns(run2.projectId, run2.task);
|
|
4012
|
+
const status = this.store.gateStatusFor(runs, required);
|
|
4013
|
+
const missing = required.filter((g) => cfg.gates.defs[g] && status.find((s) => s.gate === g)?.verdict !== "pass");
|
|
4014
|
+
if (missing.length && !run2.stopped) {
|
|
4015
|
+
await this.store.runGates(run2.projectId, run2.task, missing, {
|
|
4016
|
+
sessionId: run2.sessionId,
|
|
4017
|
+
owner: "dispatch"
|
|
4018
|
+
});
|
|
4019
|
+
runs = this.store.gateRuns(run2.projectId, run2.task);
|
|
4020
|
+
}
|
|
4021
|
+
const satisfied = gatesSatisfied(runs, required);
|
|
4022
|
+
await this.forge.refresh(0).catch(() => {});
|
|
4023
|
+
const branch = `task/${run2.task}`;
|
|
4024
|
+
const pr = this.forge.prs().find((p) => p.projectId === run2.projectId && p.branch === branch);
|
|
4025
|
+
const outcome = dispatchOutcome({
|
|
4026
|
+
exitCode: run2.exitCode,
|
|
4027
|
+
isError: run2.result?.isError ?? false,
|
|
4028
|
+
gatesSatisfied: satisfied,
|
|
4029
|
+
prOpen: Boolean(pr),
|
|
4030
|
+
requirePr: cfg.dispatch.require_pr,
|
|
4031
|
+
stopped: run2.stopped ?? false
|
|
4032
|
+
});
|
|
4033
|
+
const verdicts = this.store.gateStatusFor(runs, required).map((s) => `${s.gate} ${s.verdict ?? "\u2014"}`).join(", ");
|
|
4034
|
+
const detail = [
|
|
4035
|
+
`exit ${run2.exitCode}${run2.result?.isError ? " (error)" : ""}`,
|
|
4036
|
+
required.length ? `gates: ${verdicts}` : null,
|
|
4037
|
+
pr ? `PR ${pr.url}` : cfg.dispatch.require_pr ? "no PR" : null
|
|
4038
|
+
].filter(Boolean).join(" \xB7 ");
|
|
4039
|
+
e.state = "finished";
|
|
4040
|
+
e.endedAt = run2.endedAt;
|
|
4041
|
+
e.outcome = outcome;
|
|
4042
|
+
e.detail = detail;
|
|
4043
|
+
e.costUsd = run2.result?.costUsd ?? null;
|
|
4044
|
+
const ts = run2.endedAt ?? new Date().toISOString();
|
|
4045
|
+
this.store.append({
|
|
4046
|
+
ts,
|
|
4047
|
+
type: "dispatch.finished",
|
|
4048
|
+
projectId: run2.projectId,
|
|
4049
|
+
sessionId: run2.sessionId,
|
|
4050
|
+
payload: {
|
|
4051
|
+
task: run2.task,
|
|
4052
|
+
runId: run2.id,
|
|
4053
|
+
outcome,
|
|
4054
|
+
detail,
|
|
4055
|
+
costUsd: e.costUsd,
|
|
4056
|
+
summary: `dispatch ${run2.task}: ${outcome} \u2014 ${detail}`
|
|
4057
|
+
}
|
|
4058
|
+
});
|
|
4059
|
+
if (outcome !== "done" && outcome !== "stopped")
|
|
4060
|
+
this.store.append({
|
|
4061
|
+
ts,
|
|
4062
|
+
type: "incident.opened",
|
|
4063
|
+
projectId: run2.projectId,
|
|
4064
|
+
sessionId: run2.sessionId,
|
|
4065
|
+
payload: {
|
|
4066
|
+
rule: "dispatch_failed",
|
|
4067
|
+
action: outcome,
|
|
4068
|
+
command: run2.task,
|
|
4069
|
+
reason: `dispatched run on ${run2.task} ended ${outcome}: ${detail}. The worktree and claim are kept; resume it from the session page or release it.`
|
|
4070
|
+
}
|
|
4071
|
+
});
|
|
4072
|
+
this.store.touch();
|
|
4073
|
+
await this.fill(run2.projectId);
|
|
4074
|
+
}
|
|
4075
|
+
clear(projectId, task) {
|
|
4076
|
+
const m = this.project(projectId);
|
|
4077
|
+
let n = 0;
|
|
4078
|
+
for (const [id, e] of m) {
|
|
4079
|
+
if (task && id !== task)
|
|
4080
|
+
continue;
|
|
4081
|
+
if (e.state === "queued" || e.state === "finished" && !task) {
|
|
4082
|
+
m.delete(id);
|
|
4083
|
+
n++;
|
|
4084
|
+
}
|
|
4085
|
+
}
|
|
4086
|
+
return n;
|
|
4087
|
+
}
|
|
4088
|
+
}
|
|
4089
|
+
|
|
4090
|
+
// packages/daemon/src/forge.ts
|
|
4091
|
+
import { existsSync as existsSync3 } from "fs";
|
|
4092
|
+
import { homedir as homedir2 } from "os";
|
|
4093
|
+
import { join as join4 } from "path";
|
|
4094
|
+
var EXTRA_BIN_DIRS = [
|
|
4095
|
+
"/opt/homebrew/bin",
|
|
4096
|
+
"/usr/local/bin",
|
|
4097
|
+
"/home/linuxbrew/.linuxbrew/bin",
|
|
4098
|
+
join4(homedir2(), ".local", "bin"),
|
|
4099
|
+
join4(homedir2(), "bin")
|
|
4100
|
+
];
|
|
4101
|
+
function findBin(name) {
|
|
4102
|
+
if (!name)
|
|
4103
|
+
return null;
|
|
4104
|
+
const onPath = Bun.which(name, { PATH: process.env.PATH ?? "" });
|
|
4105
|
+
if (onPath)
|
|
4106
|
+
return onPath;
|
|
4107
|
+
for (const d of EXTRA_BIN_DIRS) {
|
|
4108
|
+
const p = join4(d, name);
|
|
4109
|
+
if (existsSync3(p))
|
|
4110
|
+
return p;
|
|
4111
|
+
}
|
|
4112
|
+
return null;
|
|
4113
|
+
}
|
|
4114
|
+
var GH_FIELDS = "number,title,headRefName,url,author,isDraft,mergeable,reviewDecision,statusCheckRollup,createdAt";
|
|
4115
|
+
|
|
4116
|
+
class ForgeService {
|
|
4117
|
+
store;
|
|
4118
|
+
cache = new Map;
|
|
4119
|
+
inflight = new Set;
|
|
4120
|
+
constructor(store) {
|
|
4121
|
+
this.store = store;
|
|
4122
|
+
}
|
|
4123
|
+
prs() {
|
|
4124
|
+
this.refresh();
|
|
4125
|
+
const all = [...this.cache.values()].flatMap((c) => c.prs);
|
|
4126
|
+
return all.sort((a, b) => a.createdAt < b.createdAt ? 1 : -1);
|
|
4127
|
+
}
|
|
4128
|
+
async refresh(maxAgeMs = 120000) {
|
|
4129
|
+
const projects = this.store.projects();
|
|
4130
|
+
await Promise.all(projects.map(async (p) => {
|
|
4131
|
+
const hit = this.cache.get(p.id);
|
|
4132
|
+
if (hit && Date.now() - hit.at < maxAgeMs)
|
|
4133
|
+
return;
|
|
4134
|
+
if (this.inflight.has(p.id))
|
|
4135
|
+
return;
|
|
4136
|
+
this.inflight.add(p.id);
|
|
4137
|
+
try {
|
|
4138
|
+
const prs = await this.poll(p.id, p.root);
|
|
4139
|
+
this.cache.set(p.id, { at: Date.now(), prs });
|
|
4140
|
+
} catch {
|
|
4141
|
+
this.cache.set(p.id, { at: Date.now(), prs: this.cache.get(p.id)?.prs ?? [] });
|
|
4142
|
+
} finally {
|
|
4143
|
+
this.inflight.delete(p.id);
|
|
4144
|
+
}
|
|
4145
|
+
}));
|
|
4146
|
+
}
|
|
4147
|
+
remote(root) {
|
|
4148
|
+
const r = Bun.spawnSync(["git", "-C", root, "remote", "get-url", "origin"]);
|
|
4149
|
+
if (r.exitCode !== 0)
|
|
4150
|
+
return null;
|
|
4151
|
+
return parseRemote(new TextDecoder().decode(r.stdout).trim());
|
|
4152
|
+
}
|
|
4153
|
+
async run(cmd, cwd) {
|
|
4154
|
+
const bin = findBin(cmd[0]);
|
|
4155
|
+
if (!bin)
|
|
4156
|
+
return null;
|
|
4157
|
+
const proc = Bun.spawn([bin, ...cmd.slice(1)], { cwd, stdout: "pipe", stderr: "ignore" });
|
|
4158
|
+
const out = await new Response(proc.stdout).text();
|
|
3030
4159
|
return await proc.exited === 0 ? out : null;
|
|
3031
4160
|
}
|
|
3032
4161
|
async poll(projectId, root) {
|
|
@@ -3045,6 +4174,69 @@ class ForgeService {
|
|
|
3045
4174
|
}
|
|
3046
4175
|
return prs.map((pr) => ({ ...pr, projectId, projectRoot: root }));
|
|
3047
4176
|
}
|
|
4177
|
+
async openPR(projectId, worktree2, draft) {
|
|
4178
|
+
const p = this.store.projects().find((x) => x.id === projectId);
|
|
4179
|
+
if (!p)
|
|
4180
|
+
return { ok: false, error: "unknown project" };
|
|
4181
|
+
if (worktree2.main)
|
|
4182
|
+
return { ok: false, error: "that is the main checkout \u2014 open the PR from a task worktree" };
|
|
4183
|
+
if (!worktree2.branch)
|
|
4184
|
+
return { ok: false, error: "detached HEAD \u2014 check out a branch first" };
|
|
4185
|
+
if (worktree2.dirty > 0)
|
|
4186
|
+
return {
|
|
4187
|
+
ok: false,
|
|
4188
|
+
error: `${worktree2.path} has uncommitted changes \u2014 commit them first (Swarm never commits for you)`
|
|
4189
|
+
};
|
|
4190
|
+
const remote = this.remote(p.root);
|
|
4191
|
+
if (!remote)
|
|
4192
|
+
return { ok: false, error: "no GitHub/GitLab remote on origin" };
|
|
4193
|
+
const cli = remote.forge === "github" ? "gh" : "glab";
|
|
4194
|
+
const bin = findBin(cli);
|
|
4195
|
+
if (!bin)
|
|
4196
|
+
return { ok: false, error: `${cli} is not installed` };
|
|
4197
|
+
const sh = async (cmd2, cwd) => {
|
|
4198
|
+
const proc = Bun.spawn(cmd2, { cwd, stdout: "pipe", stderr: "pipe" });
|
|
4199
|
+
const out = await new Response(proc.stdout).text() + await new Response(proc.stderr).text();
|
|
4200
|
+
return { ok: await proc.exited === 0, out: out.trim() };
|
|
4201
|
+
};
|
|
4202
|
+
const push = await sh(["git", "push", "-u", "origin", worktree2.branch], worktree2.path);
|
|
4203
|
+
if (!push.ok)
|
|
4204
|
+
return { ok: false, error: `git push failed: ${push.out.slice(0, 400)}` };
|
|
4205
|
+
const existing = this.prs().find((x) => x.projectId === projectId && x.branch === worktree2.branch);
|
|
4206
|
+
if (existing)
|
|
4207
|
+
return { ok: true, url: existing.url, number: existing.number };
|
|
4208
|
+
const cmd = remote.forge === "github" ? [
|
|
4209
|
+
bin,
|
|
4210
|
+
"pr",
|
|
4211
|
+
"create",
|
|
4212
|
+
"--head",
|
|
4213
|
+
worktree2.branch,
|
|
4214
|
+
"--title",
|
|
4215
|
+
draft.title,
|
|
4216
|
+
"--body",
|
|
4217
|
+
draft.body,
|
|
4218
|
+
...draft.isDraft ? ["--draft"] : []
|
|
4219
|
+
] : [
|
|
4220
|
+
bin,
|
|
4221
|
+
"mr",
|
|
4222
|
+
"create",
|
|
4223
|
+
"--source-branch",
|
|
4224
|
+
worktree2.branch,
|
|
4225
|
+
"--title",
|
|
4226
|
+
draft.title,
|
|
4227
|
+
"--description",
|
|
4228
|
+
draft.body,
|
|
4229
|
+
"--yes",
|
|
4230
|
+
...draft.isDraft ? ["--draft"] : []
|
|
4231
|
+
];
|
|
4232
|
+
const r = await sh(cmd, worktree2.path);
|
|
4233
|
+
if (!r.ok)
|
|
4234
|
+
return { ok: false, error: `${cli} failed: ${r.out.slice(0, 400)}` };
|
|
4235
|
+
const url = r.out.match(/https?:\/\/\S+/)?.[0] ?? r.out;
|
|
4236
|
+
const num = Number(url.match(/\/(\d+)\s*$/)?.[1]);
|
|
4237
|
+
this.cache.delete(projectId);
|
|
4238
|
+
return { ok: true, url, number: Number.isFinite(num) ? num : null };
|
|
4239
|
+
}
|
|
3048
4240
|
async merge(projectId, number) {
|
|
3049
4241
|
const p = this.store.projects().find((x) => x.id === projectId);
|
|
3050
4242
|
if (!p)
|
|
@@ -3065,9 +4257,216 @@ class ForgeService {
|
|
|
3065
4257
|
}
|
|
3066
4258
|
}
|
|
3067
4259
|
|
|
4260
|
+
// packages/daemon/src/git.ts
|
|
4261
|
+
import { realpathSync } from "fs";
|
|
4262
|
+
import { join as join5 } from "path";
|
|
4263
|
+
function git(cwd, args) {
|
|
4264
|
+
try {
|
|
4265
|
+
const r = Bun.spawnSync(["git", "-C", cwd, ...args], { stdout: "pipe", stderr: "ignore" });
|
|
4266
|
+
return r.exitCode === 0 ? r.stdout.toString() : null;
|
|
4267
|
+
} catch {
|
|
4268
|
+
return null;
|
|
4269
|
+
}
|
|
4270
|
+
}
|
|
4271
|
+
function gitCommonDir(cwd) {
|
|
4272
|
+
const out = git(cwd, ["rev-parse", "--git-common-dir"])?.trim();
|
|
4273
|
+
if (!out)
|
|
4274
|
+
return null;
|
|
4275
|
+
try {
|
|
4276
|
+
return realpathSync(out.startsWith("/") ? out : join5(cwd, out));
|
|
4277
|
+
} catch {
|
|
4278
|
+
return null;
|
|
4279
|
+
}
|
|
4280
|
+
}
|
|
4281
|
+
function gitToplevel(cwd) {
|
|
4282
|
+
const out = git(cwd, ["rev-parse", "--show-toplevel"])?.trim();
|
|
4283
|
+
if (!out)
|
|
4284
|
+
return null;
|
|
4285
|
+
try {
|
|
4286
|
+
return realpathSync(out);
|
|
4287
|
+
} catch {
|
|
4288
|
+
return null;
|
|
4289
|
+
}
|
|
4290
|
+
}
|
|
4291
|
+
function parseWorktreeList(out) {
|
|
4292
|
+
const wts = [];
|
|
4293
|
+
let cur = null;
|
|
4294
|
+
const flush = () => {
|
|
4295
|
+
if (cur?.path) {
|
|
4296
|
+
wts.push({
|
|
4297
|
+
path: cur.path,
|
|
4298
|
+
branch: cur.branch ?? null,
|
|
4299
|
+
head: (cur.head ?? "").slice(0, 7),
|
|
4300
|
+
main: wts.length === 0,
|
|
4301
|
+
dirty: -1,
|
|
4302
|
+
ahead: -1,
|
|
4303
|
+
behind: -1,
|
|
4304
|
+
merged: false
|
|
4305
|
+
});
|
|
4306
|
+
}
|
|
4307
|
+
cur = null;
|
|
4308
|
+
};
|
|
4309
|
+
for (const line of out.split(`
|
|
4310
|
+
`)) {
|
|
4311
|
+
if (line.startsWith("worktree ")) {
|
|
4312
|
+
flush();
|
|
4313
|
+
cur = { path: line.slice(9) };
|
|
4314
|
+
} else if (line.startsWith("HEAD ") && cur)
|
|
4315
|
+
cur.head = line.slice(5);
|
|
4316
|
+
else if (line.startsWith("branch ") && cur)
|
|
4317
|
+
cur.branch = line.slice(7).replace(/^refs\/heads\//, "");
|
|
4318
|
+
else if (line === "")
|
|
4319
|
+
flush();
|
|
4320
|
+
}
|
|
4321
|
+
flush();
|
|
4322
|
+
return wts;
|
|
4323
|
+
}
|
|
4324
|
+
function applyStatus(w, st, ah) {
|
|
4325
|
+
w.dirty = st === null ? -1 : st.split(`
|
|
4326
|
+
`).filter(Boolean).length;
|
|
4327
|
+
const a = ah?.trim();
|
|
4328
|
+
w.ahead = a === undefined || a === "" ? -1 : Number(a);
|
|
4329
|
+
}
|
|
4330
|
+
function applyDrift(w, behind, ancestor, firstParents) {
|
|
4331
|
+
const b = behind?.trim();
|
|
4332
|
+
w.behind = b === undefined || b === "" ? -1 : Number(b);
|
|
4333
|
+
const onLine = firstParents?.split(`
|
|
4334
|
+
`).some((sha) => sha.startsWith(w.head)) ?? true;
|
|
4335
|
+
w.merged = ancestor && !onLine;
|
|
4336
|
+
}
|
|
4337
|
+
var FIRST_PARENT_DEPTH = "5000";
|
|
4338
|
+
var baseOf = (wts) => wts[0]?.main ? wts[0].branch : null;
|
|
4339
|
+
async function gitAsync(cwd, args) {
|
|
4340
|
+
try {
|
|
4341
|
+
const p = Bun.spawn(["git", "-C", cwd, ...args], { stdout: "pipe", stderr: "ignore" });
|
|
4342
|
+
const [out, code] = await Promise.all([new Response(p.stdout).text(), p.exited]);
|
|
4343
|
+
return code === 0 ? out : null;
|
|
4344
|
+
} catch {
|
|
4345
|
+
return null;
|
|
4346
|
+
}
|
|
4347
|
+
}
|
|
4348
|
+
async function listWorktreesAsync(root) {
|
|
4349
|
+
const out = await gitAsync(root, ["worktree", "list", "--porcelain"]);
|
|
4350
|
+
if (!out)
|
|
4351
|
+
return [];
|
|
4352
|
+
const wts = parseWorktreeList(out);
|
|
4353
|
+
const base = baseOf(wts);
|
|
4354
|
+
const line = base ? await gitAsync(root, ["rev-list", "--first-parent", "-n", FIRST_PARENT_DEPTH, base]) : null;
|
|
4355
|
+
await Promise.all(wts.map(async (w) => {
|
|
4356
|
+
const drift = base && !w.main;
|
|
4357
|
+
const [st, ah, be, mg] = await Promise.all([
|
|
4358
|
+
gitAsync(w.path, ["status", "--porcelain", "--untracked-files=no"]),
|
|
4359
|
+
gitAsync(w.path, ["rev-list", "--count", "@{upstream}..HEAD"]),
|
|
4360
|
+
drift ? gitAsync(w.path, ["rev-list", "--count", `HEAD..${base}`]) : null,
|
|
4361
|
+
drift ? gitAsync(w.path, ["merge-base", "--is-ancestor", "HEAD", base]) : null
|
|
4362
|
+
]);
|
|
4363
|
+
applyStatus(w, st, ah);
|
|
4364
|
+
if (drift)
|
|
4365
|
+
applyDrift(w, be, mg !== null, line);
|
|
4366
|
+
}));
|
|
4367
|
+
return wts;
|
|
4368
|
+
}
|
|
4369
|
+
var branchCache = new Map;
|
|
4370
|
+
function currentBranch(cwd) {
|
|
4371
|
+
const hit = branchCache.get(cwd);
|
|
4372
|
+
const now = Date.now();
|
|
4373
|
+
if (hit && now - hit.t < 5000)
|
|
4374
|
+
return hit.v;
|
|
4375
|
+
const v = git(cwd, ["rev-parse", "--abbrev-ref", "HEAD"])?.trim() ?? null;
|
|
4376
|
+
branchCache.set(cwd, { v: v === "HEAD" ? "(detached)" : v, t: now });
|
|
4377
|
+
return branchCache.get(cwd)?.v ?? null;
|
|
4378
|
+
}
|
|
4379
|
+
function worktreeAdd(repoRoot, path, branch, baseRef = "HEAD") {
|
|
4380
|
+
const branchExists = git(repoRoot, ["rev-parse", "--verify", "--quiet", `refs/heads/${branch}`]) !== null;
|
|
4381
|
+
const args = branchExists ? ["worktree", "add", path, branch] : ["worktree", "add", "-b", branch, path, baseRef];
|
|
4382
|
+
if (git(repoRoot, args) === null)
|
|
4383
|
+
return null;
|
|
4384
|
+
try {
|
|
4385
|
+
return realpathSync(path);
|
|
4386
|
+
} catch {
|
|
4387
|
+
return path;
|
|
4388
|
+
}
|
|
4389
|
+
}
|
|
4390
|
+
function worktreeRemove(repoRoot, path, force) {
|
|
4391
|
+
const args = ["worktree", "remove", path];
|
|
4392
|
+
if (force)
|
|
4393
|
+
args.push("--force");
|
|
4394
|
+
return git(repoRoot, args) !== null;
|
|
4395
|
+
}
|
|
4396
|
+
function heldWork(path) {
|
|
4397
|
+
const status = git(path, ["status", "--porcelain"]);
|
|
4398
|
+
const dirty = status !== null && status.trim().length > 0;
|
|
4399
|
+
const count = (args) => {
|
|
4400
|
+
const out = git(path, ["rev-list", "--count", ...args])?.trim();
|
|
4401
|
+
return out !== undefined && out !== "" ? Number(out) : 0;
|
|
4402
|
+
};
|
|
4403
|
+
let unpushed;
|
|
4404
|
+
if (git(path, ["rev-parse", "--verify", "--quiet", "@{upstream}"]) !== null) {
|
|
4405
|
+
unpushed = count(["@{upstream}..HEAD"]) > 0;
|
|
4406
|
+
} else {
|
|
4407
|
+
const baselines = ["--remotes"];
|
|
4408
|
+
for (const b of ["main", "master"]) {
|
|
4409
|
+
if (git(path, ["rev-parse", "--verify", "--quiet", `refs/heads/${b}`]) !== null)
|
|
4410
|
+
baselines.push(b);
|
|
4411
|
+
}
|
|
4412
|
+
unpushed = baselines.length > 1 ? count(["HEAD", "--not", ...baselines]) > 0 : false;
|
|
4413
|
+
}
|
|
4414
|
+
return { dirty, unpushed };
|
|
4415
|
+
}
|
|
4416
|
+
async function worktreeDiff(root, path) {
|
|
4417
|
+
const wts = parseWorktreeList(await gitAsync(root, ["worktree", "list", "--porcelain"]) ?? "");
|
|
4418
|
+
const baseRef = wts[0]?.path === realpathOr(root) || wts[0]?.main ? wts[0]?.branch ?? null : null;
|
|
4419
|
+
const isMain = wts[0]?.path === path;
|
|
4420
|
+
const mb = baseRef && !isMain ? (await gitAsync(path, ["merge-base", baseRef, "HEAD"]))?.trim() : null;
|
|
4421
|
+
const from = mb || "HEAD";
|
|
4422
|
+
const [numstat, names, log, status] = await Promise.all([
|
|
4423
|
+
gitAsync(path, ["diff", "--numstat", from]),
|
|
4424
|
+
gitAsync(path, ["diff", "--name-status", from]),
|
|
4425
|
+
mb ? gitAsync(path, ["log", "--format=%s", `${mb}..HEAD`]) : Promise.resolve(""),
|
|
4426
|
+
gitAsync(path, ["status", "--porcelain"])
|
|
4427
|
+
]);
|
|
4428
|
+
const files = parseNumstat(numstat ?? "", names ?? "");
|
|
4429
|
+
for (const line of (status ?? "").split(`
|
|
4430
|
+
`)) {
|
|
4431
|
+
if (line.startsWith("?? "))
|
|
4432
|
+
files.push({ path: line.slice(3), added: -1, deleted: -1, status: "?" });
|
|
4433
|
+
}
|
|
4434
|
+
return {
|
|
4435
|
+
base: mb ?? null,
|
|
4436
|
+
baseRef,
|
|
4437
|
+
files,
|
|
4438
|
+
commits: (log ?? "").split(`
|
|
4439
|
+
`).filter(Boolean),
|
|
4440
|
+
dirty: (status ?? "").trim().length > 0
|
|
4441
|
+
};
|
|
4442
|
+
}
|
|
4443
|
+
async function worktreePatch(path, base, file) {
|
|
4444
|
+
const from = base ?? "HEAD";
|
|
4445
|
+
if (file) {
|
|
4446
|
+
const tracked = await gitAsync(path, ["ls-files", "--error-unmatch", "--", file]) !== null;
|
|
4447
|
+
if (!tracked) {
|
|
4448
|
+
const p = Bun.spawn(["git", "-C", path, "diff", "--no-index", "--", "/dev/null", file], {
|
|
4449
|
+
stdout: "pipe",
|
|
4450
|
+
stderr: "ignore"
|
|
4451
|
+
});
|
|
4452
|
+
const [out] = await Promise.all([new Response(p.stdout).text(), p.exited]);
|
|
4453
|
+
return out;
|
|
4454
|
+
}
|
|
4455
|
+
return await gitAsync(path, ["diff", from, "--", file]) ?? "";
|
|
4456
|
+
}
|
|
4457
|
+
return await gitAsync(path, ["diff", from]) ?? "";
|
|
4458
|
+
}
|
|
4459
|
+
function realpathOr(p) {
|
|
4460
|
+
try {
|
|
4461
|
+
return realpathSync(p);
|
|
4462
|
+
} catch {
|
|
4463
|
+
return p;
|
|
4464
|
+
}
|
|
4465
|
+
}
|
|
4466
|
+
|
|
3068
4467
|
// packages/daemon/src/runner.ts
|
|
3069
4468
|
import { appendFileSync, mkdirSync as mkdirSync2, openSync } from "fs";
|
|
3070
|
-
import { join as
|
|
4469
|
+
import { join as join6 } from "path";
|
|
3071
4470
|
var PERMISSION_MODES = [
|
|
3072
4471
|
"acceptEdits",
|
|
3073
4472
|
"auto",
|
|
@@ -3081,6 +4480,11 @@ class Runner {
|
|
|
3081
4480
|
store;
|
|
3082
4481
|
home;
|
|
3083
4482
|
live = new Map;
|
|
4483
|
+
endListeners = new Set;
|
|
4484
|
+
onEnd(fn) {
|
|
4485
|
+
this.endListeners.add(fn);
|
|
4486
|
+
return () => this.endListeners.delete(fn);
|
|
4487
|
+
}
|
|
3084
4488
|
constructor(store, home) {
|
|
3085
4489
|
this.store = store;
|
|
3086
4490
|
this.home = home;
|
|
@@ -3111,18 +4515,19 @@ class Runner {
|
|
|
3111
4515
|
reason: `a run on ${input.task} is already live \u2014 stop it or send it input`
|
|
3112
4516
|
};
|
|
3113
4517
|
const held = this.store.claims(input.projectId).find((c) => c.task === input.task && c.state === "held" && c.owner === input.owner);
|
|
3114
|
-
let
|
|
3115
|
-
if (!
|
|
4518
|
+
let worktree2 = held?.worktree ?? "";
|
|
4519
|
+
if (!worktree2) {
|
|
3116
4520
|
const c = this.store.claim(input.projectId, input.task, input.owner);
|
|
3117
4521
|
if (!c.ok)
|
|
3118
4522
|
return { ok: false, reason: c.error };
|
|
3119
|
-
|
|
4523
|
+
worktree2 = c.worktree;
|
|
3120
4524
|
}
|
|
4525
|
+
await this.store.awaitBootstrap(worktree2);
|
|
3121
4526
|
const sessionId = crypto.randomUUID();
|
|
3122
4527
|
const id = sessionId.slice(0, 8);
|
|
3123
|
-
const logDir =
|
|
4528
|
+
const logDir = join6(this.home, "logs", project.id);
|
|
3124
4529
|
mkdirSync2(logDir, { recursive: true });
|
|
3125
|
-
const log =
|
|
4530
|
+
const log = join6(logDir, `run-${input.task.replace(/[^a-zA-Z0-9_.-]+/g, "-")}-${id}.log`);
|
|
3126
4531
|
const logFd = openSync(log, "a");
|
|
3127
4532
|
const args = [
|
|
3128
4533
|
bin,
|
|
@@ -3141,13 +4546,22 @@ class Runner {
|
|
|
3141
4546
|
args.push("--model", input.model);
|
|
3142
4547
|
if (input.permissionMode)
|
|
3143
4548
|
args.push("--permission-mode", input.permissionMode);
|
|
3144
|
-
|
|
3145
|
-
|
|
4549
|
+
const profile = runProfile(input.profile);
|
|
4550
|
+
if (input.profile && !profile)
|
|
4551
|
+
return {
|
|
4552
|
+
ok: false,
|
|
4553
|
+
reason: `unknown profile ${input.profile} \u2014 one of ${Object.keys(RUN_PROFILES).join(", ")}`
|
|
4554
|
+
};
|
|
4555
|
+
const allowed = [...input.allowedTools ?? [], ...profile?.allowedTools ?? []];
|
|
4556
|
+
if (allowed.length)
|
|
4557
|
+
args.push("--allowedTools", ...allowed);
|
|
4558
|
+
if (profile?.disallowedTools.length)
|
|
4559
|
+
args.push("--disallowedTools", ...profile.disallowedTools);
|
|
3146
4560
|
if (input.maxTurns)
|
|
3147
4561
|
args.push("--max-turns", String(input.maxTurns));
|
|
3148
|
-
this.store.preregisterSpawnedSession(sessionId, project.id,
|
|
4562
|
+
this.store.preregisterSpawnedSession(sessionId, project.id, worktree2, input.task);
|
|
3149
4563
|
const proc = Bun.spawn(args, {
|
|
3150
|
-
cwd:
|
|
4564
|
+
cwd: worktree2,
|
|
3151
4565
|
env: { ...process.env, SWARM_RUN_ID: id, SWARM_OWNER: input.owner },
|
|
3152
4566
|
stdin: "pipe",
|
|
3153
4567
|
stdout: "pipe",
|
|
@@ -3158,11 +4572,12 @@ class Runner {
|
|
|
3158
4572
|
sessionId,
|
|
3159
4573
|
projectId: project.id,
|
|
3160
4574
|
task: input.task,
|
|
3161
|
-
worktree,
|
|
4575
|
+
worktree: worktree2,
|
|
3162
4576
|
pid: proc.pid,
|
|
3163
4577
|
owner: input.owner,
|
|
3164
4578
|
model: input.model ?? null,
|
|
3165
4579
|
permissionMode: input.permissionMode ?? null,
|
|
4580
|
+
profile: input.profile ?? null,
|
|
3166
4581
|
prompt: input.prompt,
|
|
3167
4582
|
log,
|
|
3168
4583
|
startedAt: new Date().toISOString(),
|
|
@@ -3178,7 +4593,7 @@ class Runner {
|
|
|
3178
4593
|
sessionId,
|
|
3179
4594
|
kind: "proc",
|
|
3180
4595
|
name: `run:${input.task}`,
|
|
3181
|
-
cwd:
|
|
4596
|
+
cwd: worktree2,
|
|
3182
4597
|
cmd: `claude -p (run ${id})`,
|
|
3183
4598
|
owner: input.owner,
|
|
3184
4599
|
log
|
|
@@ -3243,6 +4658,13 @@ class Runner {
|
|
|
3243
4658
|
});
|
|
3244
4659
|
this.store.endSpawnedSession(entry.run.sessionId);
|
|
3245
4660
|
this.live.delete(id);
|
|
4661
|
+
for (const fn of this.endListeners) {
|
|
4662
|
+
try {
|
|
4663
|
+
fn(entry.run);
|
|
4664
|
+
} catch (e) {
|
|
4665
|
+
console.error("swarm run: onEnd listener failed:", e.message);
|
|
4666
|
+
}
|
|
4667
|
+
}
|
|
3246
4668
|
}
|
|
3247
4669
|
onLine(run2, line) {
|
|
3248
4670
|
if (!line.startsWith("{"))
|
|
@@ -3366,6 +4788,7 @@ class Runner {
|
|
|
3366
4788
|
if (!run2)
|
|
3367
4789
|
return { ok: false, reason: "no live run" };
|
|
3368
4790
|
const entry = this.live.get(run2.id);
|
|
4791
|
+
run2.stopped = true;
|
|
3369
4792
|
try {
|
|
3370
4793
|
const stdin = entry?.proc.stdin;
|
|
3371
4794
|
if (stdin && typeof stdin !== "number")
|
|
@@ -3382,157 +4805,67 @@ class Runner {
|
|
|
3382
4805
|
import { Database } from "bun:sqlite";
|
|
3383
4806
|
import {
|
|
3384
4807
|
closeSync,
|
|
3385
|
-
existsSync as
|
|
3386
|
-
mkdirSync as
|
|
3387
|
-
openSync as
|
|
4808
|
+
existsSync as existsSync5,
|
|
4809
|
+
mkdirSync as mkdirSync4,
|
|
4810
|
+
openSync as openSync3,
|
|
3388
4811
|
readdirSync,
|
|
3389
4812
|
readFileSync as readFileSync3,
|
|
3390
4813
|
readSync,
|
|
3391
4814
|
realpathSync as realpathSync2,
|
|
3392
4815
|
renameSync,
|
|
3393
4816
|
statSync,
|
|
4817
|
+
unlinkSync,
|
|
3394
4818
|
writeFileSync as writeFileSync2
|
|
3395
4819
|
} from "fs";
|
|
3396
|
-
import { homedir as homedir3 } from "os";
|
|
3397
|
-
import { basename, dirname, join as
|
|
4820
|
+
import { homedir as homedir3, tmpdir, userInfo } from "os";
|
|
4821
|
+
import { basename, dirname as dirname2, join as join8 } from "path";
|
|
3398
4822
|
|
|
3399
|
-
// packages/daemon/src/
|
|
3400
|
-
import {
|
|
3401
|
-
import { join as
|
|
3402
|
-
function
|
|
3403
|
-
|
|
3404
|
-
|
|
3405
|
-
|
|
3406
|
-
|
|
3407
|
-
|
|
3408
|
-
|
|
3409
|
-
|
|
3410
|
-
|
|
3411
|
-
|
|
3412
|
-
if (!out)
|
|
3413
|
-
return null;
|
|
3414
|
-
try {
|
|
3415
|
-
return realpathSync(out.startsWith("/") ? out : join5(cwd, out));
|
|
3416
|
-
} catch {
|
|
3417
|
-
return null;
|
|
3418
|
-
}
|
|
3419
|
-
}
|
|
3420
|
-
function gitToplevel(cwd) {
|
|
3421
|
-
const out = git(cwd, ["rev-parse", "--show-toplevel"])?.trim();
|
|
3422
|
-
if (!out)
|
|
3423
|
-
return null;
|
|
3424
|
-
try {
|
|
3425
|
-
return realpathSync(out);
|
|
3426
|
-
} catch {
|
|
3427
|
-
return null;
|
|
3428
|
-
}
|
|
3429
|
-
}
|
|
3430
|
-
function parseWorktreeList(out) {
|
|
3431
|
-
const wts = [];
|
|
3432
|
-
let cur = null;
|
|
3433
|
-
const flush = () => {
|
|
3434
|
-
if (cur?.path) {
|
|
3435
|
-
wts.push({
|
|
3436
|
-
path: cur.path,
|
|
3437
|
-
branch: cur.branch ?? null,
|
|
3438
|
-
head: (cur.head ?? "").slice(0, 7),
|
|
3439
|
-
main: wts.length === 0,
|
|
3440
|
-
dirty: -1,
|
|
3441
|
-
ahead: -1
|
|
3442
|
-
});
|
|
4823
|
+
// packages/daemon/src/bootstrap.ts
|
|
4824
|
+
import { cpSync, existsSync as existsSync4, mkdirSync as mkdirSync3, openSync as openSync2 } from "fs";
|
|
4825
|
+
import { dirname, join as join7 } from "path";
|
|
4826
|
+
function runBootstrap(plan, opts) {
|
|
4827
|
+
const logDir = join7(opts.home, "logs", opts.projectId);
|
|
4828
|
+
mkdirSync3(logDir, { recursive: true });
|
|
4829
|
+
const log = join7(logDir, `bootstrap-${opts.task.replace(/[^a-zA-Z0-9_.-]+/g, "-")}.log`);
|
|
4830
|
+
const copied = [];
|
|
4831
|
+
const skipped = [];
|
|
4832
|
+
for (const c of plan.copies) {
|
|
4833
|
+
if (!existsSync4(c.from)) {
|
|
4834
|
+
skipped.push(c.rel);
|
|
4835
|
+
continue;
|
|
3443
4836
|
}
|
|
3444
|
-
|
|
3445
|
-
|
|
3446
|
-
|
|
3447
|
-
|
|
3448
|
-
|
|
3449
|
-
|
|
3450
|
-
cur = { path: line.slice(9) };
|
|
3451
|
-
} else if (line.startsWith("HEAD ") && cur)
|
|
3452
|
-
cur.head = line.slice(5);
|
|
3453
|
-
else if (line.startsWith("branch ") && cur)
|
|
3454
|
-
cur.branch = line.slice(7).replace(/^refs\/heads\//, "");
|
|
3455
|
-
else if (line === "")
|
|
3456
|
-
flush();
|
|
3457
|
-
}
|
|
3458
|
-
flush();
|
|
3459
|
-
return wts;
|
|
3460
|
-
}
|
|
3461
|
-
function applyStatus(w, st, ah) {
|
|
3462
|
-
w.dirty = st === null ? -1 : st.split(`
|
|
3463
|
-
`).filter(Boolean).length;
|
|
3464
|
-
const a = ah?.trim();
|
|
3465
|
-
w.ahead = a === undefined || a === "" ? -1 : Number(a);
|
|
3466
|
-
}
|
|
3467
|
-
async function gitAsync(cwd, args) {
|
|
3468
|
-
try {
|
|
3469
|
-
const p = Bun.spawn(["git", "-C", cwd, ...args], { stdout: "pipe", stderr: "ignore" });
|
|
3470
|
-
const [out, code] = await Promise.all([new Response(p.stdout).text(), p.exited]);
|
|
3471
|
-
return code === 0 ? out : null;
|
|
3472
|
-
} catch {
|
|
3473
|
-
return null;
|
|
3474
|
-
}
|
|
3475
|
-
}
|
|
3476
|
-
async function listWorktreesAsync(root) {
|
|
3477
|
-
const out = await gitAsync(root, ["worktree", "list", "--porcelain"]);
|
|
3478
|
-
if (!out)
|
|
3479
|
-
return [];
|
|
3480
|
-
const wts = parseWorktreeList(out);
|
|
3481
|
-
await Promise.all(wts.map(async (w) => {
|
|
3482
|
-
const [st, ah] = await Promise.all([
|
|
3483
|
-
gitAsync(w.path, ["status", "--porcelain", "--untracked-files=no"]),
|
|
3484
|
-
gitAsync(w.path, ["rev-list", "--count", "@{upstream}..HEAD"])
|
|
3485
|
-
]);
|
|
3486
|
-
applyStatus(w, st, ah);
|
|
3487
|
-
}));
|
|
3488
|
-
return wts;
|
|
3489
|
-
}
|
|
3490
|
-
var branchCache = new Map;
|
|
3491
|
-
function currentBranch(cwd) {
|
|
3492
|
-
const hit = branchCache.get(cwd);
|
|
3493
|
-
const now = Date.now();
|
|
3494
|
-
if (hit && now - hit.t < 5000)
|
|
3495
|
-
return hit.v;
|
|
3496
|
-
const v = git(cwd, ["rev-parse", "--abbrev-ref", "HEAD"])?.trim() ?? null;
|
|
3497
|
-
branchCache.set(cwd, { v: v === "HEAD" ? "(detached)" : v, t: now });
|
|
3498
|
-
return branchCache.get(cwd)?.v ?? null;
|
|
3499
|
-
}
|
|
3500
|
-
function worktreeAdd(repoRoot, path, branch, baseRef = "HEAD") {
|
|
3501
|
-
const branchExists = git(repoRoot, ["rev-parse", "--verify", "--quiet", `refs/heads/${branch}`]) !== null;
|
|
3502
|
-
const args = branchExists ? ["worktree", "add", path, branch] : ["worktree", "add", "-b", branch, path, baseRef];
|
|
3503
|
-
if (git(repoRoot, args) === null)
|
|
3504
|
-
return null;
|
|
3505
|
-
try {
|
|
3506
|
-
return realpathSync(path);
|
|
3507
|
-
} catch {
|
|
3508
|
-
return path;
|
|
3509
|
-
}
|
|
3510
|
-
}
|
|
3511
|
-
function worktreeRemove(repoRoot, path, force) {
|
|
3512
|
-
const args = ["worktree", "remove", path];
|
|
3513
|
-
if (force)
|
|
3514
|
-
args.push("--force");
|
|
3515
|
-
return git(repoRoot, args) !== null;
|
|
3516
|
-
}
|
|
3517
|
-
function heldWork(path) {
|
|
3518
|
-
const status = git(path, ["status", "--porcelain"]);
|
|
3519
|
-
const dirty = status !== null && status.trim().length > 0;
|
|
3520
|
-
const count = (args) => {
|
|
3521
|
-
const out = git(path, ["rev-list", "--count", ...args])?.trim();
|
|
3522
|
-
return out !== undefined && out !== "" ? Number(out) : 0;
|
|
3523
|
-
};
|
|
3524
|
-
let unpushed;
|
|
3525
|
-
if (git(path, ["rev-parse", "--verify", "--quiet", "@{upstream}"]) !== null) {
|
|
3526
|
-
unpushed = count(["@{upstream}..HEAD"]) > 0;
|
|
3527
|
-
} else {
|
|
3528
|
-
const baselines = ["--remotes"];
|
|
3529
|
-
for (const b of ["main", "master"]) {
|
|
3530
|
-
if (git(path, ["rev-parse", "--verify", "--quiet", `refs/heads/${b}`]) !== null)
|
|
3531
|
-
baselines.push(b);
|
|
4837
|
+
try {
|
|
4838
|
+
mkdirSync3(dirname(c.to), { recursive: true });
|
|
4839
|
+
cpSync(c.from, c.to, { recursive: true, force: true });
|
|
4840
|
+
copied.push(c.rel);
|
|
4841
|
+
} catch (e) {
|
|
4842
|
+
skipped.push(`${c.rel} (${e.message})`);
|
|
3532
4843
|
}
|
|
3533
|
-
unpushed = baselines.length > 1 ? count(["HEAD", "--not", ...baselines]) > 0 : false;
|
|
3534
4844
|
}
|
|
3535
|
-
|
|
4845
|
+
const done = (async () => {
|
|
4846
|
+
if (!plan.setup)
|
|
4847
|
+
return { copied, skipped, setup: null };
|
|
4848
|
+
const command = plan.setup;
|
|
4849
|
+
const started = Date.now();
|
|
4850
|
+
let exitCode = -1;
|
|
4851
|
+
try {
|
|
4852
|
+
const fd = openSync2(log, "a");
|
|
4853
|
+
const proc = Bun.spawn(["sh", "-c", command], {
|
|
4854
|
+
cwd: opts.worktree,
|
|
4855
|
+
stdin: "ignore",
|
|
4856
|
+
stdout: fd,
|
|
4857
|
+
stderr: fd,
|
|
4858
|
+
env: { ...process.env, SWARM_WORKTREE: opts.worktree, SWARM_TASK: opts.task }
|
|
4859
|
+
});
|
|
4860
|
+
exitCode = await proc.exited;
|
|
4861
|
+
} catch (e) {
|
|
4862
|
+
exitCode = -1;
|
|
4863
|
+
await Bun.write(log, `swarm: could not start setup: ${e.message}
|
|
4864
|
+
`);
|
|
4865
|
+
}
|
|
4866
|
+
return { copied, skipped, setup: { command, exitCode, durationMs: Date.now() - started } };
|
|
4867
|
+
})();
|
|
4868
|
+
return { log, done };
|
|
3536
4869
|
}
|
|
3537
4870
|
|
|
3538
4871
|
// packages/daemon/src/task-sources.ts
|
|
@@ -3658,6 +4991,12 @@ CREATE TABLE IF NOT EXISTS handoffs (
|
|
|
3658
4991
|
files TEXT, verify TEXT, by TEXT, session_id TEXT, created_at TEXT
|
|
3659
4992
|
);
|
|
3660
4993
|
CREATE INDEX IF NOT EXISTS handoffs_task ON handoffs(project_id, task, created_at);
|
|
4994
|
+
CREATE TABLE IF NOT EXISTS messages (
|
|
4995
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT, project_id TEXT, session_id TEXT, task TEXT, kind TEXT,
|
|
4996
|
+
text TEXT, options TEXT, asked_by TEXT, created_at TEXT,
|
|
4997
|
+
answer TEXT, answered_by TEXT, answered_at TEXT, delivered_at TEXT
|
|
4998
|
+
);
|
|
4999
|
+
CREATE INDEX IF NOT EXISTS messages_open ON messages(project_id, answered_at, delivered_at);
|
|
3661
5000
|
CREATE TABLE IF NOT EXISTS claims (
|
|
3662
5001
|
project_id TEXT, task TEXT, owner TEXT, worktree TEXT, branch TEXT,
|
|
3663
5002
|
acquired_at TEXT, expires_at TEXT, released_at TEXT, state TEXT,
|
|
@@ -3678,15 +5017,18 @@ class Store {
|
|
|
3678
5017
|
gen = 0;
|
|
3679
5018
|
memo = new Map;
|
|
3680
5019
|
constructor(home = swarmHome()) {
|
|
3681
|
-
|
|
5020
|
+
mkdirSync4(home, { recursive: true });
|
|
3682
5021
|
this.home = home;
|
|
3683
|
-
this.db = new Database(
|
|
5022
|
+
this.db = new Database(join8(home, "swarm.db"));
|
|
3684
5023
|
this.loadPricing();
|
|
3685
5024
|
this.db.exec("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL; PRAGMA mmap_size=268435456; PRAGMA cache_size=-32000;");
|
|
3686
5025
|
this.db.exec(SCHEMA);
|
|
3687
5026
|
this.ensureColumn("sessions", "agent", "TEXT DEFAULT 'claude-code'");
|
|
3688
5027
|
this.ensureColumn("projects", "sort_order", "INTEGER");
|
|
3689
|
-
this.
|
|
5028
|
+
this.ensureColumn("projects", "icon", "TEXT");
|
|
5029
|
+
this.ensureColumn("projects", "color", "TEXT");
|
|
5030
|
+
this.migrate();
|
|
5031
|
+
this.migrateProjectsJson(join8(home, "projects.json"));
|
|
3690
5032
|
this.reconcileMovedProjects();
|
|
3691
5033
|
this.slimExistingEvents();
|
|
3692
5034
|
this.retypeNotificationIncidents();
|
|
@@ -3734,9 +5076,9 @@ class Store {
|
|
|
3734
5076
|
reconcileMovedProjects() {
|
|
3735
5077
|
const all = this.projects();
|
|
3736
5078
|
for (const stale of all) {
|
|
3737
|
-
if (
|
|
5079
|
+
if (existsSync5(stale.root))
|
|
3738
5080
|
continue;
|
|
3739
|
-
const live = all.filter((p) => p.id !== stale.id && p.name === stale.name &&
|
|
5081
|
+
const live = all.filter((p) => p.id !== stale.id && p.name === stale.name && existsSync5(p.root));
|
|
3740
5082
|
if (live.length !== 1)
|
|
3741
5083
|
continue;
|
|
3742
5084
|
this.mergeProject(stale.id, live[0].id);
|
|
@@ -3768,8 +5110,59 @@ class Store {
|
|
|
3768
5110
|
this.db.exec(`ALTER TABLE ${table} ADD COLUMN ${col} ${decl}`);
|
|
3769
5111
|
}
|
|
3770
5112
|
}
|
|
5113
|
+
static SCHEMA_VERSION = 1;
|
|
5114
|
+
schemaVersion() {
|
|
5115
|
+
return Number(this.meta("schema_version") ?? 0);
|
|
5116
|
+
}
|
|
5117
|
+
migrate() {
|
|
5118
|
+
const steps = [
|
|
5119
|
+
(db) => {
|
|
5120
|
+
for (const t of [
|
|
5121
|
+
"events",
|
|
5122
|
+
"claims",
|
|
5123
|
+
"resources",
|
|
5124
|
+
"processes",
|
|
5125
|
+
"handoffs",
|
|
5126
|
+
"gates",
|
|
5127
|
+
"incident_acks",
|
|
5128
|
+
"sessions"
|
|
5129
|
+
]) {
|
|
5130
|
+
this.ensureColumn(t, "actor_kind", "TEXT");
|
|
5131
|
+
this.ensureColumn(t, "actor_id", "TEXT");
|
|
5132
|
+
}
|
|
5133
|
+
const user = osUser();
|
|
5134
|
+
const fill = (table, ownerCol, sessionCol, key) => {
|
|
5135
|
+
const rows = db.query(`SELECT rowid AS rid, ${ownerCol ?? "NULL"} AS owner, ${sessionCol ?? "NULL"} AS sid FROM ${table} WHERE actor_kind IS NULL`).all();
|
|
5136
|
+
const upd = db.query(`UPDATE ${table} SET actor_kind = ?, actor_id = ? WHERE rowid = ?`);
|
|
5137
|
+
for (const r of rows) {
|
|
5138
|
+
const a = actorFrom(r.owner, r.sid, { user });
|
|
5139
|
+
upd.run(a.kind, a.id, r.rid);
|
|
5140
|
+
}
|
|
5141
|
+
return `${key}:${rows.length}`;
|
|
5142
|
+
};
|
|
5143
|
+
fill("claims", "owner", null, "claims");
|
|
5144
|
+
fill("resources", "owner", "session_id", "resources");
|
|
5145
|
+
fill("processes", "owner", "session_id", "processes");
|
|
5146
|
+
fill("handoffs", "by", "session_id", "handoffs");
|
|
5147
|
+
fill("gates", "NULL", "session_id", "gates");
|
|
5148
|
+
fill("incident_acks", "'dashboard'", null, "acks");
|
|
5149
|
+
fill("sessions", "NULL", "id", "sessions");
|
|
5150
|
+
fill("events", "COALESCE(json_extract(payload, '$.owner'), json_extract(payload, '$.by'))", "session_id", "events");
|
|
5151
|
+
}
|
|
5152
|
+
];
|
|
5153
|
+
for (let v = this.schemaVersion();v < steps.length; v++) {
|
|
5154
|
+
const step = steps[v];
|
|
5155
|
+
this.db.transaction(() => {
|
|
5156
|
+
step(this.db);
|
|
5157
|
+
this.setMeta("schema_version", String(v + 1));
|
|
5158
|
+
})();
|
|
5159
|
+
}
|
|
5160
|
+
}
|
|
5161
|
+
actorFor(owner, sessionId, runId) {
|
|
5162
|
+
return actorFrom(owner, sessionId, { user: osUser(), runId });
|
|
5163
|
+
}
|
|
3771
5164
|
migrateProjectsJson(file) {
|
|
3772
|
-
if (!
|
|
5165
|
+
if (!existsSync5(file))
|
|
3773
5166
|
return;
|
|
3774
5167
|
try {
|
|
3775
5168
|
const list = JSON.parse(readFileSync3(file, "utf8"));
|
|
@@ -3784,11 +5177,12 @@ class Store {
|
|
|
3784
5177
|
const hit = this.topCache.get(cwd);
|
|
3785
5178
|
if (hit && Date.now() - hit.t < 1e4)
|
|
3786
5179
|
return hit.v;
|
|
3787
|
-
const v = cwd &&
|
|
5180
|
+
const v = cwd && existsSync5(cwd) ? gitToplevel(cwd) : null;
|
|
3788
5181
|
this.topCache.set(cwd, { v, t: Date.now() });
|
|
3789
5182
|
return v;
|
|
3790
5183
|
}
|
|
3791
|
-
|
|
5184
|
+
policyCache = new Map;
|
|
5185
|
+
policySeen = new Set;
|
|
3792
5186
|
preregisterSpawnedSession(id, projectId, cwd, task) {
|
|
3793
5187
|
const now = new Date().toISOString();
|
|
3794
5188
|
this.db.query(`INSERT INTO sessions (id, project_id, kind, cwd, started_at, last_seen_at, last, last_type, state, title)
|
|
@@ -3817,8 +5211,8 @@ class Store {
|
|
|
3817
5211
|
createdAt: new Date().toISOString()
|
|
3818
5212
|
};
|
|
3819
5213
|
const sessionId = this.knownSession(h.sessionId);
|
|
3820
|
-
const ins = this.db.query(`INSERT INTO handoffs (project_id, task, done, remaining, files, verify, by, session_id, created_at)
|
|
3821
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(projectId, handoff.task, handoff.done, handoff.remaining, JSON.stringify(handoff.files), handoff.verify, handoff.by, sessionId, handoff.createdAt);
|
|
5214
|
+
const ins = this.db.query(`INSERT INTO handoffs (project_id, task, done, remaining, files, verify, by, session_id, created_at, actor_kind, actor_id)
|
|
5215
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(projectId, handoff.task, handoff.done, handoff.remaining, JSON.stringify(handoff.files), handoff.verify, handoff.by, sessionId, handoff.createdAt, ...actorCols(this.actorFor(handoff.by, sessionId)));
|
|
3822
5216
|
this.remember(handoffDoc(projectId, Number(ins.lastInsertRowid), handoff, sessionId));
|
|
3823
5217
|
this.append({
|
|
3824
5218
|
ts: handoff.createdAt,
|
|
@@ -3868,8 +5262,8 @@ class Store {
|
|
|
3868
5262
|
if (!h)
|
|
3869
5263
|
return null;
|
|
3870
5264
|
this.db.query("DELETE FROM handoffs WHERE project_id = ? AND task = ? AND session_id = ? AND by LIKE 'auto%'").run(held.projectId, held.task, sessionId);
|
|
3871
|
-
const ins = this.db.query(`INSERT INTO handoffs (project_id, task, done, remaining, files, verify, by, session_id, created_at)
|
|
3872
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(held.projectId, held.task, h.done, h.remaining, JSON.stringify(h.files), h.verify, h.by, sessionId, h.createdAt);
|
|
5265
|
+
const ins = this.db.query(`INSERT INTO handoffs (project_id, task, done, remaining, files, verify, by, session_id, created_at, actor_kind, actor_id)
|
|
5266
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(held.projectId, held.task, h.done, h.remaining, JSON.stringify(h.files), h.verify, h.by, sessionId, h.createdAt, ...actorCols(this.actorFor(h.by, sessionId)));
|
|
3873
5267
|
this.remember(handoffDoc(held.projectId, Number(ins.lastInsertRowid), h, sessionId));
|
|
3874
5268
|
this.touch();
|
|
3875
5269
|
return h;
|
|
@@ -3989,7 +5383,7 @@ class Store {
|
|
|
3989
5383
|
}));
|
|
3990
5384
|
}
|
|
3991
5385
|
sessionContext(cwd) {
|
|
3992
|
-
if (!cwd || !
|
|
5386
|
+
if (!cwd || !existsSync5(cwd))
|
|
3993
5387
|
return null;
|
|
3994
5388
|
const toplevel = this.toplevel(cwd);
|
|
3995
5389
|
const project = this.resolveProject(cwd);
|
|
@@ -4001,6 +5395,9 @@ class Store {
|
|
|
4001
5395
|
const h = this.latestHandoff(held.projectId, held.task);
|
|
4002
5396
|
if (h)
|
|
4003
5397
|
lines.push(formatHandoff(h));
|
|
5398
|
+
const qc = this.questionContext(held.task, held.projectId);
|
|
5399
|
+
if (qc)
|
|
5400
|
+
lines.push(qc);
|
|
4004
5401
|
const required = this.requiredGates(held.projectId);
|
|
4005
5402
|
if (required.length) {
|
|
4006
5403
|
const st = gateStatus(this.gateRuns(held.projectId, held.task), required);
|
|
@@ -4027,6 +5424,131 @@ class Store {
|
|
|
4027
5424
|
lines.push(`[swarm] rules: ${on.join(" ")}`);
|
|
4028
5425
|
return lines.length ? lines.join(`
|
|
4029
5426
|
`) : null;
|
|
5427
|
+
}
|
|
5428
|
+
rowToQuestion(r) {
|
|
5429
|
+
return {
|
|
5430
|
+
id: r.id,
|
|
5431
|
+
projectId: r.project_id,
|
|
5432
|
+
sessionId: r.session_id ?? null,
|
|
5433
|
+
task: r.task ?? null,
|
|
5434
|
+
text: r.text,
|
|
5435
|
+
options: JSON.parse(r.options || "[]"),
|
|
5436
|
+
askedBy: r.asked_by ?? null,
|
|
5437
|
+
createdAt: r.created_at,
|
|
5438
|
+
answer: r.answer ?? null,
|
|
5439
|
+
answeredBy: r.answered_by ?? null,
|
|
5440
|
+
answeredAt: r.answered_at ?? null,
|
|
5441
|
+
deliveredAt: r.delivered_at ?? null
|
|
5442
|
+
};
|
|
5443
|
+
}
|
|
5444
|
+
questions(opts = {}) {
|
|
5445
|
+
const where = ["kind = 'question'"];
|
|
5446
|
+
const args = [];
|
|
5447
|
+
if (opts.projectId) {
|
|
5448
|
+
where.push("project_id = ?");
|
|
5449
|
+
args.push(opts.projectId);
|
|
5450
|
+
}
|
|
5451
|
+
if (opts.sessionId) {
|
|
5452
|
+
where.push("session_id = ?");
|
|
5453
|
+
args.push(opts.sessionId);
|
|
5454
|
+
}
|
|
5455
|
+
if (opts.open)
|
|
5456
|
+
where.push("answered_at IS NULL");
|
|
5457
|
+
args.push(opts.limit ?? 100);
|
|
5458
|
+
return this.db.query(`SELECT * FROM messages WHERE ${where.join(" AND ")} ORDER BY id DESC LIMIT ?`).all(...args).map((r) => this.rowToQuestion(r));
|
|
5459
|
+
}
|
|
5460
|
+
question(id) {
|
|
5461
|
+
const r = this.db.query("SELECT * FROM messages WHERE id = ? AND kind = 'question'").get(id);
|
|
5462
|
+
return r ? this.rowToQuestion(r) : null;
|
|
5463
|
+
}
|
|
5464
|
+
ask(projectId, input) {
|
|
5465
|
+
if (!this.project(projectId))
|
|
5466
|
+
return { ok: false, error: "unknown project" };
|
|
5467
|
+
const v = validateQuestion(input.text, input.options);
|
|
5468
|
+
if (!v.ok)
|
|
5469
|
+
return { ok: false, error: v.reason };
|
|
5470
|
+
const sessionId = this.knownSession(input.sessionId ?? null);
|
|
5471
|
+
const task = (input.cwd ? this.heldClaimsWithWorktree().find((c) => isInside(input.cwd, c.worktree))?.task : null) ?? null;
|
|
5472
|
+
const createdAt = new Date().toISOString();
|
|
5473
|
+
const r = this.db.query(`INSERT INTO messages (project_id, session_id, task, kind, text, options, asked_by, created_at)
|
|
5474
|
+
VALUES (?, ?, ?, 'question', ?, ?, ?, ?)`).run(projectId, sessionId, task, v.text, JSON.stringify(v.options), input.askedBy ?? null, createdAt);
|
|
5475
|
+
const q = this.question(Number(r.lastInsertRowid));
|
|
5476
|
+
this.append({
|
|
5477
|
+
ts: createdAt,
|
|
5478
|
+
type: "question.asked",
|
|
5479
|
+
projectId,
|
|
5480
|
+
sessionId,
|
|
5481
|
+
payload: {
|
|
5482
|
+
id: q.id,
|
|
5483
|
+
task,
|
|
5484
|
+
text: v.text,
|
|
5485
|
+
options: v.options,
|
|
5486
|
+
summary: `question #${q.id}: ${v.text.slice(0, 120)}`
|
|
5487
|
+
}
|
|
5488
|
+
});
|
|
5489
|
+
this.touch();
|
|
5490
|
+
return { ok: true, question: q };
|
|
5491
|
+
}
|
|
5492
|
+
answer(id, text, by) {
|
|
5493
|
+
const q = this.question(id);
|
|
5494
|
+
if (!q)
|
|
5495
|
+
return { ok: false, error: `no question #${id}` };
|
|
5496
|
+
if (q.answer !== null)
|
|
5497
|
+
return {
|
|
5498
|
+
ok: false,
|
|
5499
|
+
error: `#${id} was already answered by ${q.answeredBy ?? "someone"}`
|
|
5500
|
+
};
|
|
5501
|
+
const a = typeof text === "string" ? text.trim() : "";
|
|
5502
|
+
if (!a)
|
|
5503
|
+
return { ok: false, error: "an answer is required" };
|
|
5504
|
+
const at = new Date().toISOString();
|
|
5505
|
+
this.db.query("UPDATE messages SET answer = ?, answered_by = ?, answered_at = ? WHERE id = ?").run(a, by, at, id);
|
|
5506
|
+
this.append({
|
|
5507
|
+
ts: at,
|
|
5508
|
+
type: "question.answered",
|
|
5509
|
+
projectId: q.projectId,
|
|
5510
|
+
sessionId: q.sessionId,
|
|
5511
|
+
payload: { id, task: q.task, answer: a, by, summary: `answer to #${id}: ${a.slice(0, 120)}` }
|
|
5512
|
+
});
|
|
5513
|
+
this.touch();
|
|
5514
|
+
return { ok: true, question: this.question(id) };
|
|
5515
|
+
}
|
|
5516
|
+
inbox(sessionId, opts = {}) {
|
|
5517
|
+
if (!sessionId)
|
|
5518
|
+
return [];
|
|
5519
|
+
const rows = this.db.query("SELECT * FROM messages WHERE kind = 'question' AND session_id = ? AND answered_at IS NOT NULL AND delivered_at IS NULL ORDER BY id").all(sessionId);
|
|
5520
|
+
const qs = rows.map((r) => this.rowToQuestion(r));
|
|
5521
|
+
if (qs.length && !opts.peek)
|
|
5522
|
+
this.db.query(`UPDATE messages SET delivered_at = ? WHERE id IN (${qs.map(() => "?").join(",")})`).run(new Date().toISOString(), ...qs.map((q) => q.id));
|
|
5523
|
+
return qs;
|
|
5524
|
+
}
|
|
5525
|
+
answerContext(sessionId) {
|
|
5526
|
+
return formatAnswers(this.inbox(sessionId));
|
|
5527
|
+
}
|
|
5528
|
+
questionContext(task, projectId) {
|
|
5529
|
+
if (!task)
|
|
5530
|
+
return null;
|
|
5531
|
+
const qs = this.db.query("SELECT * FROM messages WHERE kind = 'question' AND project_id = ? AND task = ? AND (answered_at IS NULL OR delivered_at IS NULL) ORDER BY id").all(projectId, task);
|
|
5532
|
+
const list = qs.map((r) => this.rowToQuestion(r));
|
|
5533
|
+
const parts = [formatAnswers(list), formatOpenQuestions(list)].filter(Boolean);
|
|
5534
|
+
if (list.some((q) => q.answer !== null))
|
|
5535
|
+
this.db.query("UPDATE messages SET delivered_at = ? WHERE kind = 'question' AND project_id = ? AND task = ? AND answered_at IS NOT NULL AND delivered_at IS NULL").run(new Date().toISOString(), projectId, task);
|
|
5536
|
+
return parts.length ? parts.join(`
|
|
5537
|
+
`) : null;
|
|
5538
|
+
}
|
|
5539
|
+
contextFor(cwd, sessionId) {
|
|
5540
|
+
const parts = [];
|
|
5541
|
+
const base = this.sessionContext(cwd);
|
|
5542
|
+
if (base)
|
|
5543
|
+
parts.push(base);
|
|
5544
|
+
const answers = this.answerContext(sessionId);
|
|
5545
|
+
if (answers)
|
|
5546
|
+
parts.push(answers);
|
|
5547
|
+
const open = formatOpenQuestions(this.questions({ sessionId: sessionId ?? undefined, open: true }));
|
|
5548
|
+
if (open && !base?.includes(open))
|
|
5549
|
+
parts.push(open);
|
|
5550
|
+
return { text: parts.length ? parts.join(`
|
|
5551
|
+
`) : null, parts };
|
|
4030
5552
|
}
|
|
4031
5553
|
rowToGate(r) {
|
|
4032
5554
|
return {
|
|
@@ -4045,8 +5567,319 @@ class Store {
|
|
|
4045
5567
|
const rows = task ? this.db.query("SELECT * FROM gates WHERE project_id = ? AND task = ? ORDER BY created_at DESC, id DESC LIMIT ?").all(projectId, task, limit) : this.db.query("SELECT * FROM gates WHERE project_id = ? ORDER BY created_at DESC, id DESC LIMIT ?").all(projectId, limit);
|
|
4046
5568
|
return rows.map((r) => this.rowToGate(r));
|
|
4047
5569
|
}
|
|
4048
|
-
gateStatusFor(runs, required) {
|
|
4049
|
-
return gateStatus(runs, required);
|
|
5570
|
+
gateStatusFor(runs, required) {
|
|
5571
|
+
return gateStatus(runs, required);
|
|
5572
|
+
}
|
|
5573
|
+
config(projectId) {
|
|
5574
|
+
const p = this.project(projectId);
|
|
5575
|
+
return loadConfig({ repoRoot: p?.root ?? null, home: this.home });
|
|
5576
|
+
}
|
|
5577
|
+
gateDefs(projectId) {
|
|
5578
|
+
const p = this.project(projectId);
|
|
5579
|
+
return p ? loadConfig({ repoRoot: p.root, home: this.home }).gates : null;
|
|
5580
|
+
}
|
|
5581
|
+
gateJobs = new Map;
|
|
5582
|
+
gateBatches = new Map;
|
|
5583
|
+
async awaitGates(projectId, task) {
|
|
5584
|
+
const prefix = `${projectId}:${task}:`;
|
|
5585
|
+
await Promise.all([
|
|
5586
|
+
...[...this.gateJobs].filter(([k]) => k.startsWith(prefix)).map(([, v]) => v),
|
|
5587
|
+
...this.gateBatches.get(`${projectId}:${task}`) ?? []
|
|
5588
|
+
]);
|
|
5589
|
+
}
|
|
5590
|
+
runGate(projectId, task, gate, opts = {}) {
|
|
5591
|
+
const p = this.project(projectId);
|
|
5592
|
+
if (!p)
|
|
5593
|
+
return { ok: false, reason: "unknown project" };
|
|
5594
|
+
const cfg = this.gateDefs(projectId);
|
|
5595
|
+
const def = cfg?.defs[gate];
|
|
5596
|
+
if (!def)
|
|
5597
|
+
return {
|
|
5598
|
+
ok: false,
|
|
5599
|
+
reason: `gate ${gate} has no command \u2014 add [gates.${gate}] cmd = "\u2026" to .swarm.toml, or record it with swarm gate record`
|
|
5600
|
+
};
|
|
5601
|
+
const claim = this.claims(projectId).find((c) => c.task === task && c.state === "held");
|
|
5602
|
+
const worktree2 = claim?.worktree;
|
|
5603
|
+
if (!worktree2 || !existsSync5(worktree2))
|
|
5604
|
+
return {
|
|
5605
|
+
ok: false,
|
|
5606
|
+
reason: `${task} has no held worktree to run ${gate} in \u2014 claim it first`
|
|
5607
|
+
};
|
|
5608
|
+
const cwd = def.cwd ? join8(worktree2, def.cwd) : worktree2;
|
|
5609
|
+
if (!existsSync5(cwd))
|
|
5610
|
+
return { ok: false, reason: `gate cwd ${cwd} does not exist` };
|
|
5611
|
+
const key = `${projectId}:${task}:${gate}`;
|
|
5612
|
+
if (this.gateJobs.has(key))
|
|
5613
|
+
return { ok: false, reason: `${gate} is already running on ${task}` };
|
|
5614
|
+
const slug = (x) => x.replace(/[^a-zA-Z0-9_.-]+/g, "-");
|
|
5615
|
+
const logDir = join8(this.home, "logs", projectId);
|
|
5616
|
+
mkdirSync4(logDir, { recursive: true });
|
|
5617
|
+
const log = join8(logDir, `gate-${slug(task)}-${slug(gate)}.log`);
|
|
5618
|
+
if (def.builtin === "review")
|
|
5619
|
+
return this.runReviewGate(projectId, task, gate, def, { worktree: worktree2, cwd, key, log }, opts);
|
|
5620
|
+
writeFileSync2(log, `$ ${def.cmd}
|
|
5621
|
+
# cwd ${cwd} \xB7 ${new Date().toISOString()}
|
|
5622
|
+
`);
|
|
5623
|
+
const fd = openSync3(log, "a");
|
|
5624
|
+
let proc;
|
|
5625
|
+
try {
|
|
5626
|
+
proc = Bun.spawn(["sh", "-c", def.cmd], {
|
|
5627
|
+
cwd,
|
|
5628
|
+
stdin: "ignore",
|
|
5629
|
+
stdout: fd,
|
|
5630
|
+
stderr: fd,
|
|
5631
|
+
env: {
|
|
5632
|
+
...process.env,
|
|
5633
|
+
SWARM_WORKTREE: worktree2,
|
|
5634
|
+
SWARM_TASK: task,
|
|
5635
|
+
SWARM_GATE: gate,
|
|
5636
|
+
CI: process.env.CI ?? "1"
|
|
5637
|
+
}
|
|
5638
|
+
});
|
|
5639
|
+
} catch (e) {
|
|
5640
|
+
closeSync(fd);
|
|
5641
|
+
const run2 = this.recordGate(projectId, {
|
|
5642
|
+
...executedGateInput(task, gate, def.cmd, {
|
|
5643
|
+
exitCode: null,
|
|
5644
|
+
durationMs: 0,
|
|
5645
|
+
output: e.message
|
|
5646
|
+
}),
|
|
5647
|
+
sessionId: opts.sessionId ?? null
|
|
5648
|
+
});
|
|
5649
|
+
return { ok: true, pid: 0, log, done: Promise.resolve(run2.ok ? run2.run : null) };
|
|
5650
|
+
}
|
|
5651
|
+
const started = Date.now();
|
|
5652
|
+
const reg = this.registerProcess({
|
|
5653
|
+
pid: proc.pid,
|
|
5654
|
+
projectId,
|
|
5655
|
+
sessionId: opts.sessionId ?? null,
|
|
5656
|
+
kind: "gate",
|
|
5657
|
+
name: `gate:${task}:${gate}`,
|
|
5658
|
+
cwd,
|
|
5659
|
+
cmd: def.cmd,
|
|
5660
|
+
owner: opts.owner ?? "daemon",
|
|
5661
|
+
log
|
|
5662
|
+
});
|
|
5663
|
+
let timedOut = false;
|
|
5664
|
+
const timer = setTimeout(() => {
|
|
5665
|
+
timedOut = true;
|
|
5666
|
+
try {
|
|
5667
|
+
proc.kill("SIGTERM");
|
|
5668
|
+
setTimeout(() => {
|
|
5669
|
+
try {
|
|
5670
|
+
proc.kill("SIGKILL");
|
|
5671
|
+
} catch {}
|
|
5672
|
+
}, 5000).unref();
|
|
5673
|
+
} catch {}
|
|
5674
|
+
}, def.timeout * 1000);
|
|
5675
|
+
const done = proc.exited.then((code) => {
|
|
5676
|
+
clearTimeout(timer);
|
|
5677
|
+
closeSync(fd);
|
|
5678
|
+
let output = "";
|
|
5679
|
+
try {
|
|
5680
|
+
output = readFileSync3(log, "utf8");
|
|
5681
|
+
} catch {}
|
|
5682
|
+
const input = executedGateInput(task, gate, def.cmd, {
|
|
5683
|
+
exitCode: timedOut ? null : code,
|
|
5684
|
+
timedOut,
|
|
5685
|
+
durationMs: Date.now() - started,
|
|
5686
|
+
output
|
|
5687
|
+
});
|
|
5688
|
+
const run2 = this.recordGate(projectId, { ...input, sessionId: opts.sessionId ?? null });
|
|
5689
|
+
if (reg.ok)
|
|
5690
|
+
this.processes(projectId);
|
|
5691
|
+
return run2.ok ? run2.run : null;
|
|
5692
|
+
}).finally(() => {
|
|
5693
|
+
this.gateJobs.delete(key);
|
|
5694
|
+
this.touch();
|
|
5695
|
+
});
|
|
5696
|
+
this.gateJobs.set(key, done);
|
|
5697
|
+
return { ok: true, pid: proc.pid, log, done };
|
|
5698
|
+
}
|
|
5699
|
+
runReviewGate(projectId, task, gate, def, where, opts) {
|
|
5700
|
+
const bin = findBin("claude");
|
|
5701
|
+
if (!bin)
|
|
5702
|
+
return { ok: false, reason: "claude CLI not found \u2014 the review gate needs Claude Code" };
|
|
5703
|
+
const p = this.project(projectId);
|
|
5704
|
+
if (!p)
|
|
5705
|
+
return { ok: false, reason: "unknown project" };
|
|
5706
|
+
const started = Date.now();
|
|
5707
|
+
const record = (input) => {
|
|
5708
|
+
const run2 = this.recordGate(projectId, { ...input, sessionId: opts.sessionId ?? null });
|
|
5709
|
+
return run2.ok ? run2.run : null;
|
|
5710
|
+
};
|
|
5711
|
+
const done = (async () => {
|
|
5712
|
+
let diffText = "";
|
|
5713
|
+
let stat = "";
|
|
5714
|
+
try {
|
|
5715
|
+
const diff = await worktreeDiff(p.root, where.worktree);
|
|
5716
|
+
stat = diff.files.map((f) => `${f.status ?? "M"} ${f.path} (+${f.added} -${f.deleted})`).join(`
|
|
5717
|
+
`);
|
|
5718
|
+
diffText = await worktreePatch(where.worktree, diff.base);
|
|
5719
|
+
} catch (e) {
|
|
5720
|
+
return record(reviewGateInput(task, gate, {
|
|
5721
|
+
kind: "error",
|
|
5722
|
+
reason: `diff failed: ${e.message}`,
|
|
5723
|
+
durationMs: Date.now() - started
|
|
5724
|
+
}));
|
|
5725
|
+
}
|
|
5726
|
+
if (!diffText.trim())
|
|
5727
|
+
return record(reviewGateInput(task, gate, {
|
|
5728
|
+
kind: "verdict",
|
|
5729
|
+
durationMs: Date.now() - started,
|
|
5730
|
+
verdict: { verdict: "pass", summary: "nothing to review \u2014 empty diff", findings: [] }
|
|
5731
|
+
}));
|
|
5732
|
+
const taskRow = this.tasks(projectId)?.tasks.find((t) => t.id === task) ?? null;
|
|
5733
|
+
const w = this.findWorktree(projectId, where.worktree);
|
|
5734
|
+
const prompt = reviewPrompt({
|
|
5735
|
+
task,
|
|
5736
|
+
title: taskRow?.title ?? null,
|
|
5737
|
+
branch: w?.branch ?? null,
|
|
5738
|
+
stat,
|
|
5739
|
+
patch: diffText
|
|
5740
|
+
});
|
|
5741
|
+
writeFileSync2(where.log, `$ claude -p <review prompt, ${prompt.length} chars> --output-format json (read-only)
|
|
5742
|
+
# cwd ${where.cwd} \xB7 ${new Date().toISOString()}
|
|
5743
|
+
`);
|
|
5744
|
+
let proc;
|
|
5745
|
+
try {
|
|
5746
|
+
proc = Bun.spawn([bin, ...reviewArgs(prompt, { model: def.model })], {
|
|
5747
|
+
cwd: where.cwd,
|
|
5748
|
+
stdin: "ignore",
|
|
5749
|
+
stdout: "pipe",
|
|
5750
|
+
stderr: "pipe",
|
|
5751
|
+
env: {
|
|
5752
|
+
...process.env,
|
|
5753
|
+
SWARM_WORKTREE: where.worktree,
|
|
5754
|
+
SWARM_TASK: task,
|
|
5755
|
+
SWARM_GATE: gate,
|
|
5756
|
+
CLAUDE_CODE_DISABLE_AUTOUPDATE: "1"
|
|
5757
|
+
}
|
|
5758
|
+
});
|
|
5759
|
+
} catch (e) {
|
|
5760
|
+
return record(reviewGateInput(task, gate, {
|
|
5761
|
+
kind: "error",
|
|
5762
|
+
reason: e.message,
|
|
5763
|
+
durationMs: Date.now() - started
|
|
5764
|
+
}));
|
|
5765
|
+
}
|
|
5766
|
+
const reg = this.registerProcess({
|
|
5767
|
+
pid: proc.pid,
|
|
5768
|
+
projectId,
|
|
5769
|
+
sessionId: opts.sessionId ?? null,
|
|
5770
|
+
kind: "gate",
|
|
5771
|
+
name: `gate:${task}:${gate}`,
|
|
5772
|
+
cwd: where.cwd,
|
|
5773
|
+
cmd: "claude -p (review)",
|
|
5774
|
+
owner: opts.owner ?? "daemon",
|
|
5775
|
+
log: where.log
|
|
5776
|
+
});
|
|
5777
|
+
let timedOut = false;
|
|
5778
|
+
const timer = setTimeout(() => {
|
|
5779
|
+
timedOut = true;
|
|
5780
|
+
try {
|
|
5781
|
+
proc.kill("SIGTERM");
|
|
5782
|
+
setTimeout(() => {
|
|
5783
|
+
try {
|
|
5784
|
+
proc.kill("SIGKILL");
|
|
5785
|
+
} catch {}
|
|
5786
|
+
}, 5000).unref();
|
|
5787
|
+
} catch {}
|
|
5788
|
+
}, def.timeout * 1000);
|
|
5789
|
+
const [out, err] = await Promise.all([
|
|
5790
|
+
new Response(proc.stdout).text(),
|
|
5791
|
+
new Response(proc.stderr).text()
|
|
5792
|
+
]);
|
|
5793
|
+
const code = await proc.exited;
|
|
5794
|
+
clearTimeout(timer);
|
|
5795
|
+
try {
|
|
5796
|
+
writeFileSync2(where.log, `${readFileSync3(where.log, "utf8")}${out}
|
|
5797
|
+
${err}
|
|
5798
|
+
# exit ${timedOut ? "timeout" : code} \xB7 ${((Date.now() - started) / 1000).toFixed(0)}s
|
|
5799
|
+
`);
|
|
5800
|
+
} catch {}
|
|
5801
|
+
if (reg.ok)
|
|
5802
|
+
this.processes(projectId);
|
|
5803
|
+
const durationMs = Date.now() - started;
|
|
5804
|
+
if (timedOut)
|
|
5805
|
+
return record(reviewGateInput(task, gate, {
|
|
5806
|
+
kind: "error",
|
|
5807
|
+
reason: `timed out after ${def.timeout}s`,
|
|
5808
|
+
durationMs,
|
|
5809
|
+
output: err
|
|
5810
|
+
}));
|
|
5811
|
+
const verdict = parseReviewVerdict(out);
|
|
5812
|
+
if (!verdict)
|
|
5813
|
+
return record(reviewGateInput(task, gate, {
|
|
5814
|
+
kind: "error",
|
|
5815
|
+
reason: code === 0 ? "no JSON verdict in the reply" : `claude exited ${code}`,
|
|
5816
|
+
durationMs,
|
|
5817
|
+
output: err || out
|
|
5818
|
+
}));
|
|
5819
|
+
return record(reviewGateInput(task, gate, { kind: "verdict", verdict, durationMs }));
|
|
5820
|
+
})().finally(() => {
|
|
5821
|
+
this.gateJobs.delete(where.key);
|
|
5822
|
+
this.touch();
|
|
5823
|
+
});
|
|
5824
|
+
this.gateJobs.set(where.key, done);
|
|
5825
|
+
return { ok: true, pid: 0, log: where.log, done };
|
|
5826
|
+
}
|
|
5827
|
+
async runGates(projectId, task, gates2, opts = {}) {
|
|
5828
|
+
const cfg = this.gateDefs(projectId);
|
|
5829
|
+
const names = gates2?.length ? gates2 : (cfg?.required ?? []).filter((g) => cfg?.defs[g]);
|
|
5830
|
+
const key = `${projectId}:${task}`;
|
|
5831
|
+
const batch = (async () => {
|
|
5832
|
+
const started = [];
|
|
5833
|
+
const skipped = [];
|
|
5834
|
+
const runs = [];
|
|
5835
|
+
for (const g of names) {
|
|
5836
|
+
const r = this.runGate(projectId, task, g, opts);
|
|
5837
|
+
if (!r.ok) {
|
|
5838
|
+
skipped.push({ gate: g, reason: r.reason });
|
|
5839
|
+
continue;
|
|
5840
|
+
}
|
|
5841
|
+
started.push(g);
|
|
5842
|
+
const run2 = await r.done;
|
|
5843
|
+
if (run2)
|
|
5844
|
+
runs.push(run2);
|
|
5845
|
+
}
|
|
5846
|
+
return { started, skipped, runs };
|
|
5847
|
+
})();
|
|
5848
|
+
const set = this.gateBatches.get(key) ?? new Set;
|
|
5849
|
+
set.add(batch);
|
|
5850
|
+
this.gateBatches.set(key, set);
|
|
5851
|
+
try {
|
|
5852
|
+
return await batch;
|
|
5853
|
+
} finally {
|
|
5854
|
+
set.delete(batch);
|
|
5855
|
+
if (!set.size)
|
|
5856
|
+
this.gateBatches.delete(key);
|
|
5857
|
+
}
|
|
5858
|
+
}
|
|
5859
|
+
autoGateAt = new Map;
|
|
5860
|
+
autoGate(event, sessionId, cwd) {
|
|
5861
|
+
const held = this.heldClaimsWithWorktree().find((c) => isInside(cwd, c.worktree));
|
|
5862
|
+
if (!held)
|
|
5863
|
+
return;
|
|
5864
|
+
const cfg = this.gateDefs(held.projectId);
|
|
5865
|
+
if (!cfg || cfg.auto === "off")
|
|
5866
|
+
return;
|
|
5867
|
+
if (cfg.auto === "session-end" && event !== "SessionEnd")
|
|
5868
|
+
return;
|
|
5869
|
+
if (!cfg.required.some((g) => cfg.defs[g]))
|
|
5870
|
+
return;
|
|
5871
|
+
const key = `${held.projectId}:${held.task}`;
|
|
5872
|
+
const now = Date.now();
|
|
5873
|
+
if (event === "Stop" && now - (this.autoGateAt.get(key) ?? 0) < 120000)
|
|
5874
|
+
return;
|
|
5875
|
+
this.autoGateAt.set(key, now);
|
|
5876
|
+
this.runGates(held.projectId, held.task, undefined, { sessionId, owner: "auto" }).then((r) => {
|
|
5877
|
+
if (!r.runs.length)
|
|
5878
|
+
return;
|
|
5879
|
+
const line = r.runs.map((x) => `${x.gate} ${x.verdict === "pass" ? "\u2713" : "\u2717"} (${x.rubric})`).join("; ");
|
|
5880
|
+
this.db.query("UPDATE handoffs SET verify = ? WHERE project_id = ? AND task = ? AND session_id = ? AND by LIKE 'auto%'").run(`auto-gates: ${line}`, held.projectId, held.task, sessionId);
|
|
5881
|
+
this.touch();
|
|
5882
|
+
});
|
|
4050
5883
|
}
|
|
4051
5884
|
requiredGates(projectId) {
|
|
4052
5885
|
const p = this.project(projectId);
|
|
@@ -4060,8 +5893,8 @@ class Store {
|
|
|
4060
5893
|
return v;
|
|
4061
5894
|
const createdAt = new Date().toISOString();
|
|
4062
5895
|
const sessionId = this.knownSession(input.sessionId);
|
|
4063
|
-
const r = this.db.query(`INSERT INTO gates (project_id, task, gate, verdict, rubric, evidence, session_id, created_at)
|
|
4064
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`).run(projectId, input.task.trim(), input.gate, input.verdict, input.rubric.trim(), input.evidence?.trim() || null, sessionId, createdAt);
|
|
5896
|
+
const r = this.db.query(`INSERT INTO gates (project_id, task, gate, verdict, rubric, evidence, session_id, created_at, actor_kind, actor_id)
|
|
5897
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(projectId, input.task.trim(), input.gate, input.verdict, input.rubric.trim(), input.evidence?.trim() || null, sessionId, createdAt, ...actorCols(this.actorFor(input.sessionId ? null : "daemon", sessionId)));
|
|
4065
5898
|
const run2 = this.rowToGate(this.db.query("SELECT * FROM gates WHERE id = ?").get(Number(r.lastInsertRowid)));
|
|
4066
5899
|
this.remember(gateDoc(projectId, run2.id, run2, sessionId));
|
|
4067
5900
|
this.append({
|
|
@@ -4113,8 +5946,8 @@ class Store {
|
|
|
4113
5946
|
hit = { tasks: e.tasks };
|
|
4114
5947
|
error = e.error;
|
|
4115
5948
|
} else {
|
|
4116
|
-
const path =
|
|
4117
|
-
if (!
|
|
5949
|
+
const path = join8(p.root, source);
|
|
5950
|
+
if (!existsSync5(path))
|
|
4118
5951
|
return { source, required: this.requiredGates(projectId), tasks: [] };
|
|
4119
5952
|
const mtime = statSync(path).mtimeMs;
|
|
4120
5953
|
let md = this.taskCache.get(projectId);
|
|
@@ -4147,15 +5980,81 @@ class Store {
|
|
|
4147
5980
|
return { source, required, tasks: board, error };
|
|
4148
5981
|
}
|
|
4149
5982
|
rulesFor(repoRoot) {
|
|
5983
|
+
return this.policyFor(repoRoot).config.rules;
|
|
5984
|
+
}
|
|
5985
|
+
policyFor(repoRoot) {
|
|
4150
5986
|
const key = repoRoot ?? "";
|
|
4151
|
-
const hit = this.
|
|
5987
|
+
const hit = this.policyCache.get(key);
|
|
4152
5988
|
if (hit && Date.now() - hit.at < 30000)
|
|
4153
|
-
return hit.
|
|
4154
|
-
const
|
|
4155
|
-
this.
|
|
4156
|
-
|
|
5989
|
+
return hit.loaded;
|
|
5990
|
+
const loaded = loadConfigDetailed({ repoRoot, home: this.home });
|
|
5991
|
+
this.policyCache.set(key, { at: Date.now(), loaded });
|
|
5992
|
+
this.writePolicyCache(loaded);
|
|
5993
|
+
return loaded;
|
|
5994
|
+
}
|
|
5995
|
+
writePolicyCache(loaded) {
|
|
5996
|
+
const file = join8(this.home, POLICY_CACHE_FILE);
|
|
5997
|
+
try {
|
|
5998
|
+
if (!hasLockedRules(loaded)) {
|
|
5999
|
+
if (existsSync5(file))
|
|
6000
|
+
unlinkSync(file);
|
|
6001
|
+
return;
|
|
6002
|
+
}
|
|
6003
|
+
const cache = buildPolicyCache(loaded, this.liveSessions(), this.heldWorktrees());
|
|
6004
|
+
writeFileSync2(file, JSON.stringify(cache), { mode: 384 });
|
|
6005
|
+
} catch (e) {
|
|
6006
|
+
console.error(`swarm: policy cache: ${e.message}`);
|
|
6007
|
+
}
|
|
6008
|
+
}
|
|
6009
|
+
guardDisabled(repoRoot) {
|
|
6010
|
+
return process.env.SWARM_GUARD === "off" && !hasLockedRules(this.policyFor(repoRoot));
|
|
6011
|
+
}
|
|
6012
|
+
claudeSettings() {
|
|
6013
|
+
const p = process.env.CLAUDE_SETTINGS ?? join8(homedir3(), ".claude", "settings.json");
|
|
6014
|
+
try {
|
|
6015
|
+
return existsSync5(p) ? JSON.parse(readFileSync3(p, "utf8")) : null;
|
|
6016
|
+
} catch {
|
|
6017
|
+
return null;
|
|
6018
|
+
}
|
|
6019
|
+
}
|
|
6020
|
+
checkPolicy(cwd, sessionId) {
|
|
6021
|
+
const project = existsSync5(cwd) ? this.resolveProject(cwd) : null;
|
|
6022
|
+
const repoRoot = project?.root ?? null;
|
|
6023
|
+
const loaded = this.policyFor(repoRoot);
|
|
6024
|
+
const settings = this.claudeSettings();
|
|
6025
|
+
const findings = policyFindings({
|
|
6026
|
+
loaded,
|
|
6027
|
+
coverage: settings === null ? null : hookCoverage(settings),
|
|
6028
|
+
guardOff: process.env.SWARM_GUARD === "off",
|
|
6029
|
+
repoRoot
|
|
6030
|
+
});
|
|
6031
|
+
for (const f of findings) {
|
|
6032
|
+
if (this.policySeen.has(f.key))
|
|
6033
|
+
continue;
|
|
6034
|
+
this.policySeen.add(f.key);
|
|
6035
|
+
this.append({
|
|
6036
|
+
ts: new Date().toISOString(),
|
|
6037
|
+
type: "incident.opened",
|
|
6038
|
+
projectId: project?.id ?? "p_unknown",
|
|
6039
|
+
sessionId,
|
|
6040
|
+
payload: { rule: "policy", action: "tampered", command: f.subject, reason: f.reason }
|
|
6041
|
+
});
|
|
6042
|
+
}
|
|
6043
|
+
return findings;
|
|
4157
6044
|
}
|
|
4158
6045
|
evaluateTool(tool, input, sessionId, cwd, recordIncident = true) {
|
|
6046
|
+
if (BUDGET_ASK_TOOLS.has(tool) && cwd && existsSync5(cwd)) {
|
|
6047
|
+
const project = this.resolveProject(cwd);
|
|
6048
|
+
const b = this.budgetFor(project.id);
|
|
6049
|
+
if (b && b.status.level === "exceeded" && b.config.on_exceed === "ask") {
|
|
6050
|
+
const d = {
|
|
6051
|
+
action: "ask",
|
|
6052
|
+
rule: "budget",
|
|
6053
|
+
reason: `${budgetMessage(b.status, project.name)} \u2014 [budget] on_exceed = "ask": confirm each change, or raise the ceiling in .swarm.toml`
|
|
6054
|
+
};
|
|
6055
|
+
return { decision: d, display: input.command ?? input.file_path ?? tool };
|
|
6056
|
+
}
|
|
6057
|
+
}
|
|
4159
6058
|
const isWrite = WRITE_TOOLS.has(tool) && typeof input.file_path === "string";
|
|
4160
6059
|
const cmd = tool === "Bash" ? input.command : undefined;
|
|
4161
6060
|
const current = { id: sessionId, cwd, toplevel: this.toplevel(cwd) };
|
|
@@ -4235,7 +6134,7 @@ class Store {
|
|
|
4235
6134
|
return this.openIncident(d, cwd, id, cmd);
|
|
4236
6135
|
}
|
|
4237
6136
|
openIncident(d, cwd, sessionId, command) {
|
|
4238
|
-
const project = cwd &&
|
|
6137
|
+
const project = cwd && existsSync5(cwd) ? this.resolveProject(cwd) : null;
|
|
4239
6138
|
this.append({
|
|
4240
6139
|
ts: new Date().toISOString(),
|
|
4241
6140
|
type: "incident.opened",
|
|
@@ -4286,7 +6185,7 @@ class Store {
|
|
|
4286
6185
|
}
|
|
4287
6186
|
}
|
|
4288
6187
|
const report = dryRunRules(calls, modes, {
|
|
4289
|
-
toplevel: (cwd) => cwd &&
|
|
6188
|
+
toplevel: (cwd) => cwd && existsSync5(cwd) ? this.toplevel(cwd) : null,
|
|
4290
6189
|
claims: this.heldWorktrees()
|
|
4291
6190
|
});
|
|
4292
6191
|
return { ...report, modes };
|
|
@@ -4301,8 +6200,8 @@ class Store {
|
|
|
4301
6200
|
loadPricing() {
|
|
4302
6201
|
this.prices = { ...PRICES };
|
|
4303
6202
|
for (const f of ["pricing.litellm.json", "pricing.json"]) {
|
|
4304
|
-
const p =
|
|
4305
|
-
if (!
|
|
6203
|
+
const p = join8(this.home, f);
|
|
6204
|
+
if (!existsSync5(p))
|
|
4306
6205
|
continue;
|
|
4307
6206
|
try {
|
|
4308
6207
|
const j = JSON.parse(readFileSync3(p, "utf8"));
|
|
@@ -4317,7 +6216,7 @@ class Store {
|
|
|
4317
6216
|
throw new Error(`pricing fetch ${r.status}`);
|
|
4318
6217
|
const j = await r.json();
|
|
4319
6218
|
const slim = Object.fromEntries(Object.entries(j).filter(([k, v]) => typeof v.input_cost_per_token === "number" && !k.includes("/")));
|
|
4320
|
-
writeFileSync2(
|
|
6219
|
+
writeFileSync2(join8(this.home, "pricing.litellm.json"), JSON.stringify(slim, null, 1));
|
|
4321
6220
|
this.loadPricing();
|
|
4322
6221
|
this.reprice();
|
|
4323
6222
|
}
|
|
@@ -4344,6 +6243,8 @@ class Store {
|
|
|
4344
6243
|
name: r.name,
|
|
4345
6244
|
discovered: Boolean(r.discovered),
|
|
4346
6245
|
order: typeof r.sort_order === "number" ? r.sort_order : null,
|
|
6246
|
+
icon: r.icon ?? null,
|
|
6247
|
+
color: r.color ?? null,
|
|
4347
6248
|
createdAt: r.created_at
|
|
4348
6249
|
}));
|
|
4349
6250
|
}
|
|
@@ -4358,6 +6259,8 @@ class Store {
|
|
|
4358
6259
|
name: r.name,
|
|
4359
6260
|
discovered: Boolean(r.discovered),
|
|
4360
6261
|
order: typeof r.sort_order === "number" ? r.sort_order : null,
|
|
6262
|
+
icon: r.icon ?? null,
|
|
6263
|
+
color: r.color ?? null,
|
|
4361
6264
|
createdAt: r.created_at
|
|
4362
6265
|
};
|
|
4363
6266
|
}
|
|
@@ -4395,6 +6298,8 @@ class Store {
|
|
|
4395
6298
|
...ident,
|
|
4396
6299
|
discovered: !explicit,
|
|
4397
6300
|
order: null,
|
|
6301
|
+
icon: null,
|
|
6302
|
+
color: null,
|
|
4398
6303
|
createdAt: new Date().toISOString()
|
|
4399
6304
|
};
|
|
4400
6305
|
if (name)
|
|
@@ -4415,8 +6320,24 @@ class Store {
|
|
|
4415
6320
|
return;
|
|
4416
6321
|
if (patch.pinned !== undefined)
|
|
4417
6322
|
this.db.query("UPDATE projects SET discovered = ? WHERE id = ?").run(patch.pinned ? 0 : 1, id);
|
|
4418
|
-
if (patch.name)
|
|
4419
|
-
this.db.query("UPDATE projects SET name = ? WHERE id = ?").run(patch.name, id);
|
|
6323
|
+
if (patch.name?.trim())
|
|
6324
|
+
this.db.query("UPDATE projects SET name = ? WHERE id = ?").run(patch.name.trim(), id);
|
|
6325
|
+
if (patch.icon !== undefined) {
|
|
6326
|
+
const icon = (patch.icon ?? "").trim();
|
|
6327
|
+
const isImage = /^data:image\/(png|jpeg|webp);base64,[A-Za-z0-9+/=]+$/.test(icon);
|
|
6328
|
+
if (!isImage && [...icon].length > 4)
|
|
6329
|
+
return;
|
|
6330
|
+
if (isImage && icon.length > 24000)
|
|
6331
|
+
return;
|
|
6332
|
+
this.db.query("UPDATE projects SET icon = ? WHERE id = ?").run(icon || null, id);
|
|
6333
|
+
}
|
|
6334
|
+
if (patch.color !== undefined) {
|
|
6335
|
+
const color = (patch.color ?? "").trim();
|
|
6336
|
+
if (color && !/^c[1-7]$/.test(color))
|
|
6337
|
+
return;
|
|
6338
|
+
this.db.query("UPDATE projects SET color = ? WHERE id = ?").run(color || null, id);
|
|
6339
|
+
}
|
|
6340
|
+
this.touch();
|
|
4420
6341
|
return this.project(id);
|
|
4421
6342
|
}
|
|
4422
6343
|
reorderProjects(ids) {
|
|
@@ -4435,10 +6356,29 @@ class Store {
|
|
|
4435
6356
|
this.touch();
|
|
4436
6357
|
return this.db.query("DELETE FROM projects WHERE id = ?").run(id).changes > 0;
|
|
4437
6358
|
}
|
|
4438
|
-
|
|
6359
|
+
redactions() {
|
|
6360
|
+
const cfg = this.policyFor(null).config.privacy;
|
|
6361
|
+
const key = cfg.redact.join("\x00");
|
|
6362
|
+
if (this.redactCache?.key !== key)
|
|
6363
|
+
this.redactCache = { key, res: compileRedactions(cfg.redact) };
|
|
6364
|
+
return this.redactCache.res;
|
|
6365
|
+
}
|
|
6366
|
+
redactCache = null;
|
|
6367
|
+
append(e0) {
|
|
6368
|
+
const privacy = this.policyFor(null).config.privacy;
|
|
6369
|
+
let e = e0;
|
|
6370
|
+
if (!privacy.store_prompts && e.type === "prompt.submitted" && e.payload && typeof e.payload === "object") {
|
|
6371
|
+
const { prompt: _p, ...rest } = e.payload;
|
|
6372
|
+
e = { ...e, payload: { ...rest, prompt: "[not stored]" } };
|
|
6373
|
+
}
|
|
6374
|
+
const res = this.redactions();
|
|
6375
|
+
if (res.length)
|
|
6376
|
+
e = { ...e, payload: redactValue(e.payload, res), raw: redactValue(e.raw, res) };
|
|
4439
6377
|
const slim = slimForStorage(e);
|
|
4440
|
-
const
|
|
4441
|
-
const
|
|
6378
|
+
const p = e.payload ?? {};
|
|
6379
|
+
const actor2 = e.actor ?? this.actorFor(typeof p.owner === "string" ? p.owner : typeof p.by === "string" ? p.by : null, e.sessionId);
|
|
6380
|
+
const r = this.db.query("INSERT INTO events (ts, type, project_id, session_id, payload, raw, actor_kind, actor_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?)").run(e.ts, e.type, e.projectId, e.sessionId, JSON.stringify(slim.payload ?? null), slim.raw === undefined ? null : JSON.stringify(slim.raw), actor2.kind, actor2.id);
|
|
6381
|
+
const stored = { ...e, actor: actor2, seq: Number(r.lastInsertRowid) };
|
|
4442
6382
|
if (stored.type === "incident.opened")
|
|
4443
6383
|
this.remember(incidentDoc(stored.projectId, stored.seq, stored.payload, stored.ts, stored.sessionId));
|
|
4444
6384
|
this.projectSession(stored);
|
|
@@ -4448,9 +6388,34 @@ class Store {
|
|
|
4448
6388
|
l(wire);
|
|
4449
6389
|
return stored;
|
|
4450
6390
|
}
|
|
4451
|
-
|
|
4452
|
-
const
|
|
4453
|
-
const
|
|
6391
|
+
audit(opts = {}) {
|
|
6392
|
+
const where = [`type IN (${AUDIT_TYPES_SQL})`];
|
|
6393
|
+
const args = [];
|
|
6394
|
+
if (opts.since) {
|
|
6395
|
+
where.push("ts >= ?");
|
|
6396
|
+
args.push(opts.since);
|
|
6397
|
+
}
|
|
6398
|
+
if (opts.projectId) {
|
|
6399
|
+
where.push("project_id = ?");
|
|
6400
|
+
args.push(opts.projectId);
|
|
6401
|
+
}
|
|
6402
|
+
if (opts.type && isAuditType(opts.type)) {
|
|
6403
|
+
where.push("type = ?");
|
|
6404
|
+
args.push(opts.type);
|
|
6405
|
+
}
|
|
6406
|
+
const limit = Math.min(Math.max(opts.limit ?? 1e4, 1), 1e5);
|
|
6407
|
+
const rows = this.db.query(`SELECT * FROM (SELECT ${WIRE_COLS} FROM events WHERE ${where.join(" AND ")} ORDER BY seq DESC LIMIT ?) ORDER BY seq`).all(...args, limit);
|
|
6408
|
+
return rows.map((r) => auditRow(wireRowToEvent(r)));
|
|
6409
|
+
}
|
|
6410
|
+
prune(days2) {
|
|
6411
|
+
const cfg = this.policyFor(null).config;
|
|
6412
|
+
const chatter = days2 ?? cfg.events.retain_days;
|
|
6413
|
+
const cutoff = new Date(Date.now() - chatter * 86400000).toISOString();
|
|
6414
|
+
let n = this.db.query(`DELETE FROM events WHERE ts < ? AND type NOT IN (${AUDIT_TYPES_SQL})`).run(cutoff).changes;
|
|
6415
|
+
if (cfg.audit.retain_days > 0) {
|
|
6416
|
+
const acut = new Date(Date.now() - cfg.audit.retain_days * 86400000).toISOString();
|
|
6417
|
+
n += this.db.query(`DELETE FROM events WHERE ts < ? AND type IN (${AUDIT_TYPES_SQL}) AND type != 'incident.opened'`).run(acut).changes;
|
|
6418
|
+
}
|
|
4454
6419
|
const old = new Date(Date.now() - 7 * 86400000).toISOString();
|
|
4455
6420
|
this.db.query("UPDATE events SET raw = NULL WHERE ts < ? AND raw IS NOT NULL").run(old);
|
|
4456
6421
|
if (n > 0)
|
|
@@ -4461,11 +6426,13 @@ class Store {
|
|
|
4461
6426
|
if (typeof raw2.cwd === "string")
|
|
4462
6427
|
this.autoRenewFor(typeof raw2.session_id === "string" ? raw2.session_id : null, raw2.cwd);
|
|
4463
6428
|
const cwd = typeof raw2.cwd === "string" ? raw2.cwd : process.cwd();
|
|
4464
|
-
const project =
|
|
6429
|
+
const project = existsSync5(cwd) ? this.resolveProject(cwd) : null;
|
|
4465
6430
|
const e = this.append(normalizeHook(event, raw2, project?.id ?? "p_unknown"));
|
|
4466
6431
|
if ((event === "Stop" || event === "SessionEnd") && e.sessionId) {
|
|
4467
|
-
if (
|
|
6432
|
+
if (existsSync5(cwd)) {
|
|
4468
6433
|
this.autoHandoff(e.sessionId, cwd);
|
|
6434
|
+
this.autoGate(event, e.sessionId, cwd);
|
|
6435
|
+
}
|
|
4469
6436
|
this.rememberSession(e.sessionId);
|
|
4470
6437
|
}
|
|
4471
6438
|
if (e.sessionId && typeof raw2.transcript_path === "string") {
|
|
@@ -4488,6 +6455,15 @@ class Store {
|
|
|
4488
6455
|
"claim.renewed",
|
|
4489
6456
|
"claim.released",
|
|
4490
6457
|
"claim.orphaned",
|
|
6458
|
+
"worktree.bootstrapped",
|
|
6459
|
+
"worktree.created",
|
|
6460
|
+
"worktree.removed",
|
|
6461
|
+
"pr.opened",
|
|
6462
|
+
"question.asked",
|
|
6463
|
+
"question.answered",
|
|
6464
|
+
"dispatch.queued",
|
|
6465
|
+
"dispatch.started",
|
|
6466
|
+
"dispatch.finished",
|
|
4491
6467
|
"gate.recorded",
|
|
4492
6468
|
"handoff.recorded",
|
|
4493
6469
|
"incident.opened",
|
|
@@ -4499,7 +6475,7 @@ class Store {
|
|
|
4499
6475
|
return;
|
|
4500
6476
|
const p = e.payload;
|
|
4501
6477
|
const row = this.db.query("SELECT id, tool_counts FROM sessions WHERE id = ?").get(e.sessionId);
|
|
4502
|
-
const branch = p.cwd &&
|
|
6478
|
+
const branch = p.cwd && existsSync5(p.cwd) ? currentBranch(p.cwd) : null;
|
|
4503
6479
|
if (!row) {
|
|
4504
6480
|
this.db.query("INSERT INTO sessions (id, project_id, kind, cwd, branch, started_at, last_seen_at, last, last_type, state) VALUES (?, ?, 'interactive', ?, ?, ?, ?, ?, ?, 'active')").run(e.sessionId, e.projectId, p.cwd ?? "", branch, e.ts, e.ts, p.summary ?? e.type, e.type);
|
|
4505
6481
|
}
|
|
@@ -4519,7 +6495,7 @@ class Store {
|
|
|
4519
6495
|
const size = statSync(path).size;
|
|
4520
6496
|
if (size <= offset)
|
|
4521
6497
|
return null;
|
|
4522
|
-
const fd =
|
|
6498
|
+
const fd = openSync3(path, "r");
|
|
4523
6499
|
const buf = Buffer.alloc(size - offset);
|
|
4524
6500
|
readSync(fd, buf, 0, buf.length, offset);
|
|
4525
6501
|
closeSync(fd);
|
|
@@ -4537,13 +6513,15 @@ class Store {
|
|
|
4537
6513
|
}
|
|
4538
6514
|
}
|
|
4539
6515
|
persistTurns(sessionId, agentId, turns) {
|
|
6516
|
+
const privacy = this.policyFor(null).config.privacy;
|
|
6517
|
+
const res = this.redactions();
|
|
4540
6518
|
const up = this.db.query(`INSERT INTO turns (id, session_id, agent_id, ts, model, effort, sidechain, input, output, cache_write, cache_write_1h, cache_read, thinking, cost_usd, text, tools)
|
|
4541
6519
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
4542
6520
|
ON CONFLICT(id) DO UPDATE SET input=excluded.input, output=excluded.output, cache_write=excluded.cache_write, cache_write_1h=excluded.cache_write_1h,
|
|
4543
6521
|
cache_read=excluded.cache_read, thinking=excluded.thinking, cost_usd=excluded.cost_usd, text=CASE WHEN excluded.text != '' THEN excluded.text ELSE turns.text END, tools=excluded.tools`);
|
|
4544
6522
|
const tx = this.db.transaction((ts) => {
|
|
4545
6523
|
for (const t of ts) {
|
|
4546
|
-
up.run(t.id, sessionId, agentId, t.ts, t.model, t.effort, t.sidechain ? 1 : 0, t.usage.input, t.usage.output, t.usage.cacheWrite, t.usage.cacheWrite1h ?? 0, t.usage.cacheRead, t.usage.thinking, costUsd(t.model, t.usage, this.prices), t.text, JSON.stringify(t.tools));
|
|
6524
|
+
up.run(t.id, sessionId, agentId, t.ts, t.model, t.effort, t.sidechain ? 1 : 0, t.usage.input, t.usage.output, t.usage.cacheWrite, t.usage.cacheWrite1h ?? 0, t.usage.cacheRead, t.usage.thinking, costUsd(t.model, t.usage, this.prices), privacy.store_reasoning ? redactValue(t.text, res) : "", JSON.stringify(t.tools));
|
|
4547
6525
|
}
|
|
4548
6526
|
});
|
|
4549
6527
|
if (turns.length)
|
|
@@ -4569,12 +6547,12 @@ class Store {
|
|
|
4569
6547
|
}
|
|
4570
6548
|
tailSession(sessionId) {
|
|
4571
6549
|
const s = this.db.query("SELECT transcript_path FROM sessions WHERE id = ?").get(sessionId);
|
|
4572
|
-
if (!s?.transcript_path || !
|
|
6550
|
+
if (!s?.transcript_path || !existsSync5(s.transcript_path))
|
|
4573
6551
|
return 0;
|
|
4574
6552
|
let n = this.tailFile(s.transcript_path, sessionId, null);
|
|
4575
|
-
const subDir =
|
|
6553
|
+
const subDir = join8(dirname2(s.transcript_path), basename(s.transcript_path, ".jsonl"), "subagents");
|
|
4576
6554
|
for (const f of this.subagentFiles(subDir)) {
|
|
4577
|
-
n += this.tailFile(
|
|
6555
|
+
n += this.tailFile(join8(subDir, f), sessionId, f.replace(/^agent-|\.jsonl$/g, ""));
|
|
4578
6556
|
}
|
|
4579
6557
|
return n;
|
|
4580
6558
|
}
|
|
@@ -4606,7 +6584,7 @@ class Store {
|
|
|
4606
6584
|
return n;
|
|
4607
6585
|
}
|
|
4608
6586
|
codexRoot() {
|
|
4609
|
-
return process.env.SWARM_CODEX_DIR ??
|
|
6587
|
+
return process.env.SWARM_CODEX_DIR ?? join8(homedir3(), ".codex", "sessions");
|
|
4610
6588
|
}
|
|
4611
6589
|
codexRolloutFiles(sinceMs) {
|
|
4612
6590
|
const root = this.codexRoot();
|
|
@@ -4621,18 +6599,18 @@ class Store {
|
|
|
4621
6599
|
for (const y of ls(root)) {
|
|
4622
6600
|
if (!/^\d{4}$/.test(y))
|
|
4623
6601
|
continue;
|
|
4624
|
-
for (const m of ls(
|
|
6602
|
+
for (const m of ls(join8(root, y))) {
|
|
4625
6603
|
if (!/^\d\d$/.test(m))
|
|
4626
6604
|
continue;
|
|
4627
|
-
for (const day of ls(
|
|
6605
|
+
for (const day of ls(join8(root, y, m))) {
|
|
4628
6606
|
if (!/^\d\d$/.test(day))
|
|
4629
6607
|
continue;
|
|
4630
6608
|
if (Date.parse(`${y}-${m}-${day}T23:59:59Z`) < sinceMs)
|
|
4631
6609
|
continue;
|
|
4632
|
-
const dir =
|
|
6610
|
+
const dir = join8(root, y, m, day);
|
|
4633
6611
|
for (const f of ls(dir)) {
|
|
4634
6612
|
if (f.startsWith("rollout-") && f.endsWith(".jsonl"))
|
|
4635
|
-
out.push(
|
|
6613
|
+
out.push(join8(dir, f));
|
|
4636
6614
|
}
|
|
4637
6615
|
}
|
|
4638
6616
|
}
|
|
@@ -4640,7 +6618,7 @@ class Store {
|
|
|
4640
6618
|
return out;
|
|
4641
6619
|
}
|
|
4642
6620
|
tailCodex(windowMs = 3 * 24 * 60 * 60000) {
|
|
4643
|
-
if (!
|
|
6621
|
+
if (!existsSync5(this.codexRoot()))
|
|
4644
6622
|
return 0;
|
|
4645
6623
|
let n = 0;
|
|
4646
6624
|
for (const path of this.codexRolloutFiles(Date.now() - windowMs)) {
|
|
@@ -4649,12 +6627,12 @@ class Store {
|
|
|
4649
6627
|
return n;
|
|
4650
6628
|
}
|
|
4651
6629
|
grokRoot() {
|
|
4652
|
-
return process.env.SWARM_GROK_DIR ??
|
|
6630
|
+
return process.env.SWARM_GROK_DIR ?? join8(homedir3(), ".grok", "sessions");
|
|
4653
6631
|
}
|
|
4654
6632
|
grokSummary = new Map;
|
|
4655
6633
|
tailGrok(windowMs = 3 * 24 * 60 * 60000) {
|
|
4656
6634
|
const root = this.grokRoot();
|
|
4657
|
-
if (!
|
|
6635
|
+
if (!existsSync5(root))
|
|
4658
6636
|
return 0;
|
|
4659
6637
|
const since = Date.now() - windowMs;
|
|
4660
6638
|
const ls = (p) => {
|
|
@@ -4674,10 +6652,10 @@ class Store {
|
|
|
4674
6652
|
} catch {
|
|
4675
6653
|
cwd = enc;
|
|
4676
6654
|
}
|
|
4677
|
-
const cwdDir =
|
|
6655
|
+
const cwdDir = join8(root, enc);
|
|
4678
6656
|
for (const sid of ls(cwdDir)) {
|
|
4679
|
-
const path =
|
|
4680
|
-
if (!
|
|
6657
|
+
const path = join8(cwdDir, sid, "updates.jsonl");
|
|
6658
|
+
if (!existsSync5(path))
|
|
4681
6659
|
continue;
|
|
4682
6660
|
try {
|
|
4683
6661
|
if (statSync(path).mtimeMs < since)
|
|
@@ -4685,7 +6663,7 @@ class Store {
|
|
|
4685
6663
|
} catch {
|
|
4686
6664
|
continue;
|
|
4687
6665
|
}
|
|
4688
|
-
const sumPath =
|
|
6666
|
+
const sumPath = join8(cwdDir, sid, "summary.json");
|
|
4689
6667
|
let title;
|
|
4690
6668
|
let fresh = false;
|
|
4691
6669
|
try {
|
|
@@ -4736,9 +6714,9 @@ class Store {
|
|
|
4736
6714
|
ensureAgentSession(sid, agent, cwd, mtime) {
|
|
4737
6715
|
if (this.db.query("SELECT 1 FROM sessions WHERE id = ?").get(sid))
|
|
4738
6716
|
return;
|
|
4739
|
-
const project = cwd &&
|
|
6717
|
+
const project = cwd && existsSync5(cwd) ? this.resolveProject(cwd) : null;
|
|
4740
6718
|
const ts = new Date(mtime).toISOString();
|
|
4741
|
-
this.db.query("INSERT INTO sessions (id, project_id, kind, agent, cwd, branch, started_at, last_seen_at, last, last_type, state) VALUES (?, ?, 'interactive', ?, ?, ?, ?, ?, '', '', 'active')").run(sid, project?.id ?? "p_unknown", agent, cwd, cwd &&
|
|
6719
|
+
this.db.query("INSERT INTO sessions (id, project_id, kind, agent, cwd, branch, started_at, last_seen_at, last, last_type, state) VALUES (?, ?, 'interactive', ?, ?, ?, ?, ?, '', '', 'active')").run(sid, project?.id ?? "p_unknown", agent, cwd, cwd && existsSync5(cwd) ? currentBranch(cwd) : null, ts, ts);
|
|
4742
6720
|
}
|
|
4743
6721
|
claimRows(projectId) {
|
|
4744
6722
|
return this.db.query("SELECT * FROM claims WHERE project_id = ?").all(projectId).map((r) => ({
|
|
@@ -4772,9 +6750,9 @@ class Store {
|
|
|
4772
6750
|
worktreePath(projectId, task) {
|
|
4773
6751
|
const slug = (x) => x.replace(/[^a-zA-Z0-9._-]+/g, "-").toLowerCase();
|
|
4774
6752
|
const p = this.project(projectId);
|
|
4775
|
-
return
|
|
6753
|
+
return join8(this.home, "worktrees", slug(p?.name ?? projectId), slug(task));
|
|
4776
6754
|
}
|
|
4777
|
-
claim(projectId, task, owner, baseRef = "HEAD") {
|
|
6755
|
+
claim(projectId, task, owner, baseRef = "HEAD", sessionId = null) {
|
|
4778
6756
|
const p = this.project(projectId);
|
|
4779
6757
|
if (!p)
|
|
4780
6758
|
return { ok: false, error: "unknown project" };
|
|
@@ -4783,28 +6761,77 @@ class Store {
|
|
|
4783
6761
|
if (!decision.ok)
|
|
4784
6762
|
return { ok: false, error: claimRefusalMessage(decision, task) };
|
|
4785
6763
|
const branch = `task/${task}`;
|
|
4786
|
-
const
|
|
4787
|
-
if (
|
|
4788
|
-
return { ok: false, error: `${
|
|
4789
|
-
|
|
4790
|
-
const created = worktreeAdd(p.root,
|
|
6764
|
+
const worktree2 = this.worktreePath(projectId, task);
|
|
6765
|
+
if (existsSync5(worktree2))
|
|
6766
|
+
return { ok: false, error: `${worktree2} already exists; release ${task} first` };
|
|
6767
|
+
mkdirSync4(dirname2(worktree2), { recursive: true });
|
|
6768
|
+
const created = worktreeAdd(p.root, worktree2, branch, baseRef);
|
|
4791
6769
|
if (!created)
|
|
4792
6770
|
return { ok: false, error: `git worktree add failed for ${task}` };
|
|
4793
6771
|
this.invalidateWorktrees(projectId);
|
|
4794
6772
|
const expiresAt = nextExpiry(now);
|
|
4795
6773
|
const acquiredAt = new Date(now).toISOString();
|
|
4796
|
-
this.db.query(`INSERT INTO claims (project_id, task, owner, worktree, branch, acquired_at, expires_at, released_at, state)
|
|
4797
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, NULL, 'held')
|
|
6774
|
+
this.db.query(`INSERT INTO claims (project_id, task, owner, worktree, branch, acquired_at, expires_at, released_at, state, actor_kind, actor_id)
|
|
6775
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, NULL, 'held', ?, ?)
|
|
4798
6776
|
ON CONFLICT(project_id, task) DO UPDATE SET owner=excluded.owner, worktree=excluded.worktree, branch=excluded.branch,
|
|
4799
|
-
acquired_at=excluded.acquired_at, expires_at=excluded.expires_at, released_at=NULL, state='held'
|
|
6777
|
+
acquired_at=excluded.acquired_at, expires_at=excluded.expires_at, released_at=NULL, state='held',
|
|
6778
|
+
actor_kind=excluded.actor_kind, actor_id=excluded.actor_id`).run(projectId, task, owner, created, branch, acquiredAt, expiresAt, ...actorCols(this.actorFor(owner, sessionId)));
|
|
4800
6779
|
this.append({
|
|
4801
6780
|
ts: acquiredAt,
|
|
4802
6781
|
type: "claim.acquired",
|
|
4803
6782
|
projectId,
|
|
4804
|
-
sessionId
|
|
6783
|
+
sessionId,
|
|
6784
|
+
actor: this.actorFor(owner, sessionId),
|
|
4805
6785
|
payload: { task, owner, worktree: created, branch, summary: `claim ${task} by ${owner}` }
|
|
4806
6786
|
});
|
|
4807
|
-
|
|
6787
|
+
const bootstrap = this.bootstrapWorktree(projectId, task, p.root, created);
|
|
6788
|
+
return { ok: true, task, owner, worktree: created, branch, expiresAt, bootstrap };
|
|
6789
|
+
}
|
|
6790
|
+
bootstraps = new Map;
|
|
6791
|
+
bootstrapWorktree(projectId, task, repoRoot, worktree2) {
|
|
6792
|
+
const plan = planBootstrap(loadConfig({ repoRoot, home: this.home }), repoRoot, worktree2);
|
|
6793
|
+
if (!needsBootstrap(plan))
|
|
6794
|
+
return null;
|
|
6795
|
+
const job = runBootstrap(plan, { worktree: worktree2, home: this.home, projectId, task });
|
|
6796
|
+
const done = job.done.then((o) => {
|
|
6797
|
+
this.bootstraps.delete(worktree2);
|
|
6798
|
+
const ts = new Date().toISOString();
|
|
6799
|
+
const ok = !o.setup || o.setup.exitCode === 0;
|
|
6800
|
+
this.append({
|
|
6801
|
+
ts,
|
|
6802
|
+
type: "worktree.bootstrapped",
|
|
6803
|
+
projectId,
|
|
6804
|
+
sessionId: null,
|
|
6805
|
+
payload: {
|
|
6806
|
+
task,
|
|
6807
|
+
worktree: worktree2,
|
|
6808
|
+
ok,
|
|
6809
|
+
log: job.log,
|
|
6810
|
+
...o,
|
|
6811
|
+
summary: `bootstrap ${task}: ${summarizeBootstrap(o)}`
|
|
6812
|
+
}
|
|
6813
|
+
});
|
|
6814
|
+
if (!ok)
|
|
6815
|
+
this.append({
|
|
6816
|
+
ts,
|
|
6817
|
+
type: "incident.opened",
|
|
6818
|
+
projectId,
|
|
6819
|
+
sessionId: null,
|
|
6820
|
+
payload: {
|
|
6821
|
+
rule: "bootstrap_failed",
|
|
6822
|
+
action: "failed",
|
|
6823
|
+
command: o.setup?.command ?? "",
|
|
6824
|
+
reason: `worktree setup for ${task} exited ${o.setup?.exitCode} \u2014 see ${job.log}`
|
|
6825
|
+
}
|
|
6826
|
+
});
|
|
6827
|
+
this.touch();
|
|
6828
|
+
return o;
|
|
6829
|
+
});
|
|
6830
|
+
this.bootstraps.set(worktree2, done);
|
|
6831
|
+
return job.log;
|
|
6832
|
+
}
|
|
6833
|
+
awaitBootstrap(worktree2) {
|
|
6834
|
+
return this.bootstraps.get(worktree2) ?? Promise.resolve();
|
|
4808
6835
|
}
|
|
4809
6836
|
autoRenewAt = new Map;
|
|
4810
6837
|
autoRenewFor(sessionId, cwd) {
|
|
@@ -4847,7 +6874,7 @@ class Store {
|
|
|
4847
6874
|
for (const c of this.claimRows(p.id)) {
|
|
4848
6875
|
if (c.state !== "held" || isActive(c, now))
|
|
4849
6876
|
continue;
|
|
4850
|
-
const exists = c.worktree ?
|
|
6877
|
+
const exists = c.worktree ? existsSync5(c.worktree) : false;
|
|
4851
6878
|
const work = exists ? heldWork(c.worktree) : null;
|
|
4852
6879
|
if (reapAction(c, now, exists, work) !== "keep-orphaned")
|
|
4853
6880
|
continue;
|
|
@@ -4902,18 +6929,18 @@ class Store {
|
|
|
4902
6929
|
const row = this.db.query("SELECT * FROM claims WHERE project_id = ? AND task = ?").get(projectId, task);
|
|
4903
6930
|
if (!row)
|
|
4904
6931
|
return { ok: false, error: `no claim on ${task}` };
|
|
4905
|
-
const
|
|
4906
|
-
if (
|
|
4907
|
-
const work = heldWork(
|
|
6932
|
+
const worktree2 = row.worktree ?? "";
|
|
6933
|
+
if (worktree2 && existsSync5(worktree2)) {
|
|
6934
|
+
const work = heldWork(worktree2);
|
|
4908
6935
|
const can = canRelease(work, force);
|
|
4909
6936
|
if (!can.ok)
|
|
4910
6937
|
return {
|
|
4911
6938
|
ok: false,
|
|
4912
|
-
error: releaseRefusalMessage(can,
|
|
6939
|
+
error: releaseRefusalMessage(can, worktree2),
|
|
4913
6940
|
refused: can.reason
|
|
4914
6941
|
};
|
|
4915
|
-
if (p && !worktreeRemove(p.root,
|
|
4916
|
-
return { ok: false, error: `git worktree remove failed for ${
|
|
6942
|
+
if (p && !worktreeRemove(p.root, worktree2, force))
|
|
6943
|
+
return { ok: false, error: `git worktree remove failed for ${worktree2}` };
|
|
4917
6944
|
this.invalidateWorktrees(projectId);
|
|
4918
6945
|
}
|
|
4919
6946
|
const releasedAt = new Date().toISOString();
|
|
@@ -4938,7 +6965,7 @@ class Store {
|
|
|
4938
6965
|
continue;
|
|
4939
6966
|
if (isActive({ ...c, state: "held" }, now))
|
|
4940
6967
|
continue;
|
|
4941
|
-
const exists = c.worktree ?
|
|
6968
|
+
const exists = c.worktree ? existsSync5(c.worktree) : false;
|
|
4942
6969
|
const work = exists ? heldWork(c.worktree) : null;
|
|
4943
6970
|
const action = reapAction({ ...c, state: "held" }, now, exists, work);
|
|
4944
6971
|
if (action === "not-expired")
|
|
@@ -5029,6 +7056,141 @@ class Store {
|
|
|
5029
7056
|
this.wtInflight.set(projectId, run2);
|
|
5030
7057
|
return run2;
|
|
5031
7058
|
}
|
|
7059
|
+
createWorktree(projectId, name, baseRef = "HEAD", branch) {
|
|
7060
|
+
const p = this.project(projectId);
|
|
7061
|
+
if (!p)
|
|
7062
|
+
return { ok: false, error: "unknown project" };
|
|
7063
|
+
const slug = name.replace(/[^a-zA-Z0-9._-]+/g, "-").toLowerCase();
|
|
7064
|
+
if (!slug || slug === "." || slug === "..")
|
|
7065
|
+
return { ok: false, error: "bad worktree name" };
|
|
7066
|
+
const path = this.worktreePath(projectId, slug);
|
|
7067
|
+
if (existsSync5(path))
|
|
7068
|
+
return { ok: false, error: `${path} already exists` };
|
|
7069
|
+
mkdirSync4(dirname2(path), { recursive: true });
|
|
7070
|
+
const br = branch?.trim() || `wt/${slug}`;
|
|
7071
|
+
const created = worktreeAdd(p.root, path, br, baseRef);
|
|
7072
|
+
if (!created)
|
|
7073
|
+
return { ok: false, error: `git worktree add failed for ${name}` };
|
|
7074
|
+
this.invalidateWorktrees(projectId);
|
|
7075
|
+
this.append({
|
|
7076
|
+
ts: new Date().toISOString(),
|
|
7077
|
+
type: "worktree.created",
|
|
7078
|
+
projectId,
|
|
7079
|
+
sessionId: null,
|
|
7080
|
+
payload: { name: slug, worktree: created, branch: br, summary: `worktree ${slug} created` }
|
|
7081
|
+
});
|
|
7082
|
+
const bootstrap = this.bootstrapWorktree(projectId, slug, p.root, created);
|
|
7083
|
+
return { ok: true, name: slug, worktree: created, branch: br, bootstrap };
|
|
7084
|
+
}
|
|
7085
|
+
findWorktree(projectId, ref) {
|
|
7086
|
+
const wts = this.wtCache.get(projectId)?.v ?? [];
|
|
7087
|
+
const abs = ref.startsWith("/") ? ref.replace(/\/+$/, "") : null;
|
|
7088
|
+
return wts.find((w) => w.path === abs) ?? wts.find((w) => !w.main && basename(w.path) === ref) ?? wts.find((w) => w.branch === ref) ?? null;
|
|
7089
|
+
}
|
|
7090
|
+
async removeWorktree(projectId, ref, force = false) {
|
|
7091
|
+
const p = this.project(projectId);
|
|
7092
|
+
if (!p)
|
|
7093
|
+
return { ok: false, error: "unknown project" };
|
|
7094
|
+
await this.refreshWorktrees(projectId);
|
|
7095
|
+
const w = this.findWorktree(projectId, ref);
|
|
7096
|
+
if (!w)
|
|
7097
|
+
return { ok: false, error: `no worktree ${ref} in ${p.name}` };
|
|
7098
|
+
const held = this.claims(projectId).find((c) => c.state === "held" && c.worktree === w.path);
|
|
7099
|
+
const can = canRemoveWorktree(w, held?.task ?? null, force);
|
|
7100
|
+
if (!can.ok)
|
|
7101
|
+
return {
|
|
7102
|
+
ok: false,
|
|
7103
|
+
error: removeRefusalMessage(can.reason, w.path, held?.task),
|
|
7104
|
+
refused: can.reason
|
|
7105
|
+
};
|
|
7106
|
+
if (!worktreeRemove(p.root, w.path, force))
|
|
7107
|
+
return { ok: false, error: `git worktree remove failed for ${w.path}` };
|
|
7108
|
+
this.invalidateWorktrees(projectId);
|
|
7109
|
+
this.append({
|
|
7110
|
+
ts: new Date().toISOString(),
|
|
7111
|
+
type: "worktree.removed",
|
|
7112
|
+
projectId,
|
|
7113
|
+
sessionId: null,
|
|
7114
|
+
payload: {
|
|
7115
|
+
worktree: w.path,
|
|
7116
|
+
branch: w.branch,
|
|
7117
|
+
force,
|
|
7118
|
+
summary: `worktree ${basename(w.path)} removed`
|
|
7119
|
+
}
|
|
7120
|
+
});
|
|
7121
|
+
return { ok: true, worktree: w.path };
|
|
7122
|
+
}
|
|
7123
|
+
async gcWorktrees(projectId, apply = false) {
|
|
7124
|
+
await this.refreshWorktrees(projectId);
|
|
7125
|
+
const plan = planGc(this.wtCache.get(projectId)?.v ?? [], this.claims(projectId));
|
|
7126
|
+
const removed = [];
|
|
7127
|
+
if (apply)
|
|
7128
|
+
for (const c of plan) {
|
|
7129
|
+
if (!c.removable)
|
|
7130
|
+
continue;
|
|
7131
|
+
const r = await this.removeWorktree(projectId, c.path, false);
|
|
7132
|
+
if (r.ok)
|
|
7133
|
+
removed.push(c.path);
|
|
7134
|
+
}
|
|
7135
|
+
return { candidates: plan, removed };
|
|
7136
|
+
}
|
|
7137
|
+
openWorktree(projectId, ref) {
|
|
7138
|
+
const p = this.project(projectId);
|
|
7139
|
+
if (!p)
|
|
7140
|
+
return { ok: false, error: "unknown project" };
|
|
7141
|
+
const w = this.findWorktree(projectId, ref);
|
|
7142
|
+
if (!w)
|
|
7143
|
+
return { ok: false, error: `no worktree ${ref}` };
|
|
7144
|
+
const cfg = loadConfig({ repoRoot: p.root, home: this.home }).worktree.open;
|
|
7145
|
+
const cmd = cfg ? ["sh", "-c", cfg.replace(/\{path\}/g, `'${w.path.replace(/'/g, "'\\''")}'`)] : [
|
|
7146
|
+
process.platform === "darwin" ? "open" : process.platform === "win32" ? "explorer" : "xdg-open",
|
|
7147
|
+
w.path
|
|
7148
|
+
];
|
|
7149
|
+
try {
|
|
7150
|
+
Bun.spawn(cmd, { stdin: "ignore", stdout: "ignore", stderr: "ignore" }).unref();
|
|
7151
|
+
return { ok: true, worktree: w.path, command: cmd.join(" ") };
|
|
7152
|
+
} catch (e) {
|
|
7153
|
+
return { ok: false, error: e.message };
|
|
7154
|
+
}
|
|
7155
|
+
}
|
|
7156
|
+
async prDraftFor(projectId, ref) {
|
|
7157
|
+
const p = this.project(projectId);
|
|
7158
|
+
if (!p)
|
|
7159
|
+
return { ok: false, error: "unknown project" };
|
|
7160
|
+
await this.refreshWorktrees(projectId);
|
|
7161
|
+
const claim = this.claims(projectId).find((c) => c.task === ref && c.state === "held");
|
|
7162
|
+
const w = this.findWorktree(projectId, claim?.worktree ?? ref);
|
|
7163
|
+
if (!w)
|
|
7164
|
+
return { ok: false, error: `no worktree or held task ${ref}` };
|
|
7165
|
+
const task = claim?.task ?? this.claims(projectId).find((c) => c.state === "held" && c.worktree === w.path)?.task ?? (w.branch?.startsWith("task/") ? w.branch.slice(5) : null) ?? basename(w.path);
|
|
7166
|
+
const taskRow = this.tasks(projectId)?.tasks.find((t) => t.id === task) ?? null;
|
|
7167
|
+
const handoff = this.latestHandoff(projectId, task);
|
|
7168
|
+
const required = this.requiredGates(projectId);
|
|
7169
|
+
const gates2 = required.length ? this.gateStatusFor(this.gateRuns(projectId, task), required).map((g) => ({
|
|
7170
|
+
gate: g.gate,
|
|
7171
|
+
verdict: g.verdict
|
|
7172
|
+
})) : [];
|
|
7173
|
+
const diff = await worktreeDiff(p.root, w.path);
|
|
7174
|
+
const d = prDraft({
|
|
7175
|
+
task,
|
|
7176
|
+
title: taskRow?.title ?? null,
|
|
7177
|
+
handoff,
|
|
7178
|
+
gates: gates2,
|
|
7179
|
+
files: diff.files,
|
|
7180
|
+
commits: diff.commits
|
|
7181
|
+
});
|
|
7182
|
+
return { ok: true, task, worktree: w, ...d, diff };
|
|
7183
|
+
}
|
|
7184
|
+
recordPrOpened(projectId, task, worktree2, url) {
|
|
7185
|
+
this.append({
|
|
7186
|
+
ts: new Date().toISOString(),
|
|
7187
|
+
type: "pr.opened",
|
|
7188
|
+
projectId,
|
|
7189
|
+
sessionId: null,
|
|
7190
|
+
payload: { task, worktree: worktree2, url, summary: `PR opened for ${task}: ${url}` }
|
|
7191
|
+
});
|
|
7192
|
+
this.touch();
|
|
7193
|
+
}
|
|
5032
7194
|
invalidateWorktrees(projectId) {
|
|
5033
7195
|
if (projectId)
|
|
5034
7196
|
this.wtCache.delete(projectId);
|
|
@@ -5098,6 +7260,56 @@ class Store {
|
|
|
5098
7260
|
};
|
|
5099
7261
|
});
|
|
5100
7262
|
}
|
|
7263
|
+
projectSpend(projectId) {
|
|
7264
|
+
const dayStart = new Date;
|
|
7265
|
+
dayStart.setHours(0, 0, 0, 0);
|
|
7266
|
+
const weekStart = new Date(Date.now() - 7 * 86400000).toISOString();
|
|
7267
|
+
const q = (since) => this.db.query("SELECT COALESCE(SUM(t.cost_usd), 0) AS cost FROM turns t JOIN sessions s ON s.id = t.session_id WHERE s.project_id = ? AND t.ts >= ?").get(projectId, since).cost;
|
|
7268
|
+
return { today: q(dayStart.toISOString()), week: q(weekStart) };
|
|
7269
|
+
}
|
|
7270
|
+
budgetFor(projectId) {
|
|
7271
|
+
const cfg = this.config(projectId).budget;
|
|
7272
|
+
if (!cfg.daily && !cfg.weekly)
|
|
7273
|
+
return null;
|
|
7274
|
+
return { status: budgetStatus(this.projectSpend(projectId), cfg), config: cfg };
|
|
7275
|
+
}
|
|
7276
|
+
budgetNotified = new Map;
|
|
7277
|
+
budgetListeners = new Set;
|
|
7278
|
+
onBudgetStop(fn) {
|
|
7279
|
+
this.budgetListeners.add(fn);
|
|
7280
|
+
}
|
|
7281
|
+
checkBudgets() {
|
|
7282
|
+
const day = new Date().toDateString();
|
|
7283
|
+
const out = [];
|
|
7284
|
+
for (const p of this.projects()) {
|
|
7285
|
+
const b = this.budgetFor(p.id);
|
|
7286
|
+
if (!b || b.status.level === "ok")
|
|
7287
|
+
continue;
|
|
7288
|
+
out.push({ projectId: p.id, status: b.status });
|
|
7289
|
+
const key = `${day}:${b.status.level}`;
|
|
7290
|
+
if (this.budgetNotified.get(p.id) === key)
|
|
7291
|
+
continue;
|
|
7292
|
+
this.budgetNotified.set(p.id, key);
|
|
7293
|
+
const msg = budgetMessage(b.status, p.name);
|
|
7294
|
+
this.append({
|
|
7295
|
+
ts: new Date().toISOString(),
|
|
7296
|
+
type: "incident.opened",
|
|
7297
|
+
projectId: p.id,
|
|
7298
|
+
sessionId: null,
|
|
7299
|
+
payload: {
|
|
7300
|
+
rule: "budget",
|
|
7301
|
+
action: b.status.level === "exceeded" ? b.config.on_exceed : "warn",
|
|
7302
|
+
command: `${b.status.kind} budget`,
|
|
7303
|
+
reason: b.status.level === "exceeded" ? `${msg}. ${b.config.on_exceed === "stop" ? "Spawned runs were stopped and the dispatch queue cleared." : b.config.on_exceed === "ask" ? "Every Bash/Edit/Write now asks first." : "Raise [budget] in .swarm.toml or wait for the next day."}` : `${msg} \u2014 approaching the ceiling`
|
|
7304
|
+
}
|
|
7305
|
+
});
|
|
7306
|
+
if (b.status.level === "exceeded" && b.config.on_exceed === "stop")
|
|
7307
|
+
for (const fn of this.budgetListeners)
|
|
7308
|
+
fn(p.id, b.status);
|
|
7309
|
+
this.touch();
|
|
7310
|
+
}
|
|
7311
|
+
return out;
|
|
7312
|
+
}
|
|
5101
7313
|
spend() {
|
|
5102
7314
|
const dayStart = new Date;
|
|
5103
7315
|
dayStart.setHours(0, 0, 0, 0);
|
|
@@ -5245,19 +7457,19 @@ class Store {
|
|
|
5245
7457
|
WHERE e.type = 'incident.opened' AND a.seq IS NULL${projectId ? " AND e.project_id = ?" : ""}`).get(...projectId ? [projectId] : []);
|
|
5246
7458
|
return r.n;
|
|
5247
7459
|
}
|
|
5248
|
-
ackIncident(seq) {
|
|
7460
|
+
ackIncident(seq, by) {
|
|
5249
7461
|
const row = this.db.query("SELECT seq FROM events WHERE seq = ? AND type = 'incident.opened'").get(seq);
|
|
5250
7462
|
if (!row)
|
|
5251
7463
|
return false;
|
|
5252
|
-
this.db.query("INSERT OR IGNORE INTO incident_acks (seq, acked_at) VALUES (?, ?)").run(seq, new Date().toISOString());
|
|
7464
|
+
this.db.query("INSERT OR IGNORE INTO incident_acks (seq, acked_at, actor_kind, actor_id) VALUES (?, ?, ?, ?)").run(seq, new Date().toISOString(), ...actorCols(this.actorFor(by ?? "dashboard")));
|
|
5253
7465
|
this.touch();
|
|
5254
7466
|
return true;
|
|
5255
7467
|
}
|
|
5256
|
-
ackAllIncidents(projectId) {
|
|
7468
|
+
ackAllIncidents(projectId, by) {
|
|
5257
7469
|
const at = new Date().toISOString();
|
|
5258
|
-
const r = this.db.query(`INSERT OR IGNORE INTO incident_acks (seq, acked_at)
|
|
5259
|
-
SELECT e.seq, ? FROM events e LEFT JOIN incident_acks a ON a.seq = e.seq
|
|
5260
|
-
WHERE e.type = 'incident.opened' AND a.seq IS NULL${projectId ? " AND e.project_id = ?" : ""}`).run(at, ...projectId ? [projectId] : []);
|
|
7470
|
+
const r = this.db.query(`INSERT OR IGNORE INTO incident_acks (seq, acked_at, actor_kind, actor_id)
|
|
7471
|
+
SELECT e.seq, ?, ?, ? FROM events e LEFT JOIN incident_acks a ON a.seq = e.seq
|
|
7472
|
+
WHERE e.type = 'incident.opened' AND a.seq IS NULL${projectId ? " AND e.project_id = ?" : ""}`).run(at, ...actorCols(this.actorFor(by ?? "dashboard")), ...projectId ? [projectId] : []);
|
|
5261
7473
|
this.touch();
|
|
5262
7474
|
return Number(r.changes);
|
|
5263
7475
|
}
|
|
@@ -5424,8 +7636,8 @@ class Store {
|
|
|
5424
7636
|
endedAt: null
|
|
5425
7637
|
};
|
|
5426
7638
|
this.db.query("UPDATE processes SET ended_at = ? WHERE ended_at IS NULL AND project_id = ? AND name = ?").run(p.startedAt, p.projectId, p.name);
|
|
5427
|
-
this.db.query(`INSERT INTO processes (pid, start_time, project_id, session_id, kind, name, port, cwd, cmd, owner, log, started_at, ended_at)
|
|
5428
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL)`).run(p.pid, p.startTime, p.projectId, p.sessionId, p.kind, p.name, p.port, p.cwd, p.cmd, p.owner, p.log, p.startedAt);
|
|
7639
|
+
this.db.query(`INSERT INTO processes (pid, start_time, project_id, session_id, kind, name, port, cwd, cmd, owner, log, started_at, ended_at, actor_kind, actor_id)
|
|
7640
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, ?, ?)`).run(p.pid, p.startTime, p.projectId, p.sessionId, p.kind, p.name, p.port, p.cwd, p.cmd, p.owner, p.log, p.startedAt, ...actorCols(this.actorFor(p.owner, p.sessionId)));
|
|
5429
7641
|
this.append({
|
|
5430
7642
|
ts: p.startedAt,
|
|
5431
7643
|
type: "process.started",
|
|
@@ -5489,11 +7701,12 @@ class Store {
|
|
|
5489
7701
|
expiresAt,
|
|
5490
7702
|
released: false
|
|
5491
7703
|
};
|
|
5492
|
-
this.db.query(`INSERT INTO resources (name, project_id, kind, owner, session_id, pid, port, acquired_at, expires_at, released)
|
|
5493
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 0)
|
|
7704
|
+
this.db.query(`INSERT INTO resources (name, project_id, kind, owner, session_id, pid, port, acquired_at, expires_at, released, actor_kind, actor_id)
|
|
7705
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?)
|
|
5494
7706
|
ON CONFLICT(name, project_id) DO UPDATE SET
|
|
5495
7707
|
kind=excluded.kind, owner=excluded.owner, session_id=excluded.session_id, pid=excluded.pid,
|
|
5496
|
-
port=excluded.port, acquired_at=excluded.acquired_at, expires_at=excluded.expires_at, released=0
|
|
7708
|
+
port=excluded.port, acquired_at=excluded.acquired_at, expires_at=excluded.expires_at, released=0,
|
|
7709
|
+
actor_kind=excluded.actor_kind, actor_id=excluded.actor_id`).run(resource.name, key, resource.kind, resource.owner, resource.sessionId, resource.pid, resource.port, resource.acquiredAt, resource.expiresAt, ...actorCols(this.actorFor(resource.owner, resource.sessionId)));
|
|
5497
7710
|
this.append({
|
|
5498
7711
|
ts: resource.acquiredAt,
|
|
5499
7712
|
type: "resource.acquired",
|
|
@@ -5540,7 +7753,7 @@ class Store {
|
|
|
5540
7753
|
}
|
|
5541
7754
|
snapshot() {
|
|
5542
7755
|
const worktrees = {};
|
|
5543
|
-
const projects = this.projects();
|
|
7756
|
+
const projects = this.projects().filter((p) => !(p.discovered && isScratchRoot(p.root)));
|
|
5544
7757
|
for (const p of projects)
|
|
5545
7758
|
worktrees[p.id] = this.worktrees(p.id);
|
|
5546
7759
|
return {
|
|
@@ -5552,12 +7765,13 @@ class Store {
|
|
|
5552
7765
|
processes: this.memoised("processes", 5000, () => this.processes()),
|
|
5553
7766
|
incidents: this.memoised("incidents", 30000, () => this.incidents(20, { open: true })),
|
|
5554
7767
|
openIncidents: this.memoised("openIncidents", 30000, () => this.openIncidents()),
|
|
7768
|
+
questions: this.questions({ open: true, limit: 50 }),
|
|
5555
7769
|
resources: this.resources(),
|
|
5556
7770
|
seq: this.seq()
|
|
5557
7771
|
};
|
|
5558
7772
|
}
|
|
5559
7773
|
}
|
|
5560
|
-
var WIRE_COLS = "seq, ts, type, project_id, session_id, json_remove(payload, '$.toolInput', '$.toolResponse', '$.prompt') AS payload";
|
|
7774
|
+
var WIRE_COLS = "seq, ts, type, project_id, session_id, actor_kind, actor_id, json_remove(payload, '$.toolInput', '$.toolResponse', '$.prompt') AS payload";
|
|
5561
7775
|
var RAW_TOOL_KEYS = ["tool_input", "tool_response", "toolInput", "toolResponse", "toolResult"];
|
|
5562
7776
|
var TOOL_INPUT_MAX = 2048;
|
|
5563
7777
|
var TOOL_RESPONSE_MAX = 4096;
|
|
@@ -5603,7 +7817,7 @@ function toWire(e) {
|
|
|
5603
7817
|
}
|
|
5604
7818
|
function wireRowToEvent(r) {
|
|
5605
7819
|
const p = JSON.parse(r.payload ?? "null");
|
|
5606
|
-
|
|
7820
|
+
const e = {
|
|
5607
7821
|
seq: r.seq,
|
|
5608
7822
|
ts: r.ts,
|
|
5609
7823
|
type: r.type,
|
|
@@ -5611,6 +7825,10 @@ function wireRowToEvent(r) {
|
|
|
5611
7825
|
sessionId: r.session_id ?? null,
|
|
5612
7826
|
payload: p
|
|
5613
7827
|
};
|
|
7828
|
+
const a = actorFromColumns(r.actor_kind, r.actor_id, r.session_id);
|
|
7829
|
+
if (a)
|
|
7830
|
+
e.actor = a;
|
|
7831
|
+
return e;
|
|
5614
7832
|
}
|
|
5615
7833
|
function rowToEvent(r) {
|
|
5616
7834
|
const e = {
|
|
@@ -5623,17 +7841,32 @@ function rowToEvent(r) {
|
|
|
5623
7841
|
};
|
|
5624
7842
|
if (r.raw)
|
|
5625
7843
|
e.raw = JSON.parse(r.raw);
|
|
7844
|
+
const a = actorFromColumns(r.actor_kind, r.actor_id, r.session_id);
|
|
7845
|
+
if (a)
|
|
7846
|
+
e.actor = a;
|
|
5626
7847
|
return e;
|
|
5627
7848
|
}
|
|
7849
|
+
function osUser() {
|
|
7850
|
+
try {
|
|
7851
|
+
return userInfo().username || process.env.USER || "me";
|
|
7852
|
+
} catch {
|
|
7853
|
+
return process.env.USER || "me";
|
|
7854
|
+
}
|
|
7855
|
+
}
|
|
7856
|
+
var actorCols = (a) => [a.kind, a.id];
|
|
7857
|
+
function isScratchRoot(root) {
|
|
7858
|
+
const tmp = [tmpdir(), "/tmp", "/private/tmp", "/private/var/folders", "/var/folders"];
|
|
7859
|
+
return tmp.some((t) => root === t || root.startsWith(`${t}/`));
|
|
7860
|
+
}
|
|
5628
7861
|
|
|
5629
7862
|
// packages/daemon/src/app.ts
|
|
5630
|
-
var VERSION = "0.
|
|
7863
|
+
var VERSION = "0.8.0";
|
|
5631
7864
|
var WEB_DIR = (() => {
|
|
5632
7865
|
if (process.env.SWARM_WEB_DIR)
|
|
5633
7866
|
return process.env.SWARM_WEB_DIR;
|
|
5634
|
-
const here =
|
|
5635
|
-
const dev =
|
|
5636
|
-
return
|
|
7867
|
+
const here = dirname3(fileURLToPath(import.meta.url));
|
|
7868
|
+
const dev = join9(here, "../../web/public");
|
|
7869
|
+
return existsSync6(join9(dev, "index.html")) ? dev : join9(here, "../web");
|
|
5637
7870
|
})();
|
|
5638
7871
|
var REPLAY_TAIL = 200;
|
|
5639
7872
|
var wireCache = new WeakMap;
|
|
@@ -5645,11 +7878,41 @@ function wireJson(e) {
|
|
|
5645
7878
|
}
|
|
5646
7879
|
return s;
|
|
5647
7880
|
}
|
|
7881
|
+
function hookRepoRoot(store, raw2) {
|
|
7882
|
+
const cwd = typeof raw2.cwd === "string" ? raw2.cwd : "";
|
|
7883
|
+
return cwd && existsSync6(cwd) ? store.resolveProject(cwd)?.root ?? null : null;
|
|
7884
|
+
}
|
|
5648
7885
|
function createApp(store = new Store) {
|
|
5649
7886
|
const app = new Hono2;
|
|
5650
7887
|
const forge2 = new ForgeService(store);
|
|
5651
7888
|
const runner = new Runner(store, store.home);
|
|
5652
|
-
|
|
7889
|
+
const dispatcher = new Dispatcher(store, runner, forge2);
|
|
7890
|
+
store.onBudgetStop((projectId) => {
|
|
7891
|
+
dispatcher.clear(projectId);
|
|
7892
|
+
for (const run2 of runner.list(projectId))
|
|
7893
|
+
runner.stop(run2.id);
|
|
7894
|
+
});
|
|
7895
|
+
app.use("/v1/*", async (c, next) => {
|
|
7896
|
+
if (c.req.path === "/v1/health")
|
|
7897
|
+
return next();
|
|
7898
|
+
const token = readToken(store.home);
|
|
7899
|
+
const given = c.req.header("authorization")?.replace(/^Bearer\s+/i, "") ?? c.req.query("token");
|
|
7900
|
+
if (token && given && given === token)
|
|
7901
|
+
return next();
|
|
7902
|
+
if (given)
|
|
7903
|
+
return c.json({ error: "unauthorized: wrong daemon token" }, 401);
|
|
7904
|
+
const ip = c.env?.requestIP?.(c.req.raw)?.address;
|
|
7905
|
+
const loopback = !ip || ip === "127.0.0.1" || ip === "::1" || ip === "::ffff:127.0.0.1";
|
|
7906
|
+
if (loopback && store.policyFor(null).config.daemon.auth !== "required")
|
|
7907
|
+
return next();
|
|
7908
|
+
return c.json({ error: "unauthorized: send the daemon token (~/.swarm/token) as Authorization: Bearer" }, 401);
|
|
7909
|
+
});
|
|
7910
|
+
app.get("/v1/health", (c) => c.json({
|
|
7911
|
+
ok: true,
|
|
7912
|
+
version: VERSION,
|
|
7913
|
+
schema: store.schemaVersion(),
|
|
7914
|
+
auth: store.policyFor(null).config.daemon.auth
|
|
7915
|
+
}));
|
|
5653
7916
|
app.get("/v1/projects", (c) => c.json(store.snapshot().projects));
|
|
5654
7917
|
app.post("/v1/projects", async (c) => {
|
|
5655
7918
|
const { path, name } = await c.req.json();
|
|
@@ -5668,22 +7931,24 @@ function createApp(store = new Store) {
|
|
|
5668
7931
|
return c.json(store.reorderProjects(ids));
|
|
5669
7932
|
});
|
|
5670
7933
|
app.patch("/v1/projects/:id", async (c) => {
|
|
5671
|
-
const { pinned, name } = await c.req.json().catch(() => ({}));
|
|
5672
|
-
|
|
5673
|
-
|
|
7934
|
+
const { pinned, name, icon, color } = await c.req.json().catch(() => ({}));
|
|
7935
|
+
if (!store.project(c.req.param("id")))
|
|
7936
|
+
return c.json({ error: "not found" }, 404);
|
|
7937
|
+
const p = store.updateProject(c.req.param("id"), { pinned, name, icon, color });
|
|
7938
|
+
return p ? c.json(p) : c.json({ error: "icon is at most 4 characters; color is c1\u2026c7" }, 400);
|
|
5674
7939
|
});
|
|
5675
7940
|
app.delete("/v1/projects/:id", (c) => store.removeProject(c.req.param("id")) ? c.body(null, 204) : c.json({ error: "not found" }, 404));
|
|
5676
7941
|
app.get("/v1/fs/ls", (c) => {
|
|
5677
7942
|
const q = c.req.query("path");
|
|
5678
7943
|
let dir;
|
|
5679
7944
|
try {
|
|
5680
|
-
dir = realpathSync3(q &&
|
|
7945
|
+
dir = realpathSync3(q && existsSync6(q) ? q : homedir4());
|
|
5681
7946
|
} catch {
|
|
5682
7947
|
dir = homedir4();
|
|
5683
7948
|
}
|
|
5684
7949
|
try {
|
|
5685
|
-
const entries = readdirSync2(dir, { withFileTypes: true }).filter((e) => e.isDirectory() && !e.name.startsWith(".")).map((e) => ({ name: e.name, repo:
|
|
5686
|
-
const parent =
|
|
7950
|
+
const entries = readdirSync2(dir, { withFileTypes: true }).filter((e) => e.isDirectory() && !e.name.startsWith(".")).map((e) => ({ name: e.name, repo: existsSync6(join9(dir, e.name, ".git")) })).sort((a, b) => a.name.localeCompare(b.name));
|
|
7951
|
+
const parent = dirname3(dir);
|
|
5687
7952
|
return c.json({ path: dir, parent: parent === dir ? null : parent, entries });
|
|
5688
7953
|
} catch (e) {
|
|
5689
7954
|
return c.json({ error: e.message, path: dir }, 400);
|
|
@@ -5708,6 +7973,31 @@ function createApp(store = new Store) {
|
|
|
5708
7973
|
})
|
|
5709
7974
|
});
|
|
5710
7975
|
});
|
|
7976
|
+
app.get("/v1/policy", (c) => {
|
|
7977
|
+
const id = c.req.query("project");
|
|
7978
|
+
const p = id ? store.project(id) : null;
|
|
7979
|
+
if (id && !p)
|
|
7980
|
+
return c.json({ error: "unknown project" }, 404);
|
|
7981
|
+
const { provenance, overridden, policy: policy2 } = store.policyFor(p?.root ?? null);
|
|
7982
|
+
return c.json({ ...policy2, provenance, overridden });
|
|
7983
|
+
});
|
|
7984
|
+
app.get("/v1/audit", (c) => {
|
|
7985
|
+
const since = sinceToIso(c.req.query("since"));
|
|
7986
|
+
if (c.req.query("since") && !since)
|
|
7987
|
+
return c.json({ error: "since: use 30d / 12h / 90m or an ISO date" }, 400);
|
|
7988
|
+
const pid = c.req.query("project") || null;
|
|
7989
|
+
if (pid && !store.project(pid))
|
|
7990
|
+
return c.json({ error: "unknown project" }, 404);
|
|
7991
|
+
const rows = store.audit({
|
|
7992
|
+
since,
|
|
7993
|
+
projectId: pid,
|
|
7994
|
+
type: c.req.query("type") || null,
|
|
7995
|
+
limit: Number(c.req.query("limit")) || undefined
|
|
7996
|
+
});
|
|
7997
|
+
const format = c.req.query("format") === "csv" ? "csv" : c.req.query("format") === "jsonl" ? "jsonl" : "json";
|
|
7998
|
+
const ct = format === "csv" ? "text/csv; charset=utf-8" : format === "jsonl" ? "application/x-ndjson" : "application/json";
|
|
7999
|
+
return c.body(formatAudit(rows, format), 200, { "content-type": ct });
|
|
8000
|
+
});
|
|
5711
8001
|
app.get("/v1/rules/dryrun", (c) => {
|
|
5712
8002
|
const projectId = c.req.query("project");
|
|
5713
8003
|
if (!projectId)
|
|
@@ -5721,11 +8011,11 @@ function createApp(store = new Store) {
|
|
|
5721
8011
|
});
|
|
5722
8012
|
app.post("/v1/incidents/ack", async (c) => {
|
|
5723
8013
|
const body = await c.req.json().catch(() => ({}));
|
|
5724
|
-
return c.json({ ok: true, acked: store.ackAllIncidents(body.project || undefined) });
|
|
8014
|
+
return c.json({ ok: true, acked: store.ackAllIncidents(body.project || undefined, body.by) });
|
|
5725
8015
|
});
|
|
5726
8016
|
app.post("/v1/incidents/:seq/ack", (c) => {
|
|
5727
8017
|
const seq = Number(c.req.param("seq"));
|
|
5728
|
-
if (!Number.isInteger(seq) || !store.ackIncident(seq))
|
|
8018
|
+
if (!Number.isInteger(seq) || !store.ackIncident(seq, c.req.query("by")))
|
|
5729
8019
|
return c.json({ ok: false, error: "no such incident" }, 404);
|
|
5730
8020
|
return c.json({ ok: true });
|
|
5731
8021
|
});
|
|
@@ -5793,10 +8083,84 @@ function createApp(store = new Store) {
|
|
|
5793
8083
|
model: b.model,
|
|
5794
8084
|
permissionMode: b.permissionMode,
|
|
5795
8085
|
allowedTools: b.allowedTools,
|
|
5796
|
-
maxTurns: b.maxTurns
|
|
8086
|
+
maxTurns: b.maxTurns,
|
|
8087
|
+
profile: b.profile
|
|
5797
8088
|
});
|
|
5798
8089
|
return r.ok ? c.json(r, 201) : c.json({ ok: false, error: r.reason }, 409);
|
|
5799
8090
|
});
|
|
8091
|
+
app.get("/v1/budget", (c) => {
|
|
8092
|
+
const project = c.req.query("project");
|
|
8093
|
+
if (!project)
|
|
8094
|
+
return c.json({ error: "project required" }, 400);
|
|
8095
|
+
return c.json(store.budgetFor(project) ?? { status: null, config: store.config(project).budget });
|
|
8096
|
+
});
|
|
8097
|
+
app.get("/v1/context", (c) => {
|
|
8098
|
+
const cwd = c.req.query("cwd");
|
|
8099
|
+
if (!cwd)
|
|
8100
|
+
return c.json({ error: "cwd required" }, 400);
|
|
8101
|
+
return c.json(store.contextFor(cwd, c.req.query("session") || null));
|
|
8102
|
+
});
|
|
8103
|
+
app.get("/v1/questions", (c) => c.json(store.questions({
|
|
8104
|
+
projectId: c.req.query("project") || undefined,
|
|
8105
|
+
sessionId: c.req.query("session") || undefined,
|
|
8106
|
+
open: c.req.query("open") === "1"
|
|
8107
|
+
})));
|
|
8108
|
+
app.post("/v1/questions", async (c) => {
|
|
8109
|
+
const b = await c.req.json().catch(() => ({}));
|
|
8110
|
+
if (!b.projectId)
|
|
8111
|
+
return c.json({ ok: false, error: "projectId required" }, 400);
|
|
8112
|
+
const r = store.ask(b.projectId, {
|
|
8113
|
+
sessionId: b.sessionId ?? null,
|
|
8114
|
+
text: b.text,
|
|
8115
|
+
options: b.options,
|
|
8116
|
+
askedBy: b.askedBy ?? null,
|
|
8117
|
+
cwd: b.cwd ?? null
|
|
8118
|
+
});
|
|
8119
|
+
return c.json(r, r.ok ? 201 : 400);
|
|
8120
|
+
});
|
|
8121
|
+
app.post("/v1/questions/:id/answer", async (c) => {
|
|
8122
|
+
const b = await c.req.json().catch(() => ({}));
|
|
8123
|
+
const r = store.answer(Number(c.req.param("id")), b.text, b.by ?? null);
|
|
8124
|
+
if (r.ok && r.question.sessionId) {
|
|
8125
|
+
const run2 = runner.get(r.question.sessionId);
|
|
8126
|
+
if (run2 && run2.sessionId === r.question.sessionId) {
|
|
8127
|
+
const sent = runner.send(run2.id, `[swarm] answer from ${b.by ?? "a human"} to your question "${r.question.text.slice(0, 200)}": ${r.question.answer}`);
|
|
8128
|
+
if (sent.ok)
|
|
8129
|
+
store.inbox(r.question.sessionId);
|
|
8130
|
+
}
|
|
8131
|
+
}
|
|
8132
|
+
return c.json(r, r.ok ? 200 : 409);
|
|
8133
|
+
});
|
|
8134
|
+
app.get("/v1/inbox", (c) => c.json(store.inbox(c.req.query("session") || null, { peek: c.req.query("peek") === "1" })));
|
|
8135
|
+
app.get("/v1/dispatch", (c) => {
|
|
8136
|
+
const project = c.req.query("project");
|
|
8137
|
+
if (!project)
|
|
8138
|
+
return c.json({ error: "project required" }, 400);
|
|
8139
|
+
return c.json({ entries: dispatcher.status(project), config: store.config(project).dispatch });
|
|
8140
|
+
});
|
|
8141
|
+
app.post("/v1/dispatch", async (c) => {
|
|
8142
|
+
const b = await c.req.json().catch(() => ({}));
|
|
8143
|
+
if (!b.projectId)
|
|
8144
|
+
return c.json({ ok: false, error: "projectId required" }, 400);
|
|
8145
|
+
if (!b.ready && !b.tasks?.length)
|
|
8146
|
+
return c.json({ ok: false, error: "tasks or ready:true required" }, 400);
|
|
8147
|
+
const r = await dispatcher.dispatch(b.projectId, b.ready ? "ready" : b.tasks, {
|
|
8148
|
+
owner: b.owner ?? "dispatch",
|
|
8149
|
+
max: b.max,
|
|
8150
|
+
maxParallel: b.maxParallel,
|
|
8151
|
+
permissionMode: b.permissionMode,
|
|
8152
|
+
model: b.model,
|
|
8153
|
+
maxTurns: b.maxTurns,
|
|
8154
|
+
profile: b.profile
|
|
8155
|
+
});
|
|
8156
|
+
return c.json(r, r.ok ? 202 : 409);
|
|
8157
|
+
});
|
|
8158
|
+
app.delete("/v1/dispatch", async (c) => {
|
|
8159
|
+
const b = await c.req.json().catch(() => ({}));
|
|
8160
|
+
if (!b.projectId)
|
|
8161
|
+
return c.json({ ok: false, error: "projectId required" }, 400);
|
|
8162
|
+
return c.json({ ok: true, cleared: dispatcher.clear(b.projectId, b.task) });
|
|
8163
|
+
});
|
|
5800
8164
|
app.post("/v1/runs/:id/send", async (c) => {
|
|
5801
8165
|
const b = await c.req.json().catch(() => ({}));
|
|
5802
8166
|
if (!b.text?.trim())
|
|
@@ -5848,10 +8212,31 @@ function createApp(store = new Store) {
|
|
|
5848
8212
|
const required = store.requiredGates(project);
|
|
5849
8213
|
return c.json({
|
|
5850
8214
|
required,
|
|
8215
|
+
executable: Object.keys(store.gateDefs(project)?.defs ?? {}),
|
|
5851
8216
|
runs,
|
|
5852
8217
|
status: task ? store.gateStatusFor(runs, required) : undefined
|
|
5853
8218
|
});
|
|
5854
8219
|
});
|
|
8220
|
+
app.post("/v1/gates/run", async (c) => {
|
|
8221
|
+
const b = await c.req.json().catch(() => ({}));
|
|
8222
|
+
if (!b.projectId || !b.task)
|
|
8223
|
+
return c.json({ ok: false, error: "projectId and task required" }, 400);
|
|
8224
|
+
const opts = { sessionId: b.sessionId ?? null, owner: "cli" };
|
|
8225
|
+
if (b.wait === false) {
|
|
8226
|
+
const projectId = b.projectId;
|
|
8227
|
+
const cfg = store.gateDefs(projectId);
|
|
8228
|
+
const names = b.gates?.length ? b.gates : (cfg?.required ?? []).filter((g) => cfg?.defs[g]);
|
|
8229
|
+
store.runGates(projectId, b.task, names, opts);
|
|
8230
|
+
return c.json({ ok: true, started: names, runs: [] }, 202);
|
|
8231
|
+
}
|
|
8232
|
+
const r = await store.runGates(b.projectId, b.task, b.gates, opts);
|
|
8233
|
+
const ok = r.started.length > 0 && r.runs.every((x) => x.verdict === "pass");
|
|
8234
|
+
return c.json({
|
|
8235
|
+
ok,
|
|
8236
|
+
...r,
|
|
8237
|
+
error: r.started.length ? undefined : r.skipped[0]?.reason ?? "no executable gates"
|
|
8238
|
+
}, r.started.length ? 200 : 409);
|
|
8239
|
+
});
|
|
5855
8240
|
app.post("/v1/gates", async (c) => {
|
|
5856
8241
|
const b = await c.req.json().catch(() => ({}));
|
|
5857
8242
|
if (!b.projectId)
|
|
@@ -5878,7 +8263,7 @@ function createApp(store = new Store) {
|
|
|
5878
8263
|
const b = await c.req.json();
|
|
5879
8264
|
if (!b.projectId || !b.task)
|
|
5880
8265
|
return c.json({ error: "projectId and task required" }, 400);
|
|
5881
|
-
const r = store.claim(b.projectId, b.task, b.owner ?? "cli", b.baseRef);
|
|
8266
|
+
const r = store.claim(b.projectId, b.task, b.owner ?? "cli", b.baseRef, b.sessionId ?? null);
|
|
5882
8267
|
return c.json(r, r.ok ? 201 : 409);
|
|
5883
8268
|
});
|
|
5884
8269
|
app.post("/v1/claims/renew", async (c) => {
|
|
@@ -5891,6 +8276,81 @@ function createApp(store = new Store) {
|
|
|
5891
8276
|
const r = store.release(b.projectId ?? "", b.task ?? "", b.force ?? false);
|
|
5892
8277
|
return c.json(r, r.ok ? 200 : 409);
|
|
5893
8278
|
});
|
|
8279
|
+
app.get("/v1/worktrees", async (c) => {
|
|
8280
|
+
const project = c.req.query("project");
|
|
8281
|
+
if (!project)
|
|
8282
|
+
return c.json({ error: "project required" }, 400);
|
|
8283
|
+
return c.json(await store.refreshWorktrees(project));
|
|
8284
|
+
});
|
|
8285
|
+
app.post("/v1/worktrees", async (c) => {
|
|
8286
|
+
const b = await c.req.json();
|
|
8287
|
+
if (!b.projectId || !b.name)
|
|
8288
|
+
return c.json({ error: "projectId and name required" }, 400);
|
|
8289
|
+
const r = store.createWorktree(b.projectId, b.name, b.baseRef, b.branch);
|
|
8290
|
+
return c.json(r, r.ok ? 201 : 409);
|
|
8291
|
+
});
|
|
8292
|
+
app.post("/v1/worktrees/remove", async (c) => {
|
|
8293
|
+
const b = await c.req.json();
|
|
8294
|
+
if (!b.projectId || !b.worktree)
|
|
8295
|
+
return c.json({ error: "projectId and worktree required" }, 400);
|
|
8296
|
+
const r = await store.removeWorktree(b.projectId, b.worktree, b.force ?? false);
|
|
8297
|
+
return c.json(r, r.ok ? 200 : 409);
|
|
8298
|
+
});
|
|
8299
|
+
app.post("/v1/worktrees/open", async (c) => {
|
|
8300
|
+
const b = await c.req.json();
|
|
8301
|
+
if (!b.projectId || !b.worktree)
|
|
8302
|
+
return c.json({ error: "projectId and worktree required" }, 400);
|
|
8303
|
+
await store.refreshWorktrees(b.projectId);
|
|
8304
|
+
const r = store.openWorktree(b.projectId, b.worktree);
|
|
8305
|
+
return c.json(r, r.ok ? 200 : 404);
|
|
8306
|
+
});
|
|
8307
|
+
app.get("/v1/worktrees/diff", async (c) => {
|
|
8308
|
+
const project = c.req.query("project");
|
|
8309
|
+
const ref = c.req.query("worktree");
|
|
8310
|
+
if (!project || !ref)
|
|
8311
|
+
return c.json({ error: "project and worktree required" }, 400);
|
|
8312
|
+
await store.refreshWorktrees(project);
|
|
8313
|
+
const w = store.findWorktree(project, ref);
|
|
8314
|
+
const p = store.project(project);
|
|
8315
|
+
if (!w || !p)
|
|
8316
|
+
return c.json({ error: `no worktree ${ref}` }, 404);
|
|
8317
|
+
const file = c.req.query("file") || undefined;
|
|
8318
|
+
const d = await worktreeDiff(p.root, w.path);
|
|
8319
|
+
if (file || c.req.query("patch") === "1")
|
|
8320
|
+
return c.json({ ...d, worktree: w.path, patch: await worktreePatch(w.path, d.base, file) });
|
|
8321
|
+
return c.json({ ...d, worktree: w.path });
|
|
8322
|
+
});
|
|
8323
|
+
app.get("/v1/prs/draft", async (c) => {
|
|
8324
|
+
const project = c.req.query("project");
|
|
8325
|
+
const ref = c.req.query("worktree") || c.req.query("task");
|
|
8326
|
+
if (!project || !ref)
|
|
8327
|
+
return c.json({ error: "project and worktree|task required" }, 400);
|
|
8328
|
+
const r = await store.prDraftFor(project, ref);
|
|
8329
|
+
return c.json(r, r.ok ? 200 : 404);
|
|
8330
|
+
});
|
|
8331
|
+
app.post("/v1/prs/open", async (c) => {
|
|
8332
|
+
const b = await c.req.json().catch(() => ({}));
|
|
8333
|
+
const ref = b.worktree || b.task;
|
|
8334
|
+
if (!b.projectId || !ref)
|
|
8335
|
+
return c.json({ ok: false, error: "projectId and worktree|task required" }, 400);
|
|
8336
|
+
const d = await store.prDraftFor(b.projectId, ref);
|
|
8337
|
+
if (!d.ok)
|
|
8338
|
+
return c.json(d, 404);
|
|
8339
|
+
const r = await forge2.openPR(b.projectId, d.worktree, {
|
|
8340
|
+
title: b.title?.trim() || d.title,
|
|
8341
|
+
body: b.body ?? d.body,
|
|
8342
|
+
isDraft: b.draft ?? false
|
|
8343
|
+
});
|
|
8344
|
+
if (r.ok)
|
|
8345
|
+
store.recordPrOpened(b.projectId, d.task, d.worktree.path, r.url);
|
|
8346
|
+
return c.json(r, r.ok ? 201 : 409);
|
|
8347
|
+
});
|
|
8348
|
+
app.post("/v1/worktrees/gc", async (c) => {
|
|
8349
|
+
const b = await c.req.json().catch(() => ({}));
|
|
8350
|
+
if (!b.projectId)
|
|
8351
|
+
return c.json({ error: "projectId required" }, 400);
|
|
8352
|
+
return c.json(await store.gcWorktrees(b.projectId, b.apply ?? false));
|
|
8353
|
+
});
|
|
5894
8354
|
app.post("/v1/claims/reap", async (c) => {
|
|
5895
8355
|
const b = await c.req.json().catch(() => ({}));
|
|
5896
8356
|
return c.json({ reaped: store.reap(b.projectId) });
|
|
@@ -5950,6 +8410,7 @@ function createApp(store = new Store) {
|
|
|
5950
8410
|
const raw2 = await c.req.json().catch(() => ({}));
|
|
5951
8411
|
store.ingestHook(event, raw2);
|
|
5952
8412
|
if (event === "SessionStart" && typeof raw2.cwd === "string") {
|
|
8413
|
+
store.checkPolicy(raw2.cwd, typeof raw2.session_id === "string" ? raw2.session_id : null);
|
|
5953
8414
|
const ctx = store.sessionContext(raw2.cwd);
|
|
5954
8415
|
if (ctx)
|
|
5955
8416
|
return c.json({
|
|
@@ -5957,18 +8418,26 @@ function createApp(store = new Store) {
|
|
|
5957
8418
|
hookSpecificOutput: { hookEventName: "SessionStart", additionalContext: ctx }
|
|
5958
8419
|
});
|
|
5959
8420
|
}
|
|
5960
|
-
|
|
8421
|
+
const sid = typeof raw2.session_id === "string" ? raw2.session_id : null;
|
|
8422
|
+
const answers = event === "UserPromptSubmit" || event === "PreToolUse" || event === "PostToolUse" ? store.answerContext(sid) : null;
|
|
8423
|
+
if (event === "PreToolUse" && !store.guardDisabled(hookRepoRoot(store, raw2))) {
|
|
5961
8424
|
const guard = store.guardHook(raw2);
|
|
5962
8425
|
if (guard) {
|
|
5963
8426
|
return c.json({
|
|
5964
8427
|
hookSpecificOutput: {
|
|
5965
8428
|
hookEventName: "PreToolUse",
|
|
5966
8429
|
permissionDecision: guard.action,
|
|
5967
|
-
permissionDecisionReason: `[swarm] ${guard.reason}
|
|
8430
|
+
permissionDecisionReason: `[swarm] ${guard.reason}`,
|
|
8431
|
+
...answers ? { additionalContext: answers } : {}
|
|
5968
8432
|
}
|
|
5969
8433
|
});
|
|
5970
8434
|
}
|
|
5971
8435
|
}
|
|
8436
|
+
if (answers)
|
|
8437
|
+
return c.json({
|
|
8438
|
+
additionalContext: answers,
|
|
8439
|
+
hookSpecificOutput: { hookEventName: event, additionalContext: answers }
|
|
8440
|
+
});
|
|
5972
8441
|
return c.json({});
|
|
5973
8442
|
});
|
|
5974
8443
|
app.post("/v1/events", async (c) => {
|
|
@@ -5997,18 +8466,18 @@ function createApp(store = new Store) {
|
|
|
5997
8466
|
});
|
|
5998
8467
|
});
|
|
5999
8468
|
});
|
|
6000
|
-
app.get("/", (c) => c.html(readFileSync4(
|
|
8469
|
+
app.get("/", (c) => c.html(readFileSync4(join9(WEB_DIR, "index.html"), "utf8")));
|
|
6001
8470
|
const MIME = { js: "text/javascript", css: "text/css" };
|
|
6002
8471
|
app.get("/:file{[a-z0-9-]+\\.(js|css)}", (c) => {
|
|
6003
8472
|
const f = c.req.param("file");
|
|
6004
|
-
const p =
|
|
6005
|
-
if (!
|
|
8473
|
+
const p = join9(WEB_DIR, f);
|
|
8474
|
+
if (!existsSync6(p))
|
|
6006
8475
|
return c.text(`${f} not built \u2014 run: bun run build:web`, 404);
|
|
6007
8476
|
return c.body(readFileSync4(p, "utf8"), 200, {
|
|
6008
8477
|
"content-type": MIME[f.split(".").pop() ?? ""] ?? "text/plain"
|
|
6009
8478
|
});
|
|
6010
8479
|
});
|
|
6011
|
-
return { app, store, forge: forge2, runner };
|
|
8480
|
+
return { app, store, forge: forge2, runner, dispatcher };
|
|
6012
8481
|
}
|
|
6013
8482
|
|
|
6014
8483
|
// packages/daemon/src/bin.ts
|
|
@@ -6027,6 +8496,7 @@ function serve() {
|
|
|
6027
8496
|
}
|
|
6028
8497
|
var server = serve();
|
|
6029
8498
|
var port = server.port ?? DEFAULT_PORT2;
|
|
8499
|
+
ensureToken();
|
|
6030
8500
|
writeDaemonInfo({ port, pid: process.pid, version: VERSION, startedAt: new Date().toISOString() });
|
|
6031
8501
|
var backfillDays = Number(process.env.SWARM_CODEX_BACKFILL_DAYS ?? 30);
|
|
6032
8502
|
var backfillMs = backfillDays * 24 * 60 * 60000;
|
|
@@ -6044,6 +8514,8 @@ var tailer = setInterval(() => {
|
|
|
6044
8514
|
store.reapProcesses();
|
|
6045
8515
|
if (tick % 12 === 0)
|
|
6046
8516
|
store.sweepOrphans();
|
|
8517
|
+
if (tick % 6 === 0)
|
|
8518
|
+
store.checkBudgets();
|
|
6047
8519
|
}, 5000);
|
|
6048
8520
|
store.refreshAllWorktrees();
|
|
6049
8521
|
var wtRefresh = setInterval(() => void store.refreshAllWorktrees(), 15000);
|