@ra3orblade/swarm 0.6.0 → 0.7.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/dist/swarm-mcp.js +122 -1
- package/dist/swarm.js +458 -7
- package/dist/swarmd.js +1888 -304
- package/package.json +1 -1
- package/web/app.js +266 -23
- package/web/index.html +10 -0
- package/web/release-notes.js +1 -1
- package/web/table.js +1 -1
package/dist/swarmd.js
CHANGED
|
@@ -322,13 +322,96 @@ function normalizeHook(event, raw, projectId, ts = new Date().toISOString()) {
|
|
|
322
322
|
payload.prompt = raw.prompt;
|
|
323
323
|
return { ts, type, projectId, sessionId: raw.session_id ?? null, payload, raw };
|
|
324
324
|
}
|
|
325
|
+
// packages/core/src/budget.ts
|
|
326
|
+
function budgetStatus(spent, cfg) {
|
|
327
|
+
const part = (s, l) => ({
|
|
328
|
+
spent: s,
|
|
329
|
+
limit: l,
|
|
330
|
+
pct: l && l > 0 ? s / l : 0
|
|
331
|
+
});
|
|
332
|
+
const daily = part(spent.today, cfg.daily);
|
|
333
|
+
const weekly = part(spent.week, cfg.weekly);
|
|
334
|
+
const candidates = [
|
|
335
|
+
["daily", daily],
|
|
336
|
+
["weekly", weekly]
|
|
337
|
+
];
|
|
338
|
+
let kind = null;
|
|
339
|
+
let top = { spent: 0, limit: null, pct: 0 };
|
|
340
|
+
for (const [k, v] of candidates)
|
|
341
|
+
if (v.limit && v.pct >= top.pct)
|
|
342
|
+
({ kind, top } = { kind: k, top: v });
|
|
343
|
+
const level = !kind ? "ok" : top.pct >= 1 ? "exceeded" : top.pct >= cfg.warn_at ? "warn" : "ok";
|
|
344
|
+
return { level, kind, spent: top.spent, limit: top.limit, pct: top.pct, daily, weekly };
|
|
345
|
+
}
|
|
346
|
+
var BUDGET_ASK_TOOLS = new Set(["Bash", "Edit", "Write", "MultiEdit", "NotebookEdit"]);
|
|
347
|
+
function budgetMessage(s, project) {
|
|
348
|
+
const usd = (n) => `$${n.toFixed(2)}`;
|
|
349
|
+
if (s.level === "ok" || !s.limit)
|
|
350
|
+
return `${project}: within budget`;
|
|
351
|
+
return `${project} has spent ${usd(s.spent)} of its ${usd(s.limit)} ${s.kind} budget (${Math.round(s.pct * 100)}%)`;
|
|
352
|
+
}
|
|
353
|
+
var RUN_PROFILES = {
|
|
354
|
+
full: {
|
|
355
|
+
name: "full",
|
|
356
|
+
description: "every tool, rules still apply",
|
|
357
|
+
disallowedTools: [],
|
|
358
|
+
allowedTools: []
|
|
359
|
+
},
|
|
360
|
+
"no-edits": {
|
|
361
|
+
name: "no-edits",
|
|
362
|
+
description: "may run commands, may not edit files (review, triage, test runs)",
|
|
363
|
+
disallowedTools: ["Edit", "Write", "MultiEdit", "NotebookEdit"],
|
|
364
|
+
allowedTools: []
|
|
365
|
+
},
|
|
366
|
+
"read-only": {
|
|
367
|
+
name: "read-only",
|
|
368
|
+
description: "read and search only \u2014 no edits, no shell",
|
|
369
|
+
disallowedTools: ["Edit", "Write", "MultiEdit", "NotebookEdit", "Bash"],
|
|
370
|
+
allowedTools: ["Read", "Grep", "Glob", "LS", "WebFetch", "WebSearch"]
|
|
371
|
+
}
|
|
372
|
+
};
|
|
373
|
+
function runProfile(name) {
|
|
374
|
+
if (!name)
|
|
375
|
+
return null;
|
|
376
|
+
return RUN_PROFILES[name] ?? null;
|
|
377
|
+
}
|
|
325
378
|
// packages/core/src/config.ts
|
|
326
379
|
import { existsSync as existsSync2, readFileSync as readFileSync2 } from "fs";
|
|
327
380
|
import { join as join2 } from "path";
|
|
381
|
+
var DEFAULT_GATE_TIMEOUT_S = 900;
|
|
382
|
+
var AUTO_MODES = ["session-end", "stop", "off"];
|
|
383
|
+
function parseGateDefs(gates) {
|
|
384
|
+
const out = {};
|
|
385
|
+
if (!isRecord(gates))
|
|
386
|
+
return out;
|
|
387
|
+
for (const [name, v] of Object.entries(gates)) {
|
|
388
|
+
if (!isRecord(v) || typeof v.cmd !== "string" || !v.cmd.trim())
|
|
389
|
+
continue;
|
|
390
|
+
if (!/^[a-z0-9][a-z0-9_.-]{0,39}$/i.test(name))
|
|
391
|
+
continue;
|
|
392
|
+
const t = Number(v.timeout);
|
|
393
|
+
out[name] = {
|
|
394
|
+
cmd: v.cmd.trim(),
|
|
395
|
+
timeout: Number.isFinite(t) && t > 0 ? Math.min(t, 86400) : DEFAULT_GATE_TIMEOUT_S,
|
|
396
|
+
cwd: isRepoRelative(v.cwd) ? v.cwd.trim() : null
|
|
397
|
+
};
|
|
398
|
+
}
|
|
399
|
+
return out;
|
|
400
|
+
}
|
|
328
401
|
var DEFAULT_CONFIG = {
|
|
329
402
|
daemon: { port: 7777 },
|
|
330
403
|
tasks: { source: null, labels: [], team: null },
|
|
331
|
-
gates: { required: [] },
|
|
404
|
+
gates: { required: [], auto: "session-end", defs: {} },
|
|
405
|
+
budget: { daily: null, weekly: null, warn_at: 0.8, on_exceed: "warn" },
|
|
406
|
+
dispatch: {
|
|
407
|
+
max_parallel: 2,
|
|
408
|
+
permission_mode: null,
|
|
409
|
+
model: null,
|
|
410
|
+
max_turns: null,
|
|
411
|
+
require_pr: true,
|
|
412
|
+
profile: null
|
|
413
|
+
},
|
|
414
|
+
worktree: { setup: null, copy: [], open: null },
|
|
332
415
|
rules: {
|
|
333
416
|
shared_tree: "ask",
|
|
334
417
|
destructive_git: "ask",
|
|
@@ -359,10 +442,32 @@ function parseToml(text, source) {
|
|
|
359
442
|
return {};
|
|
360
443
|
}
|
|
361
444
|
}
|
|
445
|
+
function isRepoRelative(f) {
|
|
446
|
+
if (typeof f !== "string")
|
|
447
|
+
return false;
|
|
448
|
+
const t = f.trim();
|
|
449
|
+
if (!t || t.startsWith("/") || t.startsWith("\\") || /^[a-zA-Z]:/.test(t))
|
|
450
|
+
return false;
|
|
451
|
+
return !t.split(/[/\\]/).some((seg) => seg === "..");
|
|
452
|
+
}
|
|
362
453
|
function validate(c) {
|
|
363
454
|
const mode = (v, fallback) => MODES.includes(v) ? v : fallback;
|
|
364
455
|
const port = Number(c.daemon?.port);
|
|
365
456
|
const source = c.tasks?.source;
|
|
457
|
+
const setup = c.worktree?.setup;
|
|
458
|
+
const opener = c.worktree?.open;
|
|
459
|
+
const rawGates = c.gates;
|
|
460
|
+
const d = c.dispatch ?? {};
|
|
461
|
+
const mp = Number(d.max_parallel);
|
|
462
|
+
const mt = Number(d.max_turns);
|
|
463
|
+
const str = (v) => typeof v === "string" && v.trim() ? v.trim() : null;
|
|
464
|
+
const b = c.budget ?? {};
|
|
465
|
+
const usd = (v) => {
|
|
466
|
+
const n = Number(v);
|
|
467
|
+
return Number.isFinite(n) && n > 0 ? n : null;
|
|
468
|
+
};
|
|
469
|
+
const warnAt = Number(b.warn_at);
|
|
470
|
+
const auto = rawGates?.auto;
|
|
366
471
|
return {
|
|
367
472
|
...c,
|
|
368
473
|
daemon: { port: Number.isInteger(port) && port > 0 && port < 65536 ? port : 7777 },
|
|
@@ -371,6 +476,30 @@ function validate(c) {
|
|
|
371
476
|
labels: Array.isArray(c.tasks?.labels) ? c.tasks.labels.filter((l) => typeof l === "string" && l.trim() !== "") : [],
|
|
372
477
|
team: typeof c.tasks?.team === "string" && c.tasks.team.trim() ? c.tasks.team.trim() : null
|
|
373
478
|
},
|
|
479
|
+
gates: {
|
|
480
|
+
required: Array.isArray(rawGates?.required) ? rawGates.required.filter((g) => typeof g === "string" && g.trim() !== "") : [],
|
|
481
|
+
auto: AUTO_MODES.includes(auto) ? auto : "session-end",
|
|
482
|
+
defs: parseGateDefs(rawGates)
|
|
483
|
+
},
|
|
484
|
+
budget: {
|
|
485
|
+
daily: usd(b.daily),
|
|
486
|
+
weekly: usd(b.weekly),
|
|
487
|
+
warn_at: Number.isFinite(warnAt) && warnAt > 0 && warnAt < 1 ? warnAt : 0.8,
|
|
488
|
+
on_exceed: b.on_exceed === "ask" || b.on_exceed === "stop" ? b.on_exceed : "warn"
|
|
489
|
+
},
|
|
490
|
+
dispatch: {
|
|
491
|
+
max_parallel: Number.isInteger(mp) && mp > 0 ? Math.min(mp, 16) : 2,
|
|
492
|
+
permission_mode: str(d.permission_mode),
|
|
493
|
+
model: str(d.model),
|
|
494
|
+
max_turns: Number.isInteger(mt) && mt > 0 ? mt : null,
|
|
495
|
+
require_pr: d.require_pr === undefined ? true : d.require_pr === true,
|
|
496
|
+
profile: ["full", "no-edits", "read-only"].includes(String(d.profile)) ? String(d.profile) : null
|
|
497
|
+
},
|
|
498
|
+
worktree: {
|
|
499
|
+
setup: typeof setup === "string" && setup.trim() ? setup.trim() : null,
|
|
500
|
+
copy: Array.isArray(c.worktree?.copy) ? c.worktree.copy.filter((f) => isRepoRelative(f)) : [],
|
|
501
|
+
open: typeof opener === "string" && opener.trim() ? opener.trim() : null
|
|
502
|
+
},
|
|
374
503
|
rules: {
|
|
375
504
|
...c.rules,
|
|
376
505
|
shared_tree: mode(c.rules?.shared_tree, "ask"),
|
|
@@ -398,6 +527,71 @@ function loadConfig(opts = {}) {
|
|
|
398
527
|
}
|
|
399
528
|
return validate(cfg);
|
|
400
529
|
}
|
|
530
|
+
// packages/core/src/dispatch.ts
|
|
531
|
+
function planDispatch(tasks, wanted, opts) {
|
|
532
|
+
const byId = new Map(tasks.map((t) => [t.id, t]));
|
|
533
|
+
const queued = new Set(opts.alreadyQueued ?? []);
|
|
534
|
+
const rejected = [];
|
|
535
|
+
const picked = [];
|
|
536
|
+
const ids = wanted === "ready" ? tasks.filter((t) => t.ready).map((t) => t.id) : wanted;
|
|
537
|
+
for (const id of ids) {
|
|
538
|
+
const t = byId.get(id);
|
|
539
|
+
if (!t)
|
|
540
|
+
rejected.push({ id, reason: "not in the task source" });
|
|
541
|
+
else if (queued.has(id))
|
|
542
|
+
rejected.push({ id, reason: "already queued" });
|
|
543
|
+
else if (t.claimedBy)
|
|
544
|
+
rejected.push({ id, reason: `held by ${t.claimedBy}` });
|
|
545
|
+
else if (t.status === "done")
|
|
546
|
+
rejected.push({ id, reason: "already done" });
|
|
547
|
+
else if (!t.ready)
|
|
548
|
+
rejected.push({
|
|
549
|
+
id,
|
|
550
|
+
reason: t.status === "active" ? "in progress" : "blocked by dependencies"
|
|
551
|
+
});
|
|
552
|
+
else if (picked.some((p) => p.id === id))
|
|
553
|
+
rejected.push({ id, reason: "listed twice" });
|
|
554
|
+
else
|
|
555
|
+
picked.push(t);
|
|
556
|
+
}
|
|
557
|
+
const limit = opts.max && opts.max > 0 ? picked.slice(0, opts.max) : picked;
|
|
558
|
+
for (const t of picked.slice(limit.length))
|
|
559
|
+
rejected.push({ id: t.id, reason: `beyond --max ${opts.max}` });
|
|
560
|
+
const slots = Math.max(0, opts.maxParallel - opts.running);
|
|
561
|
+
return { start: limit.slice(0, slots), queued: limit.slice(slots), rejected };
|
|
562
|
+
}
|
|
563
|
+
function taskPrompt(task, ctx = {
|
|
564
|
+
requiredGates: [],
|
|
565
|
+
executableGates: [],
|
|
566
|
+
openPr: true
|
|
567
|
+
}) {
|
|
568
|
+
const manual = ctx.requiredGates.filter((g) => !ctx.executableGates.includes(g));
|
|
569
|
+
const exec = ctx.requiredGates.filter((g) => ctx.executableGates.includes(g));
|
|
570
|
+
const steps = [
|
|
571
|
+
"Work only inside this worktree; never touch the main checkout or another worktree.",
|
|
572
|
+
"Commit as you go with clear messages. Do not edit the task list or flip the task's status \u2014 Swarm derives it.",
|
|
573
|
+
exec.length ? `Run the executable gates with swarm_gate_run (${exec.join(", ")}) and fix what fails.` : null,
|
|
574
|
+
manual.length ? `Record the remaining required gates with swarm_gate_record and an honest rubric (${manual.join(", ")}).` : null,
|
|
575
|
+
"Call swarm_handoff with what was done, what remains, the files touched and how to verify.",
|
|
576
|
+
ctx.openPr ? "Then push and open the pull request with swarm_pr_open." : "Push the branch.",
|
|
577
|
+
"If you are blocked on a decision only a human can make, say so in the handoff and stop."
|
|
578
|
+
].filter(Boolean);
|
|
579
|
+
return `Task ${task.id}: ${task.title}
|
|
580
|
+
|
|
581
|
+
${steps.map((s, i) => `${i + 1}. ${s}`).join(`
|
|
582
|
+
`)}`;
|
|
583
|
+
}
|
|
584
|
+
function dispatchOutcome(facts) {
|
|
585
|
+
if (facts.stopped)
|
|
586
|
+
return "stopped";
|
|
587
|
+
if (facts.exitCode !== 0 || facts.isError)
|
|
588
|
+
return "crashed";
|
|
589
|
+
if (!facts.gatesSatisfied)
|
|
590
|
+
return "gates-failed";
|
|
591
|
+
if (facts.requirePr && !facts.prOpen)
|
|
592
|
+
return "no-pr";
|
|
593
|
+
return "done";
|
|
594
|
+
}
|
|
401
595
|
// packages/core/src/rules.ts
|
|
402
596
|
var LIVE_WINDOW_MS = 10 * 60000;
|
|
403
597
|
function otherLiveInSameTree(current, sessions, now, withinMs = LIVE_WINDOW_MS) {
|
|
@@ -706,6 +900,68 @@ function normalizeGitlab(raw, repo) {
|
|
|
706
900
|
};
|
|
707
901
|
});
|
|
708
902
|
}
|
|
903
|
+
function parseNumstat(numstat, nameStatus = "") {
|
|
904
|
+
const status = new Map;
|
|
905
|
+
for (const line of nameStatus.split(`
|
|
906
|
+
`)) {
|
|
907
|
+
const [st, ...rest] = line.split("\t");
|
|
908
|
+
if (!st || !rest.length)
|
|
909
|
+
continue;
|
|
910
|
+
status.set(rest[rest.length - 1], st[0]);
|
|
911
|
+
}
|
|
912
|
+
const out = [];
|
|
913
|
+
for (const line of numstat.split(`
|
|
914
|
+
`)) {
|
|
915
|
+
const [a, d, ...rest] = line.split("\t");
|
|
916
|
+
if (a === undefined || d === undefined || !rest.length)
|
|
917
|
+
continue;
|
|
918
|
+
const raw = rest.join("\t");
|
|
919
|
+
const path = raw.includes(" => ") ? raw.replace(/\{?([^{}]*) => ([^{}]*)\}?/, "$2") : raw;
|
|
920
|
+
out.push({
|
|
921
|
+
path,
|
|
922
|
+
added: a === "-" ? -1 : Number(a),
|
|
923
|
+
deleted: d === "-" ? -1 : Number(d),
|
|
924
|
+
status: status.get(path) ?? "M"
|
|
925
|
+
});
|
|
926
|
+
}
|
|
927
|
+
return out;
|
|
928
|
+
}
|
|
929
|
+
function prDraft(i) {
|
|
930
|
+
const title = ((i.title?.trim()) ? `${i.task}: ${i.title.trim()}` : i.task).slice(0, 120);
|
|
931
|
+
const b = [];
|
|
932
|
+
b.push("## Summary");
|
|
933
|
+
if (i.handoff?.done.trim())
|
|
934
|
+
b.push(i.handoff.done.trim());
|
|
935
|
+
else if (i.commits?.length)
|
|
936
|
+
b.push(i.commits.map((c) => `- ${c}`).join(`
|
|
937
|
+
`));
|
|
938
|
+
else
|
|
939
|
+
b.push(`Work on ${i.task}.`);
|
|
940
|
+
if (i.handoff?.remaining.trim() && !/^(nothing|none|\u2014|-)\.?$/i.test(i.handoff.remaining.trim()))
|
|
941
|
+
b.push(`
|
|
942
|
+
## Remaining
|
|
943
|
+
${i.handoff.remaining.trim()}`);
|
|
944
|
+
if (i.gates?.length) {
|
|
945
|
+
b.push(`
|
|
946
|
+
## Gates`);
|
|
947
|
+
b.push(i.gates.map((g) => `- ${g.verdict === "pass" ? "[x]" : "[ ]"} ${g.gate}${g.verdict === "fail" ? " \u2014 failed" : g.verdict ? "" : " \u2014 not run"}`).join(`
|
|
948
|
+
`));
|
|
949
|
+
}
|
|
950
|
+
if (i.handoff?.verify?.trim())
|
|
951
|
+
b.push(`
|
|
952
|
+
## Verify
|
|
953
|
+
${i.handoff.verify.trim()}`);
|
|
954
|
+
if (i.files?.length) {
|
|
955
|
+
const shown = i.files.slice(0, 30);
|
|
956
|
+
b.push(`
|
|
957
|
+
## Files (${i.files.length})
|
|
958
|
+
${shown.map((f) => `- \`${f.path}\`${f.added >= 0 ? ` +${f.added} \u2212${f.deleted}` : " (binary)"}`).join(`
|
|
959
|
+
`)}${i.files.length > shown.length ? `
|
|
960
|
+
- \u2026 ${i.files.length - shown.length} more` : ""}`);
|
|
961
|
+
}
|
|
962
|
+
return { title, body: b.join(`
|
|
963
|
+
`) };
|
|
964
|
+
}
|
|
709
965
|
// packages/core/src/gates.ts
|
|
710
966
|
var NAME_RE = /^[a-z0-9][a-z0-9_.-]{0,39}$/i;
|
|
711
967
|
function validateGateRun(input) {
|
|
@@ -749,6 +1005,25 @@ function gatesSatisfied(runs, declared) {
|
|
|
749
1005
|
const st = gateStatus(runs, declared);
|
|
750
1006
|
return declared.every((g) => st.find((s) => s.gate === g)?.verdict === "pass");
|
|
751
1007
|
}
|
|
1008
|
+
function evidenceTail(output, max = 2000) {
|
|
1009
|
+
const t = output.trimEnd();
|
|
1010
|
+
if (t.length <= max)
|
|
1011
|
+
return t;
|
|
1012
|
+
const cut = t.slice(-max);
|
|
1013
|
+
const nl = cut.indexOf(`
|
|
1014
|
+
`);
|
|
1015
|
+
return `\u2026${nl >= 0 && nl < 200 ? cut.slice(nl + 1) : cut}`;
|
|
1016
|
+
}
|
|
1017
|
+
function executedGateInput(task, gate, cmd, outcome) {
|
|
1018
|
+
const how = outcome.timedOut === true ? "timed out" : outcome.exitCode === null ? "could not start" : `exit ${outcome.exitCode}`;
|
|
1019
|
+
return {
|
|
1020
|
+
task,
|
|
1021
|
+
gate,
|
|
1022
|
+
verdict: outcome.exitCode === 0 && !outcome.timedOut ? "pass" : "fail",
|
|
1023
|
+
rubric: `ran \`${cmd}\` \u2014 ${how} in ${(outcome.durationMs / 1000).toFixed(1)}s`,
|
|
1024
|
+
evidence: evidenceTail(outcome.output) || null
|
|
1025
|
+
};
|
|
1026
|
+
}
|
|
752
1027
|
// packages/core/src/ledger.ts
|
|
753
1028
|
var DEFAULT_LEASE_MINUTES = 45;
|
|
754
1029
|
function isExpired(claim, now) {
|
|
@@ -1151,6 +1426,29 @@ function projectIdentity(opts) {
|
|
|
1151
1426
|
const name = parts[parts.length - 1] ?? opts.root;
|
|
1152
1427
|
return { id: `p_${fnv1a(key)}`, root: opts.root, commonDir: opts.commonDir, name };
|
|
1153
1428
|
}
|
|
1429
|
+
// packages/core/src/questions.ts
|
|
1430
|
+
function validateQuestion(text, options) {
|
|
1431
|
+
const t = typeof text === "string" ? text.trim() : "";
|
|
1432
|
+
if (t.length < 5)
|
|
1433
|
+
return { ok: false, reason: "a question needs at least a few words" };
|
|
1434
|
+
if (t.length > 4000)
|
|
1435
|
+
return { ok: false, reason: "keep the question under 4000 characters" };
|
|
1436
|
+
const opts = Array.isArray(options) ? options.filter((o) => typeof o === "string" && o.trim() !== "").map((o) => o.trim()).slice(0, 8) : [];
|
|
1437
|
+
return { ok: true, text: t, options: opts };
|
|
1438
|
+
}
|
|
1439
|
+
function formatAnswers(qs) {
|
|
1440
|
+
const answered = qs.filter((q) => q.answer !== null);
|
|
1441
|
+
if (!answered.length)
|
|
1442
|
+
return null;
|
|
1443
|
+
return answered.map((q) => `[swarm] answer from ${q.answeredBy ?? "a human"} to your question "${q.text.slice(0, 200)}": ${q.answer}`).join(`
|
|
1444
|
+
`);
|
|
1445
|
+
}
|
|
1446
|
+
function formatOpenQuestions(qs) {
|
|
1447
|
+
const open = qs.filter((q) => q.answer === null);
|
|
1448
|
+
if (!open.length)
|
|
1449
|
+
return null;
|
|
1450
|
+
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`;
|
|
1451
|
+
}
|
|
1154
1452
|
// packages/core/src/resources.ts
|
|
1155
1453
|
var DEFAULT_RESOURCE_LEASE_MINUTES = 60;
|
|
1156
1454
|
function isTrackedPid(pid) {
|
|
@@ -1335,18 +1633,91 @@ function linearIssuesQuery(teamKey, first = 200) {
|
|
|
1335
1633
|
inverseRelations { nodes { type issue { identifier } } }
|
|
1336
1634
|
} } }`;
|
|
1337
1635
|
}
|
|
1636
|
+
// packages/core/src/worktree.ts
|
|
1637
|
+
import { join as join3 } from "path";
|
|
1638
|
+
function planBootstrap(cfg, repoRoot, worktree) {
|
|
1639
|
+
const seen = new Set;
|
|
1640
|
+
const copies = [];
|
|
1641
|
+
for (const raw of cfg.worktree.copy) {
|
|
1642
|
+
if (!isRepoRelative(raw))
|
|
1643
|
+
continue;
|
|
1644
|
+
const rel = raw.trim().replace(/^\.\//, "");
|
|
1645
|
+
if (seen.has(rel))
|
|
1646
|
+
continue;
|
|
1647
|
+
seen.add(rel);
|
|
1648
|
+
copies.push({ rel, from: join3(repoRoot, rel), to: join3(worktree, rel) });
|
|
1649
|
+
}
|
|
1650
|
+
return { copies, setup: cfg.worktree.setup };
|
|
1651
|
+
}
|
|
1652
|
+
var needsBootstrap = (plan) => plan.copies.length > 0 || plan.setup !== null;
|
|
1653
|
+
function summarizeBootstrap(o) {
|
|
1654
|
+
const parts = [];
|
|
1655
|
+
if (o.copied.length)
|
|
1656
|
+
parts.push(`copied ${o.copied.join(", ")}`);
|
|
1657
|
+
if (o.skipped.length)
|
|
1658
|
+
parts.push(`skipped ${o.skipped.join(", ")} (missing)`);
|
|
1659
|
+
if (o.setup)
|
|
1660
|
+
parts.push(`${o.setup.command} \u2192 ${o.setup.exitCode === 0 ? "ok" : `exit ${o.setup.exitCode}`} in ${(o.setup.durationMs / 1000).toFixed(1)}s`);
|
|
1661
|
+
return parts.join("; ") || "nothing to do";
|
|
1662
|
+
}
|
|
1663
|
+
function canRemoveWorktree(w, heldByClaim, force) {
|
|
1664
|
+
if (w.main)
|
|
1665
|
+
return { ok: false, reason: "main" };
|
|
1666
|
+
if (heldByClaim)
|
|
1667
|
+
return { ok: false, reason: "held" };
|
|
1668
|
+
if (force)
|
|
1669
|
+
return { ok: true };
|
|
1670
|
+
if (w.dirty > 0)
|
|
1671
|
+
return { ok: false, reason: "dirty" };
|
|
1672
|
+
if (w.ahead > 0)
|
|
1673
|
+
return { ok: false, reason: "unpushed" };
|
|
1674
|
+
return { ok: true };
|
|
1675
|
+
}
|
|
1676
|
+
function removeRefusalMessage(reason, path, task) {
|
|
1677
|
+
switch (reason) {
|
|
1678
|
+
case "main":
|
|
1679
|
+
return `${path} is the main checkout \u2014 it is never removed`;
|
|
1680
|
+
case "held":
|
|
1681
|
+
return `${path} is held by claim ${task ?? "?"} \u2014 release the claim instead`;
|
|
1682
|
+
case "dirty":
|
|
1683
|
+
return `${path} has uncommitted changes \u2014 commit or stash them, or --force to discard`;
|
|
1684
|
+
case "unpushed":
|
|
1685
|
+
return `${path} has unpushed commits \u2014 push them, or --force to discard`;
|
|
1686
|
+
}
|
|
1687
|
+
}
|
|
1688
|
+
function planGc(worktrees, claims) {
|
|
1689
|
+
const held = new Map(claims.filter((c) => c.state === "held").map((c) => [c.worktree, c.task]));
|
|
1690
|
+
const stale = new Set(claims.filter((c) => c.state !== "held").map((c) => c.worktree));
|
|
1691
|
+
const out = [];
|
|
1692
|
+
for (const w of worktrees) {
|
|
1693
|
+
if (w.main || held.has(w.path))
|
|
1694
|
+
continue;
|
|
1695
|
+
const why = w.merged ? "merged" : stale.has(w.path) ? "released-claim" : null;
|
|
1696
|
+
if (!why)
|
|
1697
|
+
continue;
|
|
1698
|
+
const can = canRemoveWorktree(w, null, false);
|
|
1699
|
+
out.push({
|
|
1700
|
+
path: w.path,
|
|
1701
|
+
branch: w.branch,
|
|
1702
|
+
why,
|
|
1703
|
+
removable: can.ok,
|
|
1704
|
+
blocker: can.ok ? null : can.reason
|
|
1705
|
+
});
|
|
1706
|
+
}
|
|
1707
|
+
return out;
|
|
1708
|
+
}
|
|
1338
1709
|
// packages/daemon/src/app.ts
|
|
1339
|
-
import { existsSync as
|
|
1710
|
+
import { existsSync as existsSync6, readdirSync as readdirSync2, readFileSync as readFileSync4, realpathSync as realpathSync3 } from "fs";
|
|
1340
1711
|
import { homedir as homedir4 } from "os";
|
|
1341
|
-
import { dirname as
|
|
1712
|
+
import { dirname as dirname3, join as join9 } from "path";
|
|
1342
1713
|
import { fileURLToPath } from "url";
|
|
1343
1714
|
|
|
1344
1715
|
// node_modules/.bun/hono@4.13.3/node_modules/hono/dist/compose.js
|
|
1345
1716
|
var compose = (middleware, onError, onNotFound) => {
|
|
1346
1717
|
return (context, next) => {
|
|
1347
1718
|
let index = -1;
|
|
1348
|
-
return
|
|
1349
|
-
async function
|
|
1719
|
+
return dispatch2(0);
|
|
1720
|
+
async function dispatch2(i) {
|
|
1350
1721
|
if (i <= index) {
|
|
1351
1722
|
throw new Error("next() called multiple times");
|
|
1352
1723
|
}
|
|
@@ -1362,7 +1733,7 @@ var compose = (middleware, onError, onNotFound) => {
|
|
|
1362
1733
|
}
|
|
1363
1734
|
if (handler) {
|
|
1364
1735
|
try {
|
|
1365
|
-
res = await handler(context, () =>
|
|
1736
|
+
res = await handler(context, () => dispatch2(i + 1));
|
|
1366
1737
|
} catch (err) {
|
|
1367
1738
|
if (err instanceof Error && onError) {
|
|
1368
1739
|
context.error = err;
|
|
@@ -2958,16 +3329,269 @@ var streamSSE = (c, cb, onError) => {
|
|
|
2958
3329
|
return c.newResponse(stream.responseReadable);
|
|
2959
3330
|
};
|
|
2960
3331
|
|
|
3332
|
+
// packages/daemon/src/dispatcher.ts
|
|
3333
|
+
class Dispatcher {
|
|
3334
|
+
store;
|
|
3335
|
+
runner;
|
|
3336
|
+
forge;
|
|
3337
|
+
entries = new Map;
|
|
3338
|
+
opts = new Map;
|
|
3339
|
+
constructor(store, runner, forge2) {
|
|
3340
|
+
this.store = store;
|
|
3341
|
+
this.runner = runner;
|
|
3342
|
+
this.forge = forge2;
|
|
3343
|
+
runner.onEnd((run2) => void this.onRunEnd(run2));
|
|
3344
|
+
}
|
|
3345
|
+
project(projectId) {
|
|
3346
|
+
let m = this.entries.get(projectId);
|
|
3347
|
+
if (!m) {
|
|
3348
|
+
m = new Map;
|
|
3349
|
+
this.entries.set(projectId, m);
|
|
3350
|
+
}
|
|
3351
|
+
return m;
|
|
3352
|
+
}
|
|
3353
|
+
status(projectId) {
|
|
3354
|
+
return [...this.entries.get(projectId)?.values() ?? []];
|
|
3355
|
+
}
|
|
3356
|
+
async dispatch(projectId, wanted, o = {}) {
|
|
3357
|
+
const board = this.store.tasks(projectId);
|
|
3358
|
+
if (!board)
|
|
3359
|
+
return {
|
|
3360
|
+
ok: false,
|
|
3361
|
+
error: "this repo has no task source ([tasks] source in .swarm.toml)"
|
|
3362
|
+
};
|
|
3363
|
+
if (board.error)
|
|
3364
|
+
return { ok: false, error: `task source: ${board.error}` };
|
|
3365
|
+
const cfg = this.store.config(projectId).dispatch;
|
|
3366
|
+
const opts = {
|
|
3367
|
+
owner: o.owner ?? "dispatch",
|
|
3368
|
+
...o,
|
|
3369
|
+
maxParallel: o.maxParallel ?? cfg.max_parallel
|
|
3370
|
+
};
|
|
3371
|
+
this.opts.set(projectId, opts);
|
|
3372
|
+
const m = this.project(projectId);
|
|
3373
|
+
const running = [...m.values()].filter((e) => e.state === "running").length;
|
|
3374
|
+
const plan = planDispatch(board.tasks, wanted, {
|
|
3375
|
+
maxParallel: opts.maxParallel,
|
|
3376
|
+
running,
|
|
3377
|
+
max: o.max,
|
|
3378
|
+
alreadyQueued: [...m.values()].filter((e) => e.state !== "finished").map((e) => e.task)
|
|
3379
|
+
});
|
|
3380
|
+
const now = new Date().toISOString();
|
|
3381
|
+
for (const t of [...plan.start, ...plan.queued]) {
|
|
3382
|
+
m.set(t.id, {
|
|
3383
|
+
task: t.id,
|
|
3384
|
+
title: t.title,
|
|
3385
|
+
state: "queued",
|
|
3386
|
+
runId: null,
|
|
3387
|
+
sessionId: null,
|
|
3388
|
+
queuedAt: now,
|
|
3389
|
+
startedAt: null,
|
|
3390
|
+
endedAt: null,
|
|
3391
|
+
outcome: null,
|
|
3392
|
+
detail: null,
|
|
3393
|
+
costUsd: null
|
|
3394
|
+
});
|
|
3395
|
+
}
|
|
3396
|
+
if (plan.start.length || plan.queued.length)
|
|
3397
|
+
this.store.append({
|
|
3398
|
+
ts: now,
|
|
3399
|
+
type: "dispatch.queued",
|
|
3400
|
+
projectId,
|
|
3401
|
+
sessionId: null,
|
|
3402
|
+
payload: {
|
|
3403
|
+
tasks: [...plan.start, ...plan.queued].map((t) => t.id),
|
|
3404
|
+
maxParallel: opts.maxParallel,
|
|
3405
|
+
summary: `dispatch ${[...plan.start, ...plan.queued].map((t) => t.id).join(", ")}`
|
|
3406
|
+
}
|
|
3407
|
+
});
|
|
3408
|
+
const started = [];
|
|
3409
|
+
const failed = [];
|
|
3410
|
+
for (const t of plan.start) {
|
|
3411
|
+
const r = await this.startOne(projectId, t);
|
|
3412
|
+
if (r.ok)
|
|
3413
|
+
started.push(t.id);
|
|
3414
|
+
else
|
|
3415
|
+
failed.push({ id: t.id, reason: r.reason });
|
|
3416
|
+
}
|
|
3417
|
+
await this.fill(projectId);
|
|
3418
|
+
return {
|
|
3419
|
+
ok: true,
|
|
3420
|
+
started,
|
|
3421
|
+
queued: plan.queued.map((t) => t.id).filter((id) => m.get(id)?.state === "queued"),
|
|
3422
|
+
rejected: [...plan.rejected, ...failed]
|
|
3423
|
+
};
|
|
3424
|
+
}
|
|
3425
|
+
async startOne(projectId, t) {
|
|
3426
|
+
const m = this.project(projectId);
|
|
3427
|
+
const e = m.get(t.id);
|
|
3428
|
+
const opts = this.opts.get(projectId) ?? { owner: "dispatch" };
|
|
3429
|
+
const cfg = this.store.config(projectId);
|
|
3430
|
+
const gates2 = cfg.gates;
|
|
3431
|
+
const prompt = taskPrompt(t, {
|
|
3432
|
+
requiredGates: gates2.required,
|
|
3433
|
+
executableGates: gates2.required.filter((g) => gates2.defs[g]),
|
|
3434
|
+
openPr: cfg.dispatch.require_pr
|
|
3435
|
+
});
|
|
3436
|
+
const r = await this.runner.start({
|
|
3437
|
+
projectId,
|
|
3438
|
+
task: t.id,
|
|
3439
|
+
prompt,
|
|
3440
|
+
owner: opts.owner,
|
|
3441
|
+
permissionMode: opts.permissionMode ?? cfg.dispatch.permission_mode ?? "acceptEdits",
|
|
3442
|
+
model: opts.model ?? cfg.dispatch.model ?? undefined,
|
|
3443
|
+
maxTurns: opts.maxTurns ?? cfg.dispatch.max_turns ?? undefined,
|
|
3444
|
+
profile: opts.profile ?? cfg.dispatch.profile ?? undefined
|
|
3445
|
+
});
|
|
3446
|
+
if (!r.ok) {
|
|
3447
|
+
if (e) {
|
|
3448
|
+
e.state = "finished";
|
|
3449
|
+
e.endedAt = new Date().toISOString();
|
|
3450
|
+
e.outcome = "crashed";
|
|
3451
|
+
e.detail = r.reason;
|
|
3452
|
+
}
|
|
3453
|
+
this.store.append({
|
|
3454
|
+
ts: new Date().toISOString(),
|
|
3455
|
+
type: "dispatch.finished",
|
|
3456
|
+
projectId,
|
|
3457
|
+
sessionId: null,
|
|
3458
|
+
payload: {
|
|
3459
|
+
task: t.id,
|
|
3460
|
+
outcome: "crashed",
|
|
3461
|
+
detail: r.reason,
|
|
3462
|
+
summary: `dispatch ${t.id}: could not start \u2014 ${r.reason}`
|
|
3463
|
+
}
|
|
3464
|
+
});
|
|
3465
|
+
return { ok: false, reason: r.reason };
|
|
3466
|
+
}
|
|
3467
|
+
if (e) {
|
|
3468
|
+
e.state = "running";
|
|
3469
|
+
e.runId = r.run.id;
|
|
3470
|
+
e.sessionId = r.run.sessionId;
|
|
3471
|
+
e.startedAt = r.run.startedAt;
|
|
3472
|
+
}
|
|
3473
|
+
this.store.append({
|
|
3474
|
+
ts: r.run.startedAt,
|
|
3475
|
+
type: "dispatch.started",
|
|
3476
|
+
projectId,
|
|
3477
|
+
sessionId: r.run.sessionId,
|
|
3478
|
+
payload: {
|
|
3479
|
+
task: t.id,
|
|
3480
|
+
runId: r.run.id,
|
|
3481
|
+
worktree: r.run.worktree,
|
|
3482
|
+
summary: `dispatch ${t.id} \u2192 run ${r.run.id}`
|
|
3483
|
+
}
|
|
3484
|
+
});
|
|
3485
|
+
return { ok: true };
|
|
3486
|
+
}
|
|
3487
|
+
async fill(projectId) {
|
|
3488
|
+
const m = this.project(projectId);
|
|
3489
|
+
const cap = this.opts.get(projectId)?.maxParallel ?? this.store.config(projectId).dispatch.max_parallel;
|
|
3490
|
+
for (const e of m.values()) {
|
|
3491
|
+
const running = [...m.values()].filter((x) => x.state === "running").length;
|
|
3492
|
+
if (running >= cap)
|
|
3493
|
+
return;
|
|
3494
|
+
if (e.state !== "queued")
|
|
3495
|
+
continue;
|
|
3496
|
+
await this.startOne(projectId, { id: e.task, title: e.title });
|
|
3497
|
+
}
|
|
3498
|
+
}
|
|
3499
|
+
async onRunEnd(run2) {
|
|
3500
|
+
const m = this.entries.get(run2.projectId);
|
|
3501
|
+
const e = m?.get(run2.task);
|
|
3502
|
+
if (!e || e.runId !== run2.id)
|
|
3503
|
+
return;
|
|
3504
|
+
const cfg = this.store.config(run2.projectId);
|
|
3505
|
+
const required = cfg.gates.required;
|
|
3506
|
+
let runs = this.store.gateRuns(run2.projectId, run2.task);
|
|
3507
|
+
const status = this.store.gateStatusFor(runs, required);
|
|
3508
|
+
const missing = required.filter((g) => cfg.gates.defs[g] && status.find((s) => s.gate === g)?.verdict !== "pass");
|
|
3509
|
+
if (missing.length && !run2.stopped) {
|
|
3510
|
+
await this.store.runGates(run2.projectId, run2.task, missing, {
|
|
3511
|
+
sessionId: run2.sessionId,
|
|
3512
|
+
owner: "dispatch"
|
|
3513
|
+
});
|
|
3514
|
+
runs = this.store.gateRuns(run2.projectId, run2.task);
|
|
3515
|
+
}
|
|
3516
|
+
const satisfied = gatesSatisfied(runs, required);
|
|
3517
|
+
await this.forge.refresh(0).catch(() => {});
|
|
3518
|
+
const branch = `task/${run2.task}`;
|
|
3519
|
+
const pr = this.forge.prs().find((p) => p.projectId === run2.projectId && p.branch === branch);
|
|
3520
|
+
const outcome = dispatchOutcome({
|
|
3521
|
+
exitCode: run2.exitCode,
|
|
3522
|
+
isError: run2.result?.isError ?? false,
|
|
3523
|
+
gatesSatisfied: satisfied,
|
|
3524
|
+
prOpen: Boolean(pr),
|
|
3525
|
+
requirePr: cfg.dispatch.require_pr,
|
|
3526
|
+
stopped: run2.stopped ?? false
|
|
3527
|
+
});
|
|
3528
|
+
const verdicts = this.store.gateStatusFor(runs, required).map((s) => `${s.gate} ${s.verdict ?? "\u2014"}`).join(", ");
|
|
3529
|
+
const detail = [
|
|
3530
|
+
`exit ${run2.exitCode}${run2.result?.isError ? " (error)" : ""}`,
|
|
3531
|
+
required.length ? `gates: ${verdicts}` : null,
|
|
3532
|
+
pr ? `PR ${pr.url}` : cfg.dispatch.require_pr ? "no PR" : null
|
|
3533
|
+
].filter(Boolean).join(" \xB7 ");
|
|
3534
|
+
e.state = "finished";
|
|
3535
|
+
e.endedAt = run2.endedAt;
|
|
3536
|
+
e.outcome = outcome;
|
|
3537
|
+
e.detail = detail;
|
|
3538
|
+
e.costUsd = run2.result?.costUsd ?? null;
|
|
3539
|
+
const ts = run2.endedAt ?? new Date().toISOString();
|
|
3540
|
+
this.store.append({
|
|
3541
|
+
ts,
|
|
3542
|
+
type: "dispatch.finished",
|
|
3543
|
+
projectId: run2.projectId,
|
|
3544
|
+
sessionId: run2.sessionId,
|
|
3545
|
+
payload: {
|
|
3546
|
+
task: run2.task,
|
|
3547
|
+
runId: run2.id,
|
|
3548
|
+
outcome,
|
|
3549
|
+
detail,
|
|
3550
|
+
costUsd: e.costUsd,
|
|
3551
|
+
summary: `dispatch ${run2.task}: ${outcome} \u2014 ${detail}`
|
|
3552
|
+
}
|
|
3553
|
+
});
|
|
3554
|
+
if (outcome !== "done" && outcome !== "stopped")
|
|
3555
|
+
this.store.append({
|
|
3556
|
+
ts,
|
|
3557
|
+
type: "incident.opened",
|
|
3558
|
+
projectId: run2.projectId,
|
|
3559
|
+
sessionId: run2.sessionId,
|
|
3560
|
+
payload: {
|
|
3561
|
+
rule: "dispatch_failed",
|
|
3562
|
+
action: outcome,
|
|
3563
|
+
command: run2.task,
|
|
3564
|
+
reason: `dispatched run on ${run2.task} ended ${outcome}: ${detail}. The worktree and claim are kept; resume it from the session page or release it.`
|
|
3565
|
+
}
|
|
3566
|
+
});
|
|
3567
|
+
this.store.touch();
|
|
3568
|
+
await this.fill(run2.projectId);
|
|
3569
|
+
}
|
|
3570
|
+
clear(projectId, task) {
|
|
3571
|
+
const m = this.project(projectId);
|
|
3572
|
+
let n = 0;
|
|
3573
|
+
for (const [id, e] of m) {
|
|
3574
|
+
if (task && id !== task)
|
|
3575
|
+
continue;
|
|
3576
|
+
if (e.state === "queued" || e.state === "finished" && !task) {
|
|
3577
|
+
m.delete(id);
|
|
3578
|
+
n++;
|
|
3579
|
+
}
|
|
3580
|
+
}
|
|
3581
|
+
return n;
|
|
3582
|
+
}
|
|
3583
|
+
}
|
|
3584
|
+
|
|
2961
3585
|
// packages/daemon/src/forge.ts
|
|
2962
3586
|
import { existsSync as existsSync3 } from "fs";
|
|
2963
3587
|
import { homedir as homedir2 } from "os";
|
|
2964
|
-
import { join as
|
|
3588
|
+
import { join as join4 } from "path";
|
|
2965
3589
|
var EXTRA_BIN_DIRS = [
|
|
2966
3590
|
"/opt/homebrew/bin",
|
|
2967
3591
|
"/usr/local/bin",
|
|
2968
3592
|
"/home/linuxbrew/.linuxbrew/bin",
|
|
2969
|
-
|
|
2970
|
-
|
|
3593
|
+
join4(homedir2(), ".local", "bin"),
|
|
3594
|
+
join4(homedir2(), "bin")
|
|
2971
3595
|
];
|
|
2972
3596
|
function findBin(name) {
|
|
2973
3597
|
if (!name)
|
|
@@ -2976,7 +3600,7 @@ function findBin(name) {
|
|
|
2976
3600
|
if (onPath)
|
|
2977
3601
|
return onPath;
|
|
2978
3602
|
for (const d of EXTRA_BIN_DIRS) {
|
|
2979
|
-
const p =
|
|
3603
|
+
const p = join4(d, name);
|
|
2980
3604
|
if (existsSync3(p))
|
|
2981
3605
|
return p;
|
|
2982
3606
|
}
|
|
@@ -3045,6 +3669,69 @@ class ForgeService {
|
|
|
3045
3669
|
}
|
|
3046
3670
|
return prs.map((pr) => ({ ...pr, projectId, projectRoot: root }));
|
|
3047
3671
|
}
|
|
3672
|
+
async openPR(projectId, worktree2, draft) {
|
|
3673
|
+
const p = this.store.projects().find((x) => x.id === projectId);
|
|
3674
|
+
if (!p)
|
|
3675
|
+
return { ok: false, error: "unknown project" };
|
|
3676
|
+
if (worktree2.main)
|
|
3677
|
+
return { ok: false, error: "that is the main checkout \u2014 open the PR from a task worktree" };
|
|
3678
|
+
if (!worktree2.branch)
|
|
3679
|
+
return { ok: false, error: "detached HEAD \u2014 check out a branch first" };
|
|
3680
|
+
if (worktree2.dirty > 0)
|
|
3681
|
+
return {
|
|
3682
|
+
ok: false,
|
|
3683
|
+
error: `${worktree2.path} has uncommitted changes \u2014 commit them first (Swarm never commits for you)`
|
|
3684
|
+
};
|
|
3685
|
+
const remote = this.remote(p.root);
|
|
3686
|
+
if (!remote)
|
|
3687
|
+
return { ok: false, error: "no GitHub/GitLab remote on origin" };
|
|
3688
|
+
const cli = remote.forge === "github" ? "gh" : "glab";
|
|
3689
|
+
const bin = findBin(cli);
|
|
3690
|
+
if (!bin)
|
|
3691
|
+
return { ok: false, error: `${cli} is not installed` };
|
|
3692
|
+
const sh = async (cmd2, cwd) => {
|
|
3693
|
+
const proc = Bun.spawn(cmd2, { cwd, stdout: "pipe", stderr: "pipe" });
|
|
3694
|
+
const out = await new Response(proc.stdout).text() + await new Response(proc.stderr).text();
|
|
3695
|
+
return { ok: await proc.exited === 0, out: out.trim() };
|
|
3696
|
+
};
|
|
3697
|
+
const push = await sh(["git", "push", "-u", "origin", worktree2.branch], worktree2.path);
|
|
3698
|
+
if (!push.ok)
|
|
3699
|
+
return { ok: false, error: `git push failed: ${push.out.slice(0, 400)}` };
|
|
3700
|
+
const existing = this.prs().find((x) => x.projectId === projectId && x.branch === worktree2.branch);
|
|
3701
|
+
if (existing)
|
|
3702
|
+
return { ok: true, url: existing.url, number: existing.number };
|
|
3703
|
+
const cmd = remote.forge === "github" ? [
|
|
3704
|
+
bin,
|
|
3705
|
+
"pr",
|
|
3706
|
+
"create",
|
|
3707
|
+
"--head",
|
|
3708
|
+
worktree2.branch,
|
|
3709
|
+
"--title",
|
|
3710
|
+
draft.title,
|
|
3711
|
+
"--body",
|
|
3712
|
+
draft.body,
|
|
3713
|
+
...draft.isDraft ? ["--draft"] : []
|
|
3714
|
+
] : [
|
|
3715
|
+
bin,
|
|
3716
|
+
"mr",
|
|
3717
|
+
"create",
|
|
3718
|
+
"--source-branch",
|
|
3719
|
+
worktree2.branch,
|
|
3720
|
+
"--title",
|
|
3721
|
+
draft.title,
|
|
3722
|
+
"--description",
|
|
3723
|
+
draft.body,
|
|
3724
|
+
"--yes",
|
|
3725
|
+
...draft.isDraft ? ["--draft"] : []
|
|
3726
|
+
];
|
|
3727
|
+
const r = await sh(cmd, worktree2.path);
|
|
3728
|
+
if (!r.ok)
|
|
3729
|
+
return { ok: false, error: `${cli} failed: ${r.out.slice(0, 400)}` };
|
|
3730
|
+
const url = r.out.match(/https?:\/\/\S+/)?.[0] ?? r.out;
|
|
3731
|
+
const num = Number(url.match(/\/(\d+)\s*$/)?.[1]);
|
|
3732
|
+
this.cache.delete(projectId);
|
|
3733
|
+
return { ok: true, url, number: Number.isFinite(num) ? num : null };
|
|
3734
|
+
}
|
|
3048
3735
|
async merge(projectId, number) {
|
|
3049
3736
|
const p = this.store.projects().find((x) => x.id === projectId);
|
|
3050
3737
|
if (!p)
|
|
@@ -3065,64 +3752,277 @@ class ForgeService {
|
|
|
3065
3752
|
}
|
|
3066
3753
|
}
|
|
3067
3754
|
|
|
3068
|
-
// packages/daemon/src/
|
|
3069
|
-
import {
|
|
3070
|
-
import { join as
|
|
3071
|
-
|
|
3072
|
-
|
|
3073
|
-
|
|
3074
|
-
|
|
3075
|
-
|
|
3076
|
-
|
|
3077
|
-
"plan"
|
|
3078
|
-
];
|
|
3079
|
-
|
|
3080
|
-
class Runner {
|
|
3081
|
-
store;
|
|
3082
|
-
home;
|
|
3083
|
-
live = new Map;
|
|
3084
|
-
constructor(store, home) {
|
|
3085
|
-
this.store = store;
|
|
3086
|
-
this.home = home;
|
|
3755
|
+
// packages/daemon/src/git.ts
|
|
3756
|
+
import { realpathSync } from "fs";
|
|
3757
|
+
import { join as join5 } from "path";
|
|
3758
|
+
function git(cwd, args) {
|
|
3759
|
+
try {
|
|
3760
|
+
const r = Bun.spawnSync(["git", "-C", cwd, ...args], { stdout: "pipe", stderr: "ignore" });
|
|
3761
|
+
return r.exitCode === 0 ? r.stdout.toString() : null;
|
|
3762
|
+
} catch {
|
|
3763
|
+
return null;
|
|
3087
3764
|
}
|
|
3088
|
-
|
|
3089
|
-
|
|
3765
|
+
}
|
|
3766
|
+
function gitCommonDir(cwd) {
|
|
3767
|
+
const out = git(cwd, ["rev-parse", "--git-common-dir"])?.trim();
|
|
3768
|
+
if (!out)
|
|
3769
|
+
return null;
|
|
3770
|
+
try {
|
|
3771
|
+
return realpathSync(out.startsWith("/") ? out : join5(cwd, out));
|
|
3772
|
+
} catch {
|
|
3773
|
+
return null;
|
|
3090
3774
|
}
|
|
3091
|
-
|
|
3092
|
-
|
|
3093
|
-
|
|
3094
|
-
|
|
3775
|
+
}
|
|
3776
|
+
function gitToplevel(cwd) {
|
|
3777
|
+
const out = git(cwd, ["rev-parse", "--show-toplevel"])?.trim();
|
|
3778
|
+
if (!out)
|
|
3779
|
+
return null;
|
|
3780
|
+
try {
|
|
3781
|
+
return realpathSync(out);
|
|
3782
|
+
} catch {
|
|
3095
3783
|
return null;
|
|
3096
3784
|
}
|
|
3097
|
-
|
|
3098
|
-
|
|
3099
|
-
|
|
3100
|
-
|
|
3101
|
-
|
|
3102
|
-
if (
|
|
3103
|
-
|
|
3104
|
-
|
|
3105
|
-
|
|
3106
|
-
|
|
3107
|
-
|
|
3108
|
-
|
|
3109
|
-
|
|
3110
|
-
|
|
3111
|
-
|
|
3112
|
-
};
|
|
3113
|
-
const held = this.store.claims(input.projectId).find((c) => c.task === input.task && c.state === "held" && c.owner === input.owner);
|
|
3114
|
-
let worktree = held?.worktree ?? "";
|
|
3115
|
-
if (!worktree) {
|
|
3116
|
-
const c = this.store.claim(input.projectId, input.task, input.owner);
|
|
3117
|
-
if (!c.ok)
|
|
3118
|
-
return { ok: false, reason: c.error };
|
|
3119
|
-
worktree = c.worktree;
|
|
3785
|
+
}
|
|
3786
|
+
function parseWorktreeList(out) {
|
|
3787
|
+
const wts = [];
|
|
3788
|
+
let cur = null;
|
|
3789
|
+
const flush = () => {
|
|
3790
|
+
if (cur?.path) {
|
|
3791
|
+
wts.push({
|
|
3792
|
+
path: cur.path,
|
|
3793
|
+
branch: cur.branch ?? null,
|
|
3794
|
+
head: (cur.head ?? "").slice(0, 7),
|
|
3795
|
+
main: wts.length === 0,
|
|
3796
|
+
dirty: -1,
|
|
3797
|
+
ahead: -1,
|
|
3798
|
+
behind: -1,
|
|
3799
|
+
merged: false
|
|
3800
|
+
});
|
|
3120
3801
|
}
|
|
3802
|
+
cur = null;
|
|
3803
|
+
};
|
|
3804
|
+
for (const line of out.split(`
|
|
3805
|
+
`)) {
|
|
3806
|
+
if (line.startsWith("worktree ")) {
|
|
3807
|
+
flush();
|
|
3808
|
+
cur = { path: line.slice(9) };
|
|
3809
|
+
} else if (line.startsWith("HEAD ") && cur)
|
|
3810
|
+
cur.head = line.slice(5);
|
|
3811
|
+
else if (line.startsWith("branch ") && cur)
|
|
3812
|
+
cur.branch = line.slice(7).replace(/^refs\/heads\//, "");
|
|
3813
|
+
else if (line === "")
|
|
3814
|
+
flush();
|
|
3815
|
+
}
|
|
3816
|
+
flush();
|
|
3817
|
+
return wts;
|
|
3818
|
+
}
|
|
3819
|
+
function applyStatus(w, st, ah) {
|
|
3820
|
+
w.dirty = st === null ? -1 : st.split(`
|
|
3821
|
+
`).filter(Boolean).length;
|
|
3822
|
+
const a = ah?.trim();
|
|
3823
|
+
w.ahead = a === undefined || a === "" ? -1 : Number(a);
|
|
3824
|
+
}
|
|
3825
|
+
function applyDrift(w, behind, ancestor, firstParents) {
|
|
3826
|
+
const b = behind?.trim();
|
|
3827
|
+
w.behind = b === undefined || b === "" ? -1 : Number(b);
|
|
3828
|
+
const onLine = firstParents?.split(`
|
|
3829
|
+
`).some((sha) => sha.startsWith(w.head)) ?? true;
|
|
3830
|
+
w.merged = ancestor && !onLine;
|
|
3831
|
+
}
|
|
3832
|
+
var FIRST_PARENT_DEPTH = "5000";
|
|
3833
|
+
var baseOf = (wts) => wts[0]?.main ? wts[0].branch : null;
|
|
3834
|
+
async function gitAsync(cwd, args) {
|
|
3835
|
+
try {
|
|
3836
|
+
const p = Bun.spawn(["git", "-C", cwd, ...args], { stdout: "pipe", stderr: "ignore" });
|
|
3837
|
+
const [out, code] = await Promise.all([new Response(p.stdout).text(), p.exited]);
|
|
3838
|
+
return code === 0 ? out : null;
|
|
3839
|
+
} catch {
|
|
3840
|
+
return null;
|
|
3841
|
+
}
|
|
3842
|
+
}
|
|
3843
|
+
async function listWorktreesAsync(root) {
|
|
3844
|
+
const out = await gitAsync(root, ["worktree", "list", "--porcelain"]);
|
|
3845
|
+
if (!out)
|
|
3846
|
+
return [];
|
|
3847
|
+
const wts = parseWorktreeList(out);
|
|
3848
|
+
const base = baseOf(wts);
|
|
3849
|
+
const line = base ? await gitAsync(root, ["rev-list", "--first-parent", "-n", FIRST_PARENT_DEPTH, base]) : null;
|
|
3850
|
+
await Promise.all(wts.map(async (w) => {
|
|
3851
|
+
const drift = base && !w.main;
|
|
3852
|
+
const [st, ah, be, mg] = await Promise.all([
|
|
3853
|
+
gitAsync(w.path, ["status", "--porcelain", "--untracked-files=no"]),
|
|
3854
|
+
gitAsync(w.path, ["rev-list", "--count", "@{upstream}..HEAD"]),
|
|
3855
|
+
drift ? gitAsync(w.path, ["rev-list", "--count", `HEAD..${base}`]) : null,
|
|
3856
|
+
drift ? gitAsync(w.path, ["merge-base", "--is-ancestor", "HEAD", base]) : null
|
|
3857
|
+
]);
|
|
3858
|
+
applyStatus(w, st, ah);
|
|
3859
|
+
if (drift)
|
|
3860
|
+
applyDrift(w, be, mg !== null, line);
|
|
3861
|
+
}));
|
|
3862
|
+
return wts;
|
|
3863
|
+
}
|
|
3864
|
+
var branchCache = new Map;
|
|
3865
|
+
function currentBranch(cwd) {
|
|
3866
|
+
const hit = branchCache.get(cwd);
|
|
3867
|
+
const now = Date.now();
|
|
3868
|
+
if (hit && now - hit.t < 5000)
|
|
3869
|
+
return hit.v;
|
|
3870
|
+
const v = git(cwd, ["rev-parse", "--abbrev-ref", "HEAD"])?.trim() ?? null;
|
|
3871
|
+
branchCache.set(cwd, { v: v === "HEAD" ? "(detached)" : v, t: now });
|
|
3872
|
+
return branchCache.get(cwd)?.v ?? null;
|
|
3873
|
+
}
|
|
3874
|
+
function worktreeAdd(repoRoot, path, branch, baseRef = "HEAD") {
|
|
3875
|
+
const branchExists = git(repoRoot, ["rev-parse", "--verify", "--quiet", `refs/heads/${branch}`]) !== null;
|
|
3876
|
+
const args = branchExists ? ["worktree", "add", path, branch] : ["worktree", "add", "-b", branch, path, baseRef];
|
|
3877
|
+
if (git(repoRoot, args) === null)
|
|
3878
|
+
return null;
|
|
3879
|
+
try {
|
|
3880
|
+
return realpathSync(path);
|
|
3881
|
+
} catch {
|
|
3882
|
+
return path;
|
|
3883
|
+
}
|
|
3884
|
+
}
|
|
3885
|
+
function worktreeRemove(repoRoot, path, force) {
|
|
3886
|
+
const args = ["worktree", "remove", path];
|
|
3887
|
+
if (force)
|
|
3888
|
+
args.push("--force");
|
|
3889
|
+
return git(repoRoot, args) !== null;
|
|
3890
|
+
}
|
|
3891
|
+
function heldWork(path) {
|
|
3892
|
+
const status = git(path, ["status", "--porcelain"]);
|
|
3893
|
+
const dirty = status !== null && status.trim().length > 0;
|
|
3894
|
+
const count = (args) => {
|
|
3895
|
+
const out = git(path, ["rev-list", "--count", ...args])?.trim();
|
|
3896
|
+
return out !== undefined && out !== "" ? Number(out) : 0;
|
|
3897
|
+
};
|
|
3898
|
+
let unpushed;
|
|
3899
|
+
if (git(path, ["rev-parse", "--verify", "--quiet", "@{upstream}"]) !== null) {
|
|
3900
|
+
unpushed = count(["@{upstream}..HEAD"]) > 0;
|
|
3901
|
+
} else {
|
|
3902
|
+
const baselines = ["--remotes"];
|
|
3903
|
+
for (const b of ["main", "master"]) {
|
|
3904
|
+
if (git(path, ["rev-parse", "--verify", "--quiet", `refs/heads/${b}`]) !== null)
|
|
3905
|
+
baselines.push(b);
|
|
3906
|
+
}
|
|
3907
|
+
unpushed = baselines.length > 1 ? count(["HEAD", "--not", ...baselines]) > 0 : false;
|
|
3908
|
+
}
|
|
3909
|
+
return { dirty, unpushed };
|
|
3910
|
+
}
|
|
3911
|
+
async function worktreeDiff(root, path) {
|
|
3912
|
+
const wts = parseWorktreeList(await gitAsync(root, ["worktree", "list", "--porcelain"]) ?? "");
|
|
3913
|
+
const baseRef = wts[0]?.path === realpathOr(root) || wts[0]?.main ? wts[0]?.branch ?? null : null;
|
|
3914
|
+
const isMain = wts[0]?.path === path;
|
|
3915
|
+
const mb = baseRef && !isMain ? (await gitAsync(path, ["merge-base", baseRef, "HEAD"]))?.trim() : null;
|
|
3916
|
+
const from = mb || "HEAD";
|
|
3917
|
+
const [numstat, names, log, status] = await Promise.all([
|
|
3918
|
+
gitAsync(path, ["diff", "--numstat", from]),
|
|
3919
|
+
gitAsync(path, ["diff", "--name-status", from]),
|
|
3920
|
+
mb ? gitAsync(path, ["log", "--format=%s", `${mb}..HEAD`]) : Promise.resolve(""),
|
|
3921
|
+
gitAsync(path, ["status", "--porcelain"])
|
|
3922
|
+
]);
|
|
3923
|
+
const files = parseNumstat(numstat ?? "", names ?? "");
|
|
3924
|
+
for (const line of (status ?? "").split(`
|
|
3925
|
+
`)) {
|
|
3926
|
+
if (line.startsWith("?? "))
|
|
3927
|
+
files.push({ path: line.slice(3), added: -1, deleted: -1, status: "?" });
|
|
3928
|
+
}
|
|
3929
|
+
return {
|
|
3930
|
+
base: mb ?? null,
|
|
3931
|
+
baseRef,
|
|
3932
|
+
files,
|
|
3933
|
+
commits: (log ?? "").split(`
|
|
3934
|
+
`).filter(Boolean),
|
|
3935
|
+
dirty: (status ?? "").trim().length > 0
|
|
3936
|
+
};
|
|
3937
|
+
}
|
|
3938
|
+
async function worktreePatch(path, base, file) {
|
|
3939
|
+
const from = base ?? "HEAD";
|
|
3940
|
+
if (file) {
|
|
3941
|
+
const tracked = await gitAsync(path, ["ls-files", "--error-unmatch", "--", file]) !== null;
|
|
3942
|
+
if (!tracked) {
|
|
3943
|
+
const p = Bun.spawn(["git", "-C", path, "diff", "--no-index", "--", "/dev/null", file], {
|
|
3944
|
+
stdout: "pipe",
|
|
3945
|
+
stderr: "ignore"
|
|
3946
|
+
});
|
|
3947
|
+
const [out] = await Promise.all([new Response(p.stdout).text(), p.exited]);
|
|
3948
|
+
return out;
|
|
3949
|
+
}
|
|
3950
|
+
return await gitAsync(path, ["diff", from, "--", file]) ?? "";
|
|
3951
|
+
}
|
|
3952
|
+
return await gitAsync(path, ["diff", from]) ?? "";
|
|
3953
|
+
}
|
|
3954
|
+
function realpathOr(p) {
|
|
3955
|
+
try {
|
|
3956
|
+
return realpathSync(p);
|
|
3957
|
+
} catch {
|
|
3958
|
+
return p;
|
|
3959
|
+
}
|
|
3960
|
+
}
|
|
3961
|
+
|
|
3962
|
+
// packages/daemon/src/runner.ts
|
|
3963
|
+
import { appendFileSync, mkdirSync as mkdirSync2, openSync } from "fs";
|
|
3964
|
+
import { join as join6 } from "path";
|
|
3965
|
+
var PERMISSION_MODES = [
|
|
3966
|
+
"acceptEdits",
|
|
3967
|
+
"auto",
|
|
3968
|
+
"bypassPermissions",
|
|
3969
|
+
"manual",
|
|
3970
|
+
"dontAsk",
|
|
3971
|
+
"plan"
|
|
3972
|
+
];
|
|
3973
|
+
|
|
3974
|
+
class Runner {
|
|
3975
|
+
store;
|
|
3976
|
+
home;
|
|
3977
|
+
live = new Map;
|
|
3978
|
+
endListeners = new Set;
|
|
3979
|
+
onEnd(fn) {
|
|
3980
|
+
this.endListeners.add(fn);
|
|
3981
|
+
return () => this.endListeners.delete(fn);
|
|
3982
|
+
}
|
|
3983
|
+
constructor(store, home) {
|
|
3984
|
+
this.store = store;
|
|
3985
|
+
this.home = home;
|
|
3986
|
+
}
|
|
3987
|
+
list(projectId) {
|
|
3988
|
+
return [...this.live.values()].map((x) => x.run).filter((r) => !projectId || r.projectId === projectId);
|
|
3989
|
+
}
|
|
3990
|
+
get(idOrTask) {
|
|
3991
|
+
for (const { run: run2 } of this.live.values())
|
|
3992
|
+
if (run2.id === idOrTask || run2.sessionId === idOrTask || run2.task === idOrTask)
|
|
3993
|
+
return run2;
|
|
3994
|
+
return null;
|
|
3995
|
+
}
|
|
3996
|
+
async start(input) {
|
|
3997
|
+
const bin = findBin("claude");
|
|
3998
|
+
if (!bin)
|
|
3999
|
+
return { ok: false, reason: "claude CLI not found \u2014 install Claude Code first" };
|
|
4000
|
+
const project = this.store.project(input.projectId);
|
|
4001
|
+
if (!project)
|
|
4002
|
+
return { ok: false, reason: "unknown project" };
|
|
4003
|
+
if (!input.prompt.trim())
|
|
4004
|
+
return { ok: false, reason: "prompt is required" };
|
|
4005
|
+
if (input.permissionMode && !PERMISSION_MODES.includes(input.permissionMode))
|
|
4006
|
+
return { ok: false, reason: `permission mode must be one of ${PERMISSION_MODES.join(", ")}` };
|
|
4007
|
+
if (this.get(input.task)?.projectId === input.projectId)
|
|
4008
|
+
return {
|
|
4009
|
+
ok: false,
|
|
4010
|
+
reason: `a run on ${input.task} is already live \u2014 stop it or send it input`
|
|
4011
|
+
};
|
|
4012
|
+
const held = this.store.claims(input.projectId).find((c) => c.task === input.task && c.state === "held" && c.owner === input.owner);
|
|
4013
|
+
let worktree2 = held?.worktree ?? "";
|
|
4014
|
+
if (!worktree2) {
|
|
4015
|
+
const c = this.store.claim(input.projectId, input.task, input.owner);
|
|
4016
|
+
if (!c.ok)
|
|
4017
|
+
return { ok: false, reason: c.error };
|
|
4018
|
+
worktree2 = c.worktree;
|
|
4019
|
+
}
|
|
4020
|
+
await this.store.awaitBootstrap(worktree2);
|
|
3121
4021
|
const sessionId = crypto.randomUUID();
|
|
3122
4022
|
const id = sessionId.slice(0, 8);
|
|
3123
|
-
const logDir =
|
|
4023
|
+
const logDir = join6(this.home, "logs", project.id);
|
|
3124
4024
|
mkdirSync2(logDir, { recursive: true });
|
|
3125
|
-
const log =
|
|
4025
|
+
const log = join6(logDir, `run-${input.task.replace(/[^a-zA-Z0-9_.-]+/g, "-")}-${id}.log`);
|
|
3126
4026
|
const logFd = openSync(log, "a");
|
|
3127
4027
|
const args = [
|
|
3128
4028
|
bin,
|
|
@@ -3141,13 +4041,22 @@ class Runner {
|
|
|
3141
4041
|
args.push("--model", input.model);
|
|
3142
4042
|
if (input.permissionMode)
|
|
3143
4043
|
args.push("--permission-mode", input.permissionMode);
|
|
3144
|
-
|
|
3145
|
-
|
|
4044
|
+
const profile = runProfile(input.profile);
|
|
4045
|
+
if (input.profile && !profile)
|
|
4046
|
+
return {
|
|
4047
|
+
ok: false,
|
|
4048
|
+
reason: `unknown profile ${input.profile} \u2014 one of ${Object.keys(RUN_PROFILES).join(", ")}`
|
|
4049
|
+
};
|
|
4050
|
+
const allowed = [...input.allowedTools ?? [], ...profile?.allowedTools ?? []];
|
|
4051
|
+
if (allowed.length)
|
|
4052
|
+
args.push("--allowedTools", ...allowed);
|
|
4053
|
+
if (profile?.disallowedTools.length)
|
|
4054
|
+
args.push("--disallowedTools", ...profile.disallowedTools);
|
|
3146
4055
|
if (input.maxTurns)
|
|
3147
4056
|
args.push("--max-turns", String(input.maxTurns));
|
|
3148
|
-
this.store.preregisterSpawnedSession(sessionId, project.id,
|
|
4057
|
+
this.store.preregisterSpawnedSession(sessionId, project.id, worktree2, input.task);
|
|
3149
4058
|
const proc = Bun.spawn(args, {
|
|
3150
|
-
cwd:
|
|
4059
|
+
cwd: worktree2,
|
|
3151
4060
|
env: { ...process.env, SWARM_RUN_ID: id, SWARM_OWNER: input.owner },
|
|
3152
4061
|
stdin: "pipe",
|
|
3153
4062
|
stdout: "pipe",
|
|
@@ -3158,11 +4067,12 @@ class Runner {
|
|
|
3158
4067
|
sessionId,
|
|
3159
4068
|
projectId: project.id,
|
|
3160
4069
|
task: input.task,
|
|
3161
|
-
worktree,
|
|
4070
|
+
worktree: worktree2,
|
|
3162
4071
|
pid: proc.pid,
|
|
3163
4072
|
owner: input.owner,
|
|
3164
4073
|
model: input.model ?? null,
|
|
3165
4074
|
permissionMode: input.permissionMode ?? null,
|
|
4075
|
+
profile: input.profile ?? null,
|
|
3166
4076
|
prompt: input.prompt,
|
|
3167
4077
|
log,
|
|
3168
4078
|
startedAt: new Date().toISOString(),
|
|
@@ -3178,7 +4088,7 @@ class Runner {
|
|
|
3178
4088
|
sessionId,
|
|
3179
4089
|
kind: "proc",
|
|
3180
4090
|
name: `run:${input.task}`,
|
|
3181
|
-
cwd:
|
|
4091
|
+
cwd: worktree2,
|
|
3182
4092
|
cmd: `claude -p (run ${id})`,
|
|
3183
4093
|
owner: input.owner,
|
|
3184
4094
|
log
|
|
@@ -3243,6 +4153,13 @@ class Runner {
|
|
|
3243
4153
|
});
|
|
3244
4154
|
this.store.endSpawnedSession(entry.run.sessionId);
|
|
3245
4155
|
this.live.delete(id);
|
|
4156
|
+
for (const fn of this.endListeners) {
|
|
4157
|
+
try {
|
|
4158
|
+
fn(entry.run);
|
|
4159
|
+
} catch (e) {
|
|
4160
|
+
console.error("swarm run: onEnd listener failed:", e.message);
|
|
4161
|
+
}
|
|
4162
|
+
}
|
|
3246
4163
|
}
|
|
3247
4164
|
onLine(run2, line) {
|
|
3248
4165
|
if (!line.startsWith("{"))
|
|
@@ -3366,6 +4283,7 @@ class Runner {
|
|
|
3366
4283
|
if (!run2)
|
|
3367
4284
|
return { ok: false, reason: "no live run" };
|
|
3368
4285
|
const entry = this.live.get(run2.id);
|
|
4286
|
+
run2.stopped = true;
|
|
3369
4287
|
try {
|
|
3370
4288
|
const stdin = entry?.proc.stdin;
|
|
3371
4289
|
if (stdin && typeof stdin !== "number")
|
|
@@ -3382,9 +4300,9 @@ class Runner {
|
|
|
3382
4300
|
import { Database } from "bun:sqlite";
|
|
3383
4301
|
import {
|
|
3384
4302
|
closeSync,
|
|
3385
|
-
existsSync as
|
|
3386
|
-
mkdirSync as
|
|
3387
|
-
openSync as
|
|
4303
|
+
existsSync as existsSync5,
|
|
4304
|
+
mkdirSync as mkdirSync4,
|
|
4305
|
+
openSync as openSync3,
|
|
3388
4306
|
readdirSync,
|
|
3389
4307
|
readFileSync as readFileSync3,
|
|
3390
4308
|
readSync,
|
|
@@ -3394,145 +4312,54 @@ import {
|
|
|
3394
4312
|
writeFileSync as writeFileSync2
|
|
3395
4313
|
} from "fs";
|
|
3396
4314
|
import { homedir as homedir3 } from "os";
|
|
3397
|
-
import { basename, dirname, join as
|
|
4315
|
+
import { basename, dirname as dirname2, join as join8 } from "path";
|
|
3398
4316
|
|
|
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
|
-
});
|
|
4317
|
+
// packages/daemon/src/bootstrap.ts
|
|
4318
|
+
import { cpSync, existsSync as existsSync4, mkdirSync as mkdirSync3, openSync as openSync2 } from "fs";
|
|
4319
|
+
import { dirname, join as join7 } from "path";
|
|
4320
|
+
function runBootstrap(plan, opts) {
|
|
4321
|
+
const logDir = join7(opts.home, "logs", opts.projectId);
|
|
4322
|
+
mkdirSync3(logDir, { recursive: true });
|
|
4323
|
+
const log = join7(logDir, `bootstrap-${opts.task.replace(/[^a-zA-Z0-9_.-]+/g, "-")}.log`);
|
|
4324
|
+
const copied = [];
|
|
4325
|
+
const skipped = [];
|
|
4326
|
+
for (const c of plan.copies) {
|
|
4327
|
+
if (!existsSync4(c.from)) {
|
|
4328
|
+
skipped.push(c.rel);
|
|
4329
|
+
continue;
|
|
3443
4330
|
}
|
|
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);
|
|
4331
|
+
try {
|
|
4332
|
+
mkdirSync3(dirname(c.to), { recursive: true });
|
|
4333
|
+
cpSync(c.from, c.to, { recursive: true, force: true });
|
|
4334
|
+
copied.push(c.rel);
|
|
4335
|
+
} catch (e) {
|
|
4336
|
+
skipped.push(`${c.rel} (${e.message})`);
|
|
3532
4337
|
}
|
|
3533
|
-
unpushed = baselines.length > 1 ? count(["HEAD", "--not", ...baselines]) > 0 : false;
|
|
3534
4338
|
}
|
|
3535
|
-
|
|
4339
|
+
const done = (async () => {
|
|
4340
|
+
if (!plan.setup)
|
|
4341
|
+
return { copied, skipped, setup: null };
|
|
4342
|
+
const command = plan.setup;
|
|
4343
|
+
const started = Date.now();
|
|
4344
|
+
let exitCode = -1;
|
|
4345
|
+
try {
|
|
4346
|
+
const fd = openSync2(log, "a");
|
|
4347
|
+
const proc = Bun.spawn(["sh", "-c", command], {
|
|
4348
|
+
cwd: opts.worktree,
|
|
4349
|
+
stdin: "ignore",
|
|
4350
|
+
stdout: fd,
|
|
4351
|
+
stderr: fd,
|
|
4352
|
+
env: { ...process.env, SWARM_WORKTREE: opts.worktree, SWARM_TASK: opts.task }
|
|
4353
|
+
});
|
|
4354
|
+
exitCode = await proc.exited;
|
|
4355
|
+
} catch (e) {
|
|
4356
|
+
exitCode = -1;
|
|
4357
|
+
await Bun.write(log, `swarm: could not start setup: ${e.message}
|
|
4358
|
+
`);
|
|
4359
|
+
}
|
|
4360
|
+
return { copied, skipped, setup: { command, exitCode, durationMs: Date.now() - started } };
|
|
4361
|
+
})();
|
|
4362
|
+
return { log, done };
|
|
3536
4363
|
}
|
|
3537
4364
|
|
|
3538
4365
|
// packages/daemon/src/task-sources.ts
|
|
@@ -3658,6 +4485,12 @@ CREATE TABLE IF NOT EXISTS handoffs (
|
|
|
3658
4485
|
files TEXT, verify TEXT, by TEXT, session_id TEXT, created_at TEXT
|
|
3659
4486
|
);
|
|
3660
4487
|
CREATE INDEX IF NOT EXISTS handoffs_task ON handoffs(project_id, task, created_at);
|
|
4488
|
+
CREATE TABLE IF NOT EXISTS messages (
|
|
4489
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT, project_id TEXT, session_id TEXT, task TEXT, kind TEXT,
|
|
4490
|
+
text TEXT, options TEXT, asked_by TEXT, created_at TEXT,
|
|
4491
|
+
answer TEXT, answered_by TEXT, answered_at TEXT, delivered_at TEXT
|
|
4492
|
+
);
|
|
4493
|
+
CREATE INDEX IF NOT EXISTS messages_open ON messages(project_id, answered_at, delivered_at);
|
|
3661
4494
|
CREATE TABLE IF NOT EXISTS claims (
|
|
3662
4495
|
project_id TEXT, task TEXT, owner TEXT, worktree TEXT, branch TEXT,
|
|
3663
4496
|
acquired_at TEXT, expires_at TEXT, released_at TEXT, state TEXT,
|
|
@@ -3678,15 +4511,15 @@ class Store {
|
|
|
3678
4511
|
gen = 0;
|
|
3679
4512
|
memo = new Map;
|
|
3680
4513
|
constructor(home = swarmHome()) {
|
|
3681
|
-
|
|
4514
|
+
mkdirSync4(home, { recursive: true });
|
|
3682
4515
|
this.home = home;
|
|
3683
|
-
this.db = new Database(
|
|
4516
|
+
this.db = new Database(join8(home, "swarm.db"));
|
|
3684
4517
|
this.loadPricing();
|
|
3685
4518
|
this.db.exec("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL; PRAGMA mmap_size=268435456; PRAGMA cache_size=-32000;");
|
|
3686
4519
|
this.db.exec(SCHEMA);
|
|
3687
4520
|
this.ensureColumn("sessions", "agent", "TEXT DEFAULT 'claude-code'");
|
|
3688
4521
|
this.ensureColumn("projects", "sort_order", "INTEGER");
|
|
3689
|
-
this.migrateProjectsJson(
|
|
4522
|
+
this.migrateProjectsJson(join8(home, "projects.json"));
|
|
3690
4523
|
this.reconcileMovedProjects();
|
|
3691
4524
|
this.slimExistingEvents();
|
|
3692
4525
|
this.retypeNotificationIncidents();
|
|
@@ -3734,9 +4567,9 @@ class Store {
|
|
|
3734
4567
|
reconcileMovedProjects() {
|
|
3735
4568
|
const all = this.projects();
|
|
3736
4569
|
for (const stale of all) {
|
|
3737
|
-
if (
|
|
4570
|
+
if (existsSync5(stale.root))
|
|
3738
4571
|
continue;
|
|
3739
|
-
const live = all.filter((p) => p.id !== stale.id && p.name === stale.name &&
|
|
4572
|
+
const live = all.filter((p) => p.id !== stale.id && p.name === stale.name && existsSync5(p.root));
|
|
3740
4573
|
if (live.length !== 1)
|
|
3741
4574
|
continue;
|
|
3742
4575
|
this.mergeProject(stale.id, live[0].id);
|
|
@@ -3769,7 +4602,7 @@ class Store {
|
|
|
3769
4602
|
}
|
|
3770
4603
|
}
|
|
3771
4604
|
migrateProjectsJson(file) {
|
|
3772
|
-
if (!
|
|
4605
|
+
if (!existsSync5(file))
|
|
3773
4606
|
return;
|
|
3774
4607
|
try {
|
|
3775
4608
|
const list = JSON.parse(readFileSync3(file, "utf8"));
|
|
@@ -3784,7 +4617,7 @@ class Store {
|
|
|
3784
4617
|
const hit = this.topCache.get(cwd);
|
|
3785
4618
|
if (hit && Date.now() - hit.t < 1e4)
|
|
3786
4619
|
return hit.v;
|
|
3787
|
-
const v = cwd &&
|
|
4620
|
+
const v = cwd && existsSync5(cwd) ? gitToplevel(cwd) : null;
|
|
3788
4621
|
this.topCache.set(cwd, { v, t: Date.now() });
|
|
3789
4622
|
return v;
|
|
3790
4623
|
}
|
|
@@ -3988,45 +4821,173 @@ class Store {
|
|
|
3988
4821
|
snippet: r.snippet
|
|
3989
4822
|
}));
|
|
3990
4823
|
}
|
|
3991
|
-
sessionContext(cwd) {
|
|
3992
|
-
if (!cwd || !
|
|
4824
|
+
sessionContext(cwd) {
|
|
4825
|
+
if (!cwd || !existsSync5(cwd))
|
|
4826
|
+
return null;
|
|
4827
|
+
const toplevel = this.toplevel(cwd);
|
|
4828
|
+
const project = this.resolveProject(cwd);
|
|
4829
|
+
const lines = [];
|
|
4830
|
+
const held = this.heldClaimsWithWorktree().find((c) => isInside(cwd, c.worktree));
|
|
4831
|
+
if (held) {
|
|
4832
|
+
const left = Math.max(0, Math.round((new Date(held.expiresAt).getTime() - Date.now()) / 60000));
|
|
4833
|
+
lines.push(`[swarm] you hold ${held.task} (${left}m left, renews while you work) in ${held.worktree}`);
|
|
4834
|
+
const h = this.latestHandoff(held.projectId, held.task);
|
|
4835
|
+
if (h)
|
|
4836
|
+
lines.push(formatHandoff(h));
|
|
4837
|
+
const qc = this.questionContext(held.task, held.projectId);
|
|
4838
|
+
if (qc)
|
|
4839
|
+
lines.push(qc);
|
|
4840
|
+
const required = this.requiredGates(held.projectId);
|
|
4841
|
+
if (required.length) {
|
|
4842
|
+
const st = gateStatus(this.gateRuns(held.projectId, held.task), required);
|
|
4843
|
+
lines.push(`[swarm] gates on ${held.task}: ${st.map((g) => `${g.gate} ${g.verdict ?? "not run"}`).join(", ")} \u2014 record with swarm_gate_record (rubric required)`);
|
|
4844
|
+
}
|
|
4845
|
+
} else if (project) {
|
|
4846
|
+
const active = this.claimRows(project.id).filter((c) => isActive(c, Date.now()));
|
|
4847
|
+
if (active.length)
|
|
4848
|
+
lines.push(`[swarm] ${project.name}: held by others \u2014 ${active.map((c) => `${c.task} (${c.owner})`).join(", ")}. Claim a task (swarm_claim) to get your own worktree.`);
|
|
4849
|
+
}
|
|
4850
|
+
const res = this.resources(project?.id).filter((r) => !r.released);
|
|
4851
|
+
if (res.length)
|
|
4852
|
+
lines.push(`[swarm] resources held: ${res.map((r) => `${r.name}${r.port ? `:${r.port}` : ""} (${r.owner})`).join(", ")} \u2014 their ports are protected; don't kill them`);
|
|
4853
|
+
const modes = this.rulesFor(toplevel);
|
|
4854
|
+
const on = [
|
|
4855
|
+
"shared_tree",
|
|
4856
|
+
"destructive_git",
|
|
4857
|
+
"pattern_kill",
|
|
4858
|
+
"protected_ports",
|
|
4859
|
+
"no_foreign_worktree",
|
|
4860
|
+
"claim_required_to_write"
|
|
4861
|
+
].filter((k) => modes[k] !== "off").map((k) => `${k}=${modes[k]}`);
|
|
4862
|
+
if (on.length && (lines.length || on.some((x) => x.endsWith("=deny"))))
|
|
4863
|
+
lines.push(`[swarm] rules: ${on.join(" ")}`);
|
|
4864
|
+
return lines.length ? lines.join(`
|
|
4865
|
+
`) : null;
|
|
4866
|
+
}
|
|
4867
|
+
rowToQuestion(r) {
|
|
4868
|
+
return {
|
|
4869
|
+
id: r.id,
|
|
4870
|
+
projectId: r.project_id,
|
|
4871
|
+
sessionId: r.session_id ?? null,
|
|
4872
|
+
task: r.task ?? null,
|
|
4873
|
+
text: r.text,
|
|
4874
|
+
options: JSON.parse(r.options || "[]"),
|
|
4875
|
+
askedBy: r.asked_by ?? null,
|
|
4876
|
+
createdAt: r.created_at,
|
|
4877
|
+
answer: r.answer ?? null,
|
|
4878
|
+
answeredBy: r.answered_by ?? null,
|
|
4879
|
+
answeredAt: r.answered_at ?? null,
|
|
4880
|
+
deliveredAt: r.delivered_at ?? null
|
|
4881
|
+
};
|
|
4882
|
+
}
|
|
4883
|
+
questions(opts = {}) {
|
|
4884
|
+
const where = ["kind = 'question'"];
|
|
4885
|
+
const args = [];
|
|
4886
|
+
if (opts.projectId) {
|
|
4887
|
+
where.push("project_id = ?");
|
|
4888
|
+
args.push(opts.projectId);
|
|
4889
|
+
}
|
|
4890
|
+
if (opts.sessionId) {
|
|
4891
|
+
where.push("session_id = ?");
|
|
4892
|
+
args.push(opts.sessionId);
|
|
4893
|
+
}
|
|
4894
|
+
if (opts.open)
|
|
4895
|
+
where.push("answered_at IS NULL");
|
|
4896
|
+
args.push(opts.limit ?? 100);
|
|
4897
|
+
return this.db.query(`SELECT * FROM messages WHERE ${where.join(" AND ")} ORDER BY id DESC LIMIT ?`).all(...args).map((r) => this.rowToQuestion(r));
|
|
4898
|
+
}
|
|
4899
|
+
question(id) {
|
|
4900
|
+
const r = this.db.query("SELECT * FROM messages WHERE id = ? AND kind = 'question'").get(id);
|
|
4901
|
+
return r ? this.rowToQuestion(r) : null;
|
|
4902
|
+
}
|
|
4903
|
+
ask(projectId, input) {
|
|
4904
|
+
if (!this.project(projectId))
|
|
4905
|
+
return { ok: false, error: "unknown project" };
|
|
4906
|
+
const v = validateQuestion(input.text, input.options);
|
|
4907
|
+
if (!v.ok)
|
|
4908
|
+
return { ok: false, error: v.reason };
|
|
4909
|
+
const sessionId = this.knownSession(input.sessionId ?? null);
|
|
4910
|
+
const task = (input.cwd ? this.heldClaimsWithWorktree().find((c) => isInside(input.cwd, c.worktree))?.task : null) ?? null;
|
|
4911
|
+
const createdAt = new Date().toISOString();
|
|
4912
|
+
const r = this.db.query(`INSERT INTO messages (project_id, session_id, task, kind, text, options, asked_by, created_at)
|
|
4913
|
+
VALUES (?, ?, ?, 'question', ?, ?, ?, ?)`).run(projectId, sessionId, task, v.text, JSON.stringify(v.options), input.askedBy ?? null, createdAt);
|
|
4914
|
+
const q = this.question(Number(r.lastInsertRowid));
|
|
4915
|
+
this.append({
|
|
4916
|
+
ts: createdAt,
|
|
4917
|
+
type: "question.asked",
|
|
4918
|
+
projectId,
|
|
4919
|
+
sessionId,
|
|
4920
|
+
payload: {
|
|
4921
|
+
id: q.id,
|
|
4922
|
+
task,
|
|
4923
|
+
text: v.text,
|
|
4924
|
+
options: v.options,
|
|
4925
|
+
summary: `question #${q.id}: ${v.text.slice(0, 120)}`
|
|
4926
|
+
}
|
|
4927
|
+
});
|
|
4928
|
+
this.touch();
|
|
4929
|
+
return { ok: true, question: q };
|
|
4930
|
+
}
|
|
4931
|
+
answer(id, text, by) {
|
|
4932
|
+
const q = this.question(id);
|
|
4933
|
+
if (!q)
|
|
4934
|
+
return { ok: false, error: `no question #${id}` };
|
|
4935
|
+
if (q.answer !== null)
|
|
4936
|
+
return {
|
|
4937
|
+
ok: false,
|
|
4938
|
+
error: `#${id} was already answered by ${q.answeredBy ?? "someone"}`
|
|
4939
|
+
};
|
|
4940
|
+
const a = typeof text === "string" ? text.trim() : "";
|
|
4941
|
+
if (!a)
|
|
4942
|
+
return { ok: false, error: "an answer is required" };
|
|
4943
|
+
const at = new Date().toISOString();
|
|
4944
|
+
this.db.query("UPDATE messages SET answer = ?, answered_by = ?, answered_at = ? WHERE id = ?").run(a, by, at, id);
|
|
4945
|
+
this.append({
|
|
4946
|
+
ts: at,
|
|
4947
|
+
type: "question.answered",
|
|
4948
|
+
projectId: q.projectId,
|
|
4949
|
+
sessionId: q.sessionId,
|
|
4950
|
+
payload: { id, task: q.task, answer: a, by, summary: `answer to #${id}: ${a.slice(0, 120)}` }
|
|
4951
|
+
});
|
|
4952
|
+
this.touch();
|
|
4953
|
+
return { ok: true, question: this.question(id) };
|
|
4954
|
+
}
|
|
4955
|
+
inbox(sessionId, opts = {}) {
|
|
4956
|
+
if (!sessionId)
|
|
4957
|
+
return [];
|
|
4958
|
+
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);
|
|
4959
|
+
const qs = rows.map((r) => this.rowToQuestion(r));
|
|
4960
|
+
if (qs.length && !opts.peek)
|
|
4961
|
+
this.db.query(`UPDATE messages SET delivered_at = ? WHERE id IN (${qs.map(() => "?").join(",")})`).run(new Date().toISOString(), ...qs.map((q) => q.id));
|
|
4962
|
+
return qs;
|
|
4963
|
+
}
|
|
4964
|
+
answerContext(sessionId) {
|
|
4965
|
+
return formatAnswers(this.inbox(sessionId));
|
|
4966
|
+
}
|
|
4967
|
+
questionContext(task, projectId) {
|
|
4968
|
+
if (!task)
|
|
3993
4969
|
return null;
|
|
3994
|
-
const
|
|
3995
|
-
const
|
|
3996
|
-
const
|
|
3997
|
-
|
|
3998
|
-
|
|
3999
|
-
|
|
4000
|
-
lines.push(`[swarm] you hold ${held.task} (${left}m left, renews while you work) in ${held.worktree}`);
|
|
4001
|
-
const h = this.latestHandoff(held.projectId, held.task);
|
|
4002
|
-
if (h)
|
|
4003
|
-
lines.push(formatHandoff(h));
|
|
4004
|
-
const required = this.requiredGates(held.projectId);
|
|
4005
|
-
if (required.length) {
|
|
4006
|
-
const st = gateStatus(this.gateRuns(held.projectId, held.task), required);
|
|
4007
|
-
lines.push(`[swarm] gates on ${held.task}: ${st.map((g) => `${g.gate} ${g.verdict ?? "not run"}`).join(", ")} \u2014 record with swarm_gate_record (rubric required)`);
|
|
4008
|
-
}
|
|
4009
|
-
} else if (project) {
|
|
4010
|
-
const active = this.claimRows(project.id).filter((c) => isActive(c, Date.now()));
|
|
4011
|
-
if (active.length)
|
|
4012
|
-
lines.push(`[swarm] ${project.name}: held by others \u2014 ${active.map((c) => `${c.task} (${c.owner})`).join(", ")}. Claim a task (swarm_claim) to get your own worktree.`);
|
|
4013
|
-
}
|
|
4014
|
-
const res = this.resources(project?.id).filter((r) => !r.released);
|
|
4015
|
-
if (res.length)
|
|
4016
|
-
lines.push(`[swarm] resources held: ${res.map((r) => `${r.name}${r.port ? `:${r.port}` : ""} (${r.owner})`).join(", ")} \u2014 their ports are protected; don't kill them`);
|
|
4017
|
-
const modes = this.rulesFor(toplevel);
|
|
4018
|
-
const on = [
|
|
4019
|
-
"shared_tree",
|
|
4020
|
-
"destructive_git",
|
|
4021
|
-
"pattern_kill",
|
|
4022
|
-
"protected_ports",
|
|
4023
|
-
"no_foreign_worktree",
|
|
4024
|
-
"claim_required_to_write"
|
|
4025
|
-
].filter((k) => modes[k] !== "off").map((k) => `${k}=${modes[k]}`);
|
|
4026
|
-
if (on.length && (lines.length || on.some((x) => x.endsWith("=deny"))))
|
|
4027
|
-
lines.push(`[swarm] rules: ${on.join(" ")}`);
|
|
4028
|
-
return lines.length ? lines.join(`
|
|
4970
|
+
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);
|
|
4971
|
+
const list = qs.map((r) => this.rowToQuestion(r));
|
|
4972
|
+
const parts = [formatAnswers(list), formatOpenQuestions(list)].filter(Boolean);
|
|
4973
|
+
if (list.some((q) => q.answer !== null))
|
|
4974
|
+
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);
|
|
4975
|
+
return parts.length ? parts.join(`
|
|
4029
4976
|
`) : null;
|
|
4977
|
+
}
|
|
4978
|
+
contextFor(cwd, sessionId) {
|
|
4979
|
+
const parts = [];
|
|
4980
|
+
const base = this.sessionContext(cwd);
|
|
4981
|
+
if (base)
|
|
4982
|
+
parts.push(base);
|
|
4983
|
+
const answers = this.answerContext(sessionId);
|
|
4984
|
+
if (answers)
|
|
4985
|
+
parts.push(answers);
|
|
4986
|
+
const open = formatOpenQuestions(this.questions({ sessionId: sessionId ?? undefined, open: true }));
|
|
4987
|
+
if (open && !base?.includes(open))
|
|
4988
|
+
parts.push(open);
|
|
4989
|
+
return { text: parts.length ? parts.join(`
|
|
4990
|
+
`) : null, parts };
|
|
4030
4991
|
}
|
|
4031
4992
|
rowToGate(r) {
|
|
4032
4993
|
return {
|
|
@@ -4048,6 +5009,187 @@ class Store {
|
|
|
4048
5009
|
gateStatusFor(runs, required) {
|
|
4049
5010
|
return gateStatus(runs, required);
|
|
4050
5011
|
}
|
|
5012
|
+
config(projectId) {
|
|
5013
|
+
const p = this.project(projectId);
|
|
5014
|
+
return loadConfig({ repoRoot: p?.root ?? null, home: this.home });
|
|
5015
|
+
}
|
|
5016
|
+
gateDefs(projectId) {
|
|
5017
|
+
const p = this.project(projectId);
|
|
5018
|
+
return p ? loadConfig({ repoRoot: p.root, home: this.home }).gates : null;
|
|
5019
|
+
}
|
|
5020
|
+
gateJobs = new Map;
|
|
5021
|
+
gateBatches = new Map;
|
|
5022
|
+
async awaitGates(projectId, task) {
|
|
5023
|
+
const prefix = `${projectId}:${task}:`;
|
|
5024
|
+
await Promise.all([
|
|
5025
|
+
...[...this.gateJobs].filter(([k]) => k.startsWith(prefix)).map(([, v]) => v),
|
|
5026
|
+
...this.gateBatches.get(`${projectId}:${task}`) ?? []
|
|
5027
|
+
]);
|
|
5028
|
+
}
|
|
5029
|
+
runGate(projectId, task, gate, opts = {}) {
|
|
5030
|
+
const p = this.project(projectId);
|
|
5031
|
+
if (!p)
|
|
5032
|
+
return { ok: false, reason: "unknown project" };
|
|
5033
|
+
const cfg = this.gateDefs(projectId);
|
|
5034
|
+
const def = cfg?.defs[gate];
|
|
5035
|
+
if (!def)
|
|
5036
|
+
return {
|
|
5037
|
+
ok: false,
|
|
5038
|
+
reason: `gate ${gate} has no command \u2014 add [gates.${gate}] cmd = "\u2026" to .swarm.toml, or record it with swarm gate record`
|
|
5039
|
+
};
|
|
5040
|
+
const claim = this.claims(projectId).find((c) => c.task === task && c.state === "held");
|
|
5041
|
+
const worktree2 = claim?.worktree;
|
|
5042
|
+
if (!worktree2 || !existsSync5(worktree2))
|
|
5043
|
+
return {
|
|
5044
|
+
ok: false,
|
|
5045
|
+
reason: `${task} has no held worktree to run ${gate} in \u2014 claim it first`
|
|
5046
|
+
};
|
|
5047
|
+
const cwd = def.cwd ? join8(worktree2, def.cwd) : worktree2;
|
|
5048
|
+
if (!existsSync5(cwd))
|
|
5049
|
+
return { ok: false, reason: `gate cwd ${cwd} does not exist` };
|
|
5050
|
+
const key = `${projectId}:${task}:${gate}`;
|
|
5051
|
+
if (this.gateJobs.has(key))
|
|
5052
|
+
return { ok: false, reason: `${gate} is already running on ${task}` };
|
|
5053
|
+
const slug = (x) => x.replace(/[^a-zA-Z0-9_.-]+/g, "-");
|
|
5054
|
+
const logDir = join8(this.home, "logs", projectId);
|
|
5055
|
+
mkdirSync4(logDir, { recursive: true });
|
|
5056
|
+
const log = join8(logDir, `gate-${slug(task)}-${slug(gate)}.log`);
|
|
5057
|
+
writeFileSync2(log, `$ ${def.cmd}
|
|
5058
|
+
# cwd ${cwd} \xB7 ${new Date().toISOString()}
|
|
5059
|
+
`);
|
|
5060
|
+
const fd = openSync3(log, "a");
|
|
5061
|
+
let proc;
|
|
5062
|
+
try {
|
|
5063
|
+
proc = Bun.spawn(["sh", "-c", def.cmd], {
|
|
5064
|
+
cwd,
|
|
5065
|
+
stdin: "ignore",
|
|
5066
|
+
stdout: fd,
|
|
5067
|
+
stderr: fd,
|
|
5068
|
+
env: {
|
|
5069
|
+
...process.env,
|
|
5070
|
+
SWARM_WORKTREE: worktree2,
|
|
5071
|
+
SWARM_TASK: task,
|
|
5072
|
+
SWARM_GATE: gate,
|
|
5073
|
+
CI: process.env.CI ?? "1"
|
|
5074
|
+
}
|
|
5075
|
+
});
|
|
5076
|
+
} catch (e) {
|
|
5077
|
+
closeSync(fd);
|
|
5078
|
+
const run2 = this.recordGate(projectId, {
|
|
5079
|
+
...executedGateInput(task, gate, def.cmd, {
|
|
5080
|
+
exitCode: null,
|
|
5081
|
+
durationMs: 0,
|
|
5082
|
+
output: e.message
|
|
5083
|
+
}),
|
|
5084
|
+
sessionId: opts.sessionId ?? null
|
|
5085
|
+
});
|
|
5086
|
+
return { ok: true, pid: 0, log, done: Promise.resolve(run2.ok ? run2.run : null) };
|
|
5087
|
+
}
|
|
5088
|
+
const started = Date.now();
|
|
5089
|
+
const reg = this.registerProcess({
|
|
5090
|
+
pid: proc.pid,
|
|
5091
|
+
projectId,
|
|
5092
|
+
sessionId: opts.sessionId ?? null,
|
|
5093
|
+
kind: "gate",
|
|
5094
|
+
name: `gate:${task}:${gate}`,
|
|
5095
|
+
cwd,
|
|
5096
|
+
cmd: def.cmd,
|
|
5097
|
+
owner: opts.owner ?? "daemon",
|
|
5098
|
+
log
|
|
5099
|
+
});
|
|
5100
|
+
let timedOut = false;
|
|
5101
|
+
const timer = setTimeout(() => {
|
|
5102
|
+
timedOut = true;
|
|
5103
|
+
try {
|
|
5104
|
+
proc.kill("SIGTERM");
|
|
5105
|
+
setTimeout(() => {
|
|
5106
|
+
try {
|
|
5107
|
+
proc.kill("SIGKILL");
|
|
5108
|
+
} catch {}
|
|
5109
|
+
}, 5000).unref();
|
|
5110
|
+
} catch {}
|
|
5111
|
+
}, def.timeout * 1000);
|
|
5112
|
+
const done = proc.exited.then((code) => {
|
|
5113
|
+
clearTimeout(timer);
|
|
5114
|
+
closeSync(fd);
|
|
5115
|
+
let output = "";
|
|
5116
|
+
try {
|
|
5117
|
+
output = readFileSync3(log, "utf8");
|
|
5118
|
+
} catch {}
|
|
5119
|
+
const input = executedGateInput(task, gate, def.cmd, {
|
|
5120
|
+
exitCode: timedOut ? null : code,
|
|
5121
|
+
timedOut,
|
|
5122
|
+
durationMs: Date.now() - started,
|
|
5123
|
+
output
|
|
5124
|
+
});
|
|
5125
|
+
const run2 = this.recordGate(projectId, { ...input, sessionId: opts.sessionId ?? null });
|
|
5126
|
+
if (reg.ok)
|
|
5127
|
+
this.processes(projectId);
|
|
5128
|
+
return run2.ok ? run2.run : null;
|
|
5129
|
+
}).finally(() => {
|
|
5130
|
+
this.gateJobs.delete(key);
|
|
5131
|
+
this.touch();
|
|
5132
|
+
});
|
|
5133
|
+
this.gateJobs.set(key, done);
|
|
5134
|
+
return { ok: true, pid: proc.pid, log, done };
|
|
5135
|
+
}
|
|
5136
|
+
async runGates(projectId, task, gates2, opts = {}) {
|
|
5137
|
+
const cfg = this.gateDefs(projectId);
|
|
5138
|
+
const names = gates2?.length ? gates2 : (cfg?.required ?? []).filter((g) => cfg?.defs[g]);
|
|
5139
|
+
const key = `${projectId}:${task}`;
|
|
5140
|
+
const batch = (async () => {
|
|
5141
|
+
const started = [];
|
|
5142
|
+
const skipped = [];
|
|
5143
|
+
const runs = [];
|
|
5144
|
+
for (const g of names) {
|
|
5145
|
+
const r = this.runGate(projectId, task, g, opts);
|
|
5146
|
+
if (!r.ok) {
|
|
5147
|
+
skipped.push({ gate: g, reason: r.reason });
|
|
5148
|
+
continue;
|
|
5149
|
+
}
|
|
5150
|
+
started.push(g);
|
|
5151
|
+
const run2 = await r.done;
|
|
5152
|
+
if (run2)
|
|
5153
|
+
runs.push(run2);
|
|
5154
|
+
}
|
|
5155
|
+
return { started, skipped, runs };
|
|
5156
|
+
})();
|
|
5157
|
+
const set = this.gateBatches.get(key) ?? new Set;
|
|
5158
|
+
set.add(batch);
|
|
5159
|
+
this.gateBatches.set(key, set);
|
|
5160
|
+
try {
|
|
5161
|
+
return await batch;
|
|
5162
|
+
} finally {
|
|
5163
|
+
set.delete(batch);
|
|
5164
|
+
if (!set.size)
|
|
5165
|
+
this.gateBatches.delete(key);
|
|
5166
|
+
}
|
|
5167
|
+
}
|
|
5168
|
+
autoGateAt = new Map;
|
|
5169
|
+
autoGate(event, sessionId, cwd) {
|
|
5170
|
+
const held = this.heldClaimsWithWorktree().find((c) => isInside(cwd, c.worktree));
|
|
5171
|
+
if (!held)
|
|
5172
|
+
return;
|
|
5173
|
+
const cfg = this.gateDefs(held.projectId);
|
|
5174
|
+
if (!cfg || cfg.auto === "off")
|
|
5175
|
+
return;
|
|
5176
|
+
if (cfg.auto === "session-end" && event !== "SessionEnd")
|
|
5177
|
+
return;
|
|
5178
|
+
if (!cfg.required.some((g) => cfg.defs[g]))
|
|
5179
|
+
return;
|
|
5180
|
+
const key = `${held.projectId}:${held.task}`;
|
|
5181
|
+
const now = Date.now();
|
|
5182
|
+
if (event === "Stop" && now - (this.autoGateAt.get(key) ?? 0) < 120000)
|
|
5183
|
+
return;
|
|
5184
|
+
this.autoGateAt.set(key, now);
|
|
5185
|
+
this.runGates(held.projectId, held.task, undefined, { sessionId, owner: "auto" }).then((r) => {
|
|
5186
|
+
if (!r.runs.length)
|
|
5187
|
+
return;
|
|
5188
|
+
const line = r.runs.map((x) => `${x.gate} ${x.verdict === "pass" ? "\u2713" : "\u2717"} (${x.rubric})`).join("; ");
|
|
5189
|
+
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);
|
|
5190
|
+
this.touch();
|
|
5191
|
+
});
|
|
5192
|
+
}
|
|
4051
5193
|
requiredGates(projectId) {
|
|
4052
5194
|
const p = this.project(projectId);
|
|
4053
5195
|
return p ? loadConfig({ repoRoot: p.root, home: this.home }).gates.required : [];
|
|
@@ -4113,8 +5255,8 @@ class Store {
|
|
|
4113
5255
|
hit = { tasks: e.tasks };
|
|
4114
5256
|
error = e.error;
|
|
4115
5257
|
} else {
|
|
4116
|
-
const path =
|
|
4117
|
-
if (!
|
|
5258
|
+
const path = join8(p.root, source);
|
|
5259
|
+
if (!existsSync5(path))
|
|
4118
5260
|
return { source, required: this.requiredGates(projectId), tasks: [] };
|
|
4119
5261
|
const mtime = statSync(path).mtimeMs;
|
|
4120
5262
|
let md = this.taskCache.get(projectId);
|
|
@@ -4156,6 +5298,18 @@ class Store {
|
|
|
4156
5298
|
return rules2;
|
|
4157
5299
|
}
|
|
4158
5300
|
evaluateTool(tool, input, sessionId, cwd, recordIncident = true) {
|
|
5301
|
+
if (BUDGET_ASK_TOOLS.has(tool) && cwd && existsSync5(cwd)) {
|
|
5302
|
+
const project = this.resolveProject(cwd);
|
|
5303
|
+
const b = this.budgetFor(project.id);
|
|
5304
|
+
if (b && b.status.level === "exceeded" && b.config.on_exceed === "ask") {
|
|
5305
|
+
const d = {
|
|
5306
|
+
action: "ask",
|
|
5307
|
+
rule: "budget",
|
|
5308
|
+
reason: `${budgetMessage(b.status, project.name)} \u2014 [budget] on_exceed = "ask": confirm each change, or raise the ceiling in .swarm.toml`
|
|
5309
|
+
};
|
|
5310
|
+
return { decision: d, display: input.command ?? input.file_path ?? tool };
|
|
5311
|
+
}
|
|
5312
|
+
}
|
|
4159
5313
|
const isWrite = WRITE_TOOLS.has(tool) && typeof input.file_path === "string";
|
|
4160
5314
|
const cmd = tool === "Bash" ? input.command : undefined;
|
|
4161
5315
|
const current = { id: sessionId, cwd, toplevel: this.toplevel(cwd) };
|
|
@@ -4235,7 +5389,7 @@ class Store {
|
|
|
4235
5389
|
return this.openIncident(d, cwd, id, cmd);
|
|
4236
5390
|
}
|
|
4237
5391
|
openIncident(d, cwd, sessionId, command) {
|
|
4238
|
-
const project = cwd &&
|
|
5392
|
+
const project = cwd && existsSync5(cwd) ? this.resolveProject(cwd) : null;
|
|
4239
5393
|
this.append({
|
|
4240
5394
|
ts: new Date().toISOString(),
|
|
4241
5395
|
type: "incident.opened",
|
|
@@ -4286,7 +5440,7 @@ class Store {
|
|
|
4286
5440
|
}
|
|
4287
5441
|
}
|
|
4288
5442
|
const report = dryRunRules(calls, modes, {
|
|
4289
|
-
toplevel: (cwd) => cwd &&
|
|
5443
|
+
toplevel: (cwd) => cwd && existsSync5(cwd) ? this.toplevel(cwd) : null,
|
|
4290
5444
|
claims: this.heldWorktrees()
|
|
4291
5445
|
});
|
|
4292
5446
|
return { ...report, modes };
|
|
@@ -4301,8 +5455,8 @@ class Store {
|
|
|
4301
5455
|
loadPricing() {
|
|
4302
5456
|
this.prices = { ...PRICES };
|
|
4303
5457
|
for (const f of ["pricing.litellm.json", "pricing.json"]) {
|
|
4304
|
-
const p =
|
|
4305
|
-
if (!
|
|
5458
|
+
const p = join8(this.home, f);
|
|
5459
|
+
if (!existsSync5(p))
|
|
4306
5460
|
continue;
|
|
4307
5461
|
try {
|
|
4308
5462
|
const j = JSON.parse(readFileSync3(p, "utf8"));
|
|
@@ -4317,7 +5471,7 @@ class Store {
|
|
|
4317
5471
|
throw new Error(`pricing fetch ${r.status}`);
|
|
4318
5472
|
const j = await r.json();
|
|
4319
5473
|
const slim = Object.fromEntries(Object.entries(j).filter(([k, v]) => typeof v.input_cost_per_token === "number" && !k.includes("/")));
|
|
4320
|
-
writeFileSync2(
|
|
5474
|
+
writeFileSync2(join8(this.home, "pricing.litellm.json"), JSON.stringify(slim, null, 1));
|
|
4321
5475
|
this.loadPricing();
|
|
4322
5476
|
this.reprice();
|
|
4323
5477
|
}
|
|
@@ -4461,11 +5615,13 @@ class Store {
|
|
|
4461
5615
|
if (typeof raw2.cwd === "string")
|
|
4462
5616
|
this.autoRenewFor(typeof raw2.session_id === "string" ? raw2.session_id : null, raw2.cwd);
|
|
4463
5617
|
const cwd = typeof raw2.cwd === "string" ? raw2.cwd : process.cwd();
|
|
4464
|
-
const project =
|
|
5618
|
+
const project = existsSync5(cwd) ? this.resolveProject(cwd) : null;
|
|
4465
5619
|
const e = this.append(normalizeHook(event, raw2, project?.id ?? "p_unknown"));
|
|
4466
5620
|
if ((event === "Stop" || event === "SessionEnd") && e.sessionId) {
|
|
4467
|
-
if (
|
|
5621
|
+
if (existsSync5(cwd)) {
|
|
4468
5622
|
this.autoHandoff(e.sessionId, cwd);
|
|
5623
|
+
this.autoGate(event, e.sessionId, cwd);
|
|
5624
|
+
}
|
|
4469
5625
|
this.rememberSession(e.sessionId);
|
|
4470
5626
|
}
|
|
4471
5627
|
if (e.sessionId && typeof raw2.transcript_path === "string") {
|
|
@@ -4488,6 +5644,15 @@ class Store {
|
|
|
4488
5644
|
"claim.renewed",
|
|
4489
5645
|
"claim.released",
|
|
4490
5646
|
"claim.orphaned",
|
|
5647
|
+
"worktree.bootstrapped",
|
|
5648
|
+
"worktree.created",
|
|
5649
|
+
"worktree.removed",
|
|
5650
|
+
"pr.opened",
|
|
5651
|
+
"question.asked",
|
|
5652
|
+
"question.answered",
|
|
5653
|
+
"dispatch.queued",
|
|
5654
|
+
"dispatch.started",
|
|
5655
|
+
"dispatch.finished",
|
|
4491
5656
|
"gate.recorded",
|
|
4492
5657
|
"handoff.recorded",
|
|
4493
5658
|
"incident.opened",
|
|
@@ -4499,7 +5664,7 @@ class Store {
|
|
|
4499
5664
|
return;
|
|
4500
5665
|
const p = e.payload;
|
|
4501
5666
|
const row = this.db.query("SELECT id, tool_counts FROM sessions WHERE id = ?").get(e.sessionId);
|
|
4502
|
-
const branch = p.cwd &&
|
|
5667
|
+
const branch = p.cwd && existsSync5(p.cwd) ? currentBranch(p.cwd) : null;
|
|
4503
5668
|
if (!row) {
|
|
4504
5669
|
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
5670
|
}
|
|
@@ -4519,7 +5684,7 @@ class Store {
|
|
|
4519
5684
|
const size = statSync(path).size;
|
|
4520
5685
|
if (size <= offset)
|
|
4521
5686
|
return null;
|
|
4522
|
-
const fd =
|
|
5687
|
+
const fd = openSync3(path, "r");
|
|
4523
5688
|
const buf = Buffer.alloc(size - offset);
|
|
4524
5689
|
readSync(fd, buf, 0, buf.length, offset);
|
|
4525
5690
|
closeSync(fd);
|
|
@@ -4569,12 +5734,12 @@ class Store {
|
|
|
4569
5734
|
}
|
|
4570
5735
|
tailSession(sessionId) {
|
|
4571
5736
|
const s = this.db.query("SELECT transcript_path FROM sessions WHERE id = ?").get(sessionId);
|
|
4572
|
-
if (!s?.transcript_path || !
|
|
5737
|
+
if (!s?.transcript_path || !existsSync5(s.transcript_path))
|
|
4573
5738
|
return 0;
|
|
4574
5739
|
let n = this.tailFile(s.transcript_path, sessionId, null);
|
|
4575
|
-
const subDir =
|
|
5740
|
+
const subDir = join8(dirname2(s.transcript_path), basename(s.transcript_path, ".jsonl"), "subagents");
|
|
4576
5741
|
for (const f of this.subagentFiles(subDir)) {
|
|
4577
|
-
n += this.tailFile(
|
|
5742
|
+
n += this.tailFile(join8(subDir, f), sessionId, f.replace(/^agent-|\.jsonl$/g, ""));
|
|
4578
5743
|
}
|
|
4579
5744
|
return n;
|
|
4580
5745
|
}
|
|
@@ -4606,7 +5771,7 @@ class Store {
|
|
|
4606
5771
|
return n;
|
|
4607
5772
|
}
|
|
4608
5773
|
codexRoot() {
|
|
4609
|
-
return process.env.SWARM_CODEX_DIR ??
|
|
5774
|
+
return process.env.SWARM_CODEX_DIR ?? join8(homedir3(), ".codex", "sessions");
|
|
4610
5775
|
}
|
|
4611
5776
|
codexRolloutFiles(sinceMs) {
|
|
4612
5777
|
const root = this.codexRoot();
|
|
@@ -4621,18 +5786,18 @@ class Store {
|
|
|
4621
5786
|
for (const y of ls(root)) {
|
|
4622
5787
|
if (!/^\d{4}$/.test(y))
|
|
4623
5788
|
continue;
|
|
4624
|
-
for (const m of ls(
|
|
5789
|
+
for (const m of ls(join8(root, y))) {
|
|
4625
5790
|
if (!/^\d\d$/.test(m))
|
|
4626
5791
|
continue;
|
|
4627
|
-
for (const day of ls(
|
|
5792
|
+
for (const day of ls(join8(root, y, m))) {
|
|
4628
5793
|
if (!/^\d\d$/.test(day))
|
|
4629
5794
|
continue;
|
|
4630
5795
|
if (Date.parse(`${y}-${m}-${day}T23:59:59Z`) < sinceMs)
|
|
4631
5796
|
continue;
|
|
4632
|
-
const dir =
|
|
5797
|
+
const dir = join8(root, y, m, day);
|
|
4633
5798
|
for (const f of ls(dir)) {
|
|
4634
5799
|
if (f.startsWith("rollout-") && f.endsWith(".jsonl"))
|
|
4635
|
-
out.push(
|
|
5800
|
+
out.push(join8(dir, f));
|
|
4636
5801
|
}
|
|
4637
5802
|
}
|
|
4638
5803
|
}
|
|
@@ -4640,7 +5805,7 @@ class Store {
|
|
|
4640
5805
|
return out;
|
|
4641
5806
|
}
|
|
4642
5807
|
tailCodex(windowMs = 3 * 24 * 60 * 60000) {
|
|
4643
|
-
if (!
|
|
5808
|
+
if (!existsSync5(this.codexRoot()))
|
|
4644
5809
|
return 0;
|
|
4645
5810
|
let n = 0;
|
|
4646
5811
|
for (const path of this.codexRolloutFiles(Date.now() - windowMs)) {
|
|
@@ -4649,12 +5814,12 @@ class Store {
|
|
|
4649
5814
|
return n;
|
|
4650
5815
|
}
|
|
4651
5816
|
grokRoot() {
|
|
4652
|
-
return process.env.SWARM_GROK_DIR ??
|
|
5817
|
+
return process.env.SWARM_GROK_DIR ?? join8(homedir3(), ".grok", "sessions");
|
|
4653
5818
|
}
|
|
4654
5819
|
grokSummary = new Map;
|
|
4655
5820
|
tailGrok(windowMs = 3 * 24 * 60 * 60000) {
|
|
4656
5821
|
const root = this.grokRoot();
|
|
4657
|
-
if (!
|
|
5822
|
+
if (!existsSync5(root))
|
|
4658
5823
|
return 0;
|
|
4659
5824
|
const since = Date.now() - windowMs;
|
|
4660
5825
|
const ls = (p) => {
|
|
@@ -4674,10 +5839,10 @@ class Store {
|
|
|
4674
5839
|
} catch {
|
|
4675
5840
|
cwd = enc;
|
|
4676
5841
|
}
|
|
4677
|
-
const cwdDir =
|
|
5842
|
+
const cwdDir = join8(root, enc);
|
|
4678
5843
|
for (const sid of ls(cwdDir)) {
|
|
4679
|
-
const path =
|
|
4680
|
-
if (!
|
|
5844
|
+
const path = join8(cwdDir, sid, "updates.jsonl");
|
|
5845
|
+
if (!existsSync5(path))
|
|
4681
5846
|
continue;
|
|
4682
5847
|
try {
|
|
4683
5848
|
if (statSync(path).mtimeMs < since)
|
|
@@ -4685,7 +5850,7 @@ class Store {
|
|
|
4685
5850
|
} catch {
|
|
4686
5851
|
continue;
|
|
4687
5852
|
}
|
|
4688
|
-
const sumPath =
|
|
5853
|
+
const sumPath = join8(cwdDir, sid, "summary.json");
|
|
4689
5854
|
let title;
|
|
4690
5855
|
let fresh = false;
|
|
4691
5856
|
try {
|
|
@@ -4736,9 +5901,9 @@ class Store {
|
|
|
4736
5901
|
ensureAgentSession(sid, agent, cwd, mtime) {
|
|
4737
5902
|
if (this.db.query("SELECT 1 FROM sessions WHERE id = ?").get(sid))
|
|
4738
5903
|
return;
|
|
4739
|
-
const project = cwd &&
|
|
5904
|
+
const project = cwd && existsSync5(cwd) ? this.resolveProject(cwd) : null;
|
|
4740
5905
|
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 &&
|
|
5906
|
+
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
5907
|
}
|
|
4743
5908
|
claimRows(projectId) {
|
|
4744
5909
|
return this.db.query("SELECT * FROM claims WHERE project_id = ?").all(projectId).map((r) => ({
|
|
@@ -4772,7 +5937,7 @@ class Store {
|
|
|
4772
5937
|
worktreePath(projectId, task) {
|
|
4773
5938
|
const slug = (x) => x.replace(/[^a-zA-Z0-9._-]+/g, "-").toLowerCase();
|
|
4774
5939
|
const p = this.project(projectId);
|
|
4775
|
-
return
|
|
5940
|
+
return join8(this.home, "worktrees", slug(p?.name ?? projectId), slug(task));
|
|
4776
5941
|
}
|
|
4777
5942
|
claim(projectId, task, owner, baseRef = "HEAD") {
|
|
4778
5943
|
const p = this.project(projectId);
|
|
@@ -4783,11 +5948,11 @@ class Store {
|
|
|
4783
5948
|
if (!decision.ok)
|
|
4784
5949
|
return { ok: false, error: claimRefusalMessage(decision, task) };
|
|
4785
5950
|
const branch = `task/${task}`;
|
|
4786
|
-
const
|
|
4787
|
-
if (
|
|
4788
|
-
return { ok: false, error: `${
|
|
4789
|
-
|
|
4790
|
-
const created = worktreeAdd(p.root,
|
|
5951
|
+
const worktree2 = this.worktreePath(projectId, task);
|
|
5952
|
+
if (existsSync5(worktree2))
|
|
5953
|
+
return { ok: false, error: `${worktree2} already exists; release ${task} first` };
|
|
5954
|
+
mkdirSync4(dirname2(worktree2), { recursive: true });
|
|
5955
|
+
const created = worktreeAdd(p.root, worktree2, branch, baseRef);
|
|
4791
5956
|
if (!created)
|
|
4792
5957
|
return { ok: false, error: `git worktree add failed for ${task}` };
|
|
4793
5958
|
this.invalidateWorktrees(projectId);
|
|
@@ -4804,7 +5969,54 @@ class Store {
|
|
|
4804
5969
|
sessionId: null,
|
|
4805
5970
|
payload: { task, owner, worktree: created, branch, summary: `claim ${task} by ${owner}` }
|
|
4806
5971
|
});
|
|
4807
|
-
|
|
5972
|
+
const bootstrap = this.bootstrapWorktree(projectId, task, p.root, created);
|
|
5973
|
+
return { ok: true, task, owner, worktree: created, branch, expiresAt, bootstrap };
|
|
5974
|
+
}
|
|
5975
|
+
bootstraps = new Map;
|
|
5976
|
+
bootstrapWorktree(projectId, task, repoRoot, worktree2) {
|
|
5977
|
+
const plan = planBootstrap(loadConfig({ repoRoot, home: this.home }), repoRoot, worktree2);
|
|
5978
|
+
if (!needsBootstrap(plan))
|
|
5979
|
+
return null;
|
|
5980
|
+
const job = runBootstrap(plan, { worktree: worktree2, home: this.home, projectId, task });
|
|
5981
|
+
const done = job.done.then((o) => {
|
|
5982
|
+
this.bootstraps.delete(worktree2);
|
|
5983
|
+
const ts = new Date().toISOString();
|
|
5984
|
+
const ok = !o.setup || o.setup.exitCode === 0;
|
|
5985
|
+
this.append({
|
|
5986
|
+
ts,
|
|
5987
|
+
type: "worktree.bootstrapped",
|
|
5988
|
+
projectId,
|
|
5989
|
+
sessionId: null,
|
|
5990
|
+
payload: {
|
|
5991
|
+
task,
|
|
5992
|
+
worktree: worktree2,
|
|
5993
|
+
ok,
|
|
5994
|
+
log: job.log,
|
|
5995
|
+
...o,
|
|
5996
|
+
summary: `bootstrap ${task}: ${summarizeBootstrap(o)}`
|
|
5997
|
+
}
|
|
5998
|
+
});
|
|
5999
|
+
if (!ok)
|
|
6000
|
+
this.append({
|
|
6001
|
+
ts,
|
|
6002
|
+
type: "incident.opened",
|
|
6003
|
+
projectId,
|
|
6004
|
+
sessionId: null,
|
|
6005
|
+
payload: {
|
|
6006
|
+
rule: "bootstrap_failed",
|
|
6007
|
+
action: "failed",
|
|
6008
|
+
command: o.setup?.command ?? "",
|
|
6009
|
+
reason: `worktree setup for ${task} exited ${o.setup?.exitCode} \u2014 see ${job.log}`
|
|
6010
|
+
}
|
|
6011
|
+
});
|
|
6012
|
+
this.touch();
|
|
6013
|
+
return o;
|
|
6014
|
+
});
|
|
6015
|
+
this.bootstraps.set(worktree2, done);
|
|
6016
|
+
return job.log;
|
|
6017
|
+
}
|
|
6018
|
+
awaitBootstrap(worktree2) {
|
|
6019
|
+
return this.bootstraps.get(worktree2) ?? Promise.resolve();
|
|
4808
6020
|
}
|
|
4809
6021
|
autoRenewAt = new Map;
|
|
4810
6022
|
autoRenewFor(sessionId, cwd) {
|
|
@@ -4847,7 +6059,7 @@ class Store {
|
|
|
4847
6059
|
for (const c of this.claimRows(p.id)) {
|
|
4848
6060
|
if (c.state !== "held" || isActive(c, now))
|
|
4849
6061
|
continue;
|
|
4850
|
-
const exists = c.worktree ?
|
|
6062
|
+
const exists = c.worktree ? existsSync5(c.worktree) : false;
|
|
4851
6063
|
const work = exists ? heldWork(c.worktree) : null;
|
|
4852
6064
|
if (reapAction(c, now, exists, work) !== "keep-orphaned")
|
|
4853
6065
|
continue;
|
|
@@ -4902,18 +6114,18 @@ class Store {
|
|
|
4902
6114
|
const row = this.db.query("SELECT * FROM claims WHERE project_id = ? AND task = ?").get(projectId, task);
|
|
4903
6115
|
if (!row)
|
|
4904
6116
|
return { ok: false, error: `no claim on ${task}` };
|
|
4905
|
-
const
|
|
4906
|
-
if (
|
|
4907
|
-
const work = heldWork(
|
|
6117
|
+
const worktree2 = row.worktree ?? "";
|
|
6118
|
+
if (worktree2 && existsSync5(worktree2)) {
|
|
6119
|
+
const work = heldWork(worktree2);
|
|
4908
6120
|
const can = canRelease(work, force);
|
|
4909
6121
|
if (!can.ok)
|
|
4910
6122
|
return {
|
|
4911
6123
|
ok: false,
|
|
4912
|
-
error: releaseRefusalMessage(can,
|
|
6124
|
+
error: releaseRefusalMessage(can, worktree2),
|
|
4913
6125
|
refused: can.reason
|
|
4914
6126
|
};
|
|
4915
|
-
if (p && !worktreeRemove(p.root,
|
|
4916
|
-
return { ok: false, error: `git worktree remove failed for ${
|
|
6127
|
+
if (p && !worktreeRemove(p.root, worktree2, force))
|
|
6128
|
+
return { ok: false, error: `git worktree remove failed for ${worktree2}` };
|
|
4917
6129
|
this.invalidateWorktrees(projectId);
|
|
4918
6130
|
}
|
|
4919
6131
|
const releasedAt = new Date().toISOString();
|
|
@@ -4938,7 +6150,7 @@ class Store {
|
|
|
4938
6150
|
continue;
|
|
4939
6151
|
if (isActive({ ...c, state: "held" }, now))
|
|
4940
6152
|
continue;
|
|
4941
|
-
const exists = c.worktree ?
|
|
6153
|
+
const exists = c.worktree ? existsSync5(c.worktree) : false;
|
|
4942
6154
|
const work = exists ? heldWork(c.worktree) : null;
|
|
4943
6155
|
const action = reapAction({ ...c, state: "held" }, now, exists, work);
|
|
4944
6156
|
if (action === "not-expired")
|
|
@@ -5029,6 +6241,141 @@ class Store {
|
|
|
5029
6241
|
this.wtInflight.set(projectId, run2);
|
|
5030
6242
|
return run2;
|
|
5031
6243
|
}
|
|
6244
|
+
createWorktree(projectId, name, baseRef = "HEAD", branch) {
|
|
6245
|
+
const p = this.project(projectId);
|
|
6246
|
+
if (!p)
|
|
6247
|
+
return { ok: false, error: "unknown project" };
|
|
6248
|
+
const slug = name.replace(/[^a-zA-Z0-9._-]+/g, "-").toLowerCase();
|
|
6249
|
+
if (!slug || slug === "." || slug === "..")
|
|
6250
|
+
return { ok: false, error: "bad worktree name" };
|
|
6251
|
+
const path = this.worktreePath(projectId, slug);
|
|
6252
|
+
if (existsSync5(path))
|
|
6253
|
+
return { ok: false, error: `${path} already exists` };
|
|
6254
|
+
mkdirSync4(dirname2(path), { recursive: true });
|
|
6255
|
+
const br = branch?.trim() || `wt/${slug}`;
|
|
6256
|
+
const created = worktreeAdd(p.root, path, br, baseRef);
|
|
6257
|
+
if (!created)
|
|
6258
|
+
return { ok: false, error: `git worktree add failed for ${name}` };
|
|
6259
|
+
this.invalidateWorktrees(projectId);
|
|
6260
|
+
this.append({
|
|
6261
|
+
ts: new Date().toISOString(),
|
|
6262
|
+
type: "worktree.created",
|
|
6263
|
+
projectId,
|
|
6264
|
+
sessionId: null,
|
|
6265
|
+
payload: { name: slug, worktree: created, branch: br, summary: `worktree ${slug} created` }
|
|
6266
|
+
});
|
|
6267
|
+
const bootstrap = this.bootstrapWorktree(projectId, slug, p.root, created);
|
|
6268
|
+
return { ok: true, name: slug, worktree: created, branch: br, bootstrap };
|
|
6269
|
+
}
|
|
6270
|
+
findWorktree(projectId, ref) {
|
|
6271
|
+
const wts = this.wtCache.get(projectId)?.v ?? [];
|
|
6272
|
+
const abs = ref.startsWith("/") ? ref.replace(/\/+$/, "") : null;
|
|
6273
|
+
return wts.find((w) => w.path === abs) ?? wts.find((w) => !w.main && basename(w.path) === ref) ?? wts.find((w) => w.branch === ref) ?? null;
|
|
6274
|
+
}
|
|
6275
|
+
async removeWorktree(projectId, ref, force = false) {
|
|
6276
|
+
const p = this.project(projectId);
|
|
6277
|
+
if (!p)
|
|
6278
|
+
return { ok: false, error: "unknown project" };
|
|
6279
|
+
await this.refreshWorktrees(projectId);
|
|
6280
|
+
const w = this.findWorktree(projectId, ref);
|
|
6281
|
+
if (!w)
|
|
6282
|
+
return { ok: false, error: `no worktree ${ref} in ${p.name}` };
|
|
6283
|
+
const held = this.claims(projectId).find((c) => c.state === "held" && c.worktree === w.path);
|
|
6284
|
+
const can = canRemoveWorktree(w, held?.task ?? null, force);
|
|
6285
|
+
if (!can.ok)
|
|
6286
|
+
return {
|
|
6287
|
+
ok: false,
|
|
6288
|
+
error: removeRefusalMessage(can.reason, w.path, held?.task),
|
|
6289
|
+
refused: can.reason
|
|
6290
|
+
};
|
|
6291
|
+
if (!worktreeRemove(p.root, w.path, force))
|
|
6292
|
+
return { ok: false, error: `git worktree remove failed for ${w.path}` };
|
|
6293
|
+
this.invalidateWorktrees(projectId);
|
|
6294
|
+
this.append({
|
|
6295
|
+
ts: new Date().toISOString(),
|
|
6296
|
+
type: "worktree.removed",
|
|
6297
|
+
projectId,
|
|
6298
|
+
sessionId: null,
|
|
6299
|
+
payload: {
|
|
6300
|
+
worktree: w.path,
|
|
6301
|
+
branch: w.branch,
|
|
6302
|
+
force,
|
|
6303
|
+
summary: `worktree ${basename(w.path)} removed`
|
|
6304
|
+
}
|
|
6305
|
+
});
|
|
6306
|
+
return { ok: true, worktree: w.path };
|
|
6307
|
+
}
|
|
6308
|
+
async gcWorktrees(projectId, apply = false) {
|
|
6309
|
+
await this.refreshWorktrees(projectId);
|
|
6310
|
+
const plan = planGc(this.wtCache.get(projectId)?.v ?? [], this.claims(projectId));
|
|
6311
|
+
const removed = [];
|
|
6312
|
+
if (apply)
|
|
6313
|
+
for (const c of plan) {
|
|
6314
|
+
if (!c.removable)
|
|
6315
|
+
continue;
|
|
6316
|
+
const r = await this.removeWorktree(projectId, c.path, false);
|
|
6317
|
+
if (r.ok)
|
|
6318
|
+
removed.push(c.path);
|
|
6319
|
+
}
|
|
6320
|
+
return { candidates: plan, removed };
|
|
6321
|
+
}
|
|
6322
|
+
openWorktree(projectId, ref) {
|
|
6323
|
+
const p = this.project(projectId);
|
|
6324
|
+
if (!p)
|
|
6325
|
+
return { ok: false, error: "unknown project" };
|
|
6326
|
+
const w = this.findWorktree(projectId, ref);
|
|
6327
|
+
if (!w)
|
|
6328
|
+
return { ok: false, error: `no worktree ${ref}` };
|
|
6329
|
+
const cfg = loadConfig({ repoRoot: p.root, home: this.home }).worktree.open;
|
|
6330
|
+
const cmd = cfg ? ["sh", "-c", cfg.replace(/\{path\}/g, `'${w.path.replace(/'/g, "'\\''")}'`)] : [
|
|
6331
|
+
process.platform === "darwin" ? "open" : process.platform === "win32" ? "explorer" : "xdg-open",
|
|
6332
|
+
w.path
|
|
6333
|
+
];
|
|
6334
|
+
try {
|
|
6335
|
+
Bun.spawn(cmd, { stdin: "ignore", stdout: "ignore", stderr: "ignore" }).unref();
|
|
6336
|
+
return { ok: true, worktree: w.path, command: cmd.join(" ") };
|
|
6337
|
+
} catch (e) {
|
|
6338
|
+
return { ok: false, error: e.message };
|
|
6339
|
+
}
|
|
6340
|
+
}
|
|
6341
|
+
async prDraftFor(projectId, ref) {
|
|
6342
|
+
const p = this.project(projectId);
|
|
6343
|
+
if (!p)
|
|
6344
|
+
return { ok: false, error: "unknown project" };
|
|
6345
|
+
await this.refreshWorktrees(projectId);
|
|
6346
|
+
const claim = this.claims(projectId).find((c) => c.task === ref && c.state === "held");
|
|
6347
|
+
const w = this.findWorktree(projectId, claim?.worktree ?? ref);
|
|
6348
|
+
if (!w)
|
|
6349
|
+
return { ok: false, error: `no worktree or held task ${ref}` };
|
|
6350
|
+
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);
|
|
6351
|
+
const taskRow = this.tasks(projectId)?.tasks.find((t) => t.id === task) ?? null;
|
|
6352
|
+
const handoff = this.latestHandoff(projectId, task);
|
|
6353
|
+
const required = this.requiredGates(projectId);
|
|
6354
|
+
const gates2 = required.length ? this.gateStatusFor(this.gateRuns(projectId, task), required).map((g) => ({
|
|
6355
|
+
gate: g.gate,
|
|
6356
|
+
verdict: g.verdict
|
|
6357
|
+
})) : [];
|
|
6358
|
+
const diff = await worktreeDiff(p.root, w.path);
|
|
6359
|
+
const d = prDraft({
|
|
6360
|
+
task,
|
|
6361
|
+
title: taskRow?.title ?? null,
|
|
6362
|
+
handoff,
|
|
6363
|
+
gates: gates2,
|
|
6364
|
+
files: diff.files,
|
|
6365
|
+
commits: diff.commits
|
|
6366
|
+
});
|
|
6367
|
+
return { ok: true, task, worktree: w, ...d, diff };
|
|
6368
|
+
}
|
|
6369
|
+
recordPrOpened(projectId, task, worktree2, url) {
|
|
6370
|
+
this.append({
|
|
6371
|
+
ts: new Date().toISOString(),
|
|
6372
|
+
type: "pr.opened",
|
|
6373
|
+
projectId,
|
|
6374
|
+
sessionId: null,
|
|
6375
|
+
payload: { task, worktree: worktree2, url, summary: `PR opened for ${task}: ${url}` }
|
|
6376
|
+
});
|
|
6377
|
+
this.touch();
|
|
6378
|
+
}
|
|
5032
6379
|
invalidateWorktrees(projectId) {
|
|
5033
6380
|
if (projectId)
|
|
5034
6381
|
this.wtCache.delete(projectId);
|
|
@@ -5098,6 +6445,56 @@ class Store {
|
|
|
5098
6445
|
};
|
|
5099
6446
|
});
|
|
5100
6447
|
}
|
|
6448
|
+
projectSpend(projectId) {
|
|
6449
|
+
const dayStart = new Date;
|
|
6450
|
+
dayStart.setHours(0, 0, 0, 0);
|
|
6451
|
+
const weekStart = new Date(Date.now() - 7 * 86400000).toISOString();
|
|
6452
|
+
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;
|
|
6453
|
+
return { today: q(dayStart.toISOString()), week: q(weekStart) };
|
|
6454
|
+
}
|
|
6455
|
+
budgetFor(projectId) {
|
|
6456
|
+
const cfg = this.config(projectId).budget;
|
|
6457
|
+
if (!cfg.daily && !cfg.weekly)
|
|
6458
|
+
return null;
|
|
6459
|
+
return { status: budgetStatus(this.projectSpend(projectId), cfg), config: cfg };
|
|
6460
|
+
}
|
|
6461
|
+
budgetNotified = new Map;
|
|
6462
|
+
budgetListeners = new Set;
|
|
6463
|
+
onBudgetStop(fn) {
|
|
6464
|
+
this.budgetListeners.add(fn);
|
|
6465
|
+
}
|
|
6466
|
+
checkBudgets() {
|
|
6467
|
+
const day = new Date().toDateString();
|
|
6468
|
+
const out = [];
|
|
6469
|
+
for (const p of this.projects()) {
|
|
6470
|
+
const b = this.budgetFor(p.id);
|
|
6471
|
+
if (!b || b.status.level === "ok")
|
|
6472
|
+
continue;
|
|
6473
|
+
out.push({ projectId: p.id, status: b.status });
|
|
6474
|
+
const key = `${day}:${b.status.level}`;
|
|
6475
|
+
if (this.budgetNotified.get(p.id) === key)
|
|
6476
|
+
continue;
|
|
6477
|
+
this.budgetNotified.set(p.id, key);
|
|
6478
|
+
const msg = budgetMessage(b.status, p.name);
|
|
6479
|
+
this.append({
|
|
6480
|
+
ts: new Date().toISOString(),
|
|
6481
|
+
type: "incident.opened",
|
|
6482
|
+
projectId: p.id,
|
|
6483
|
+
sessionId: null,
|
|
6484
|
+
payload: {
|
|
6485
|
+
rule: "budget",
|
|
6486
|
+
action: b.status.level === "exceeded" ? b.config.on_exceed : "warn",
|
|
6487
|
+
command: `${b.status.kind} budget`,
|
|
6488
|
+
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`
|
|
6489
|
+
}
|
|
6490
|
+
});
|
|
6491
|
+
if (b.status.level === "exceeded" && b.config.on_exceed === "stop")
|
|
6492
|
+
for (const fn of this.budgetListeners)
|
|
6493
|
+
fn(p.id, b.status);
|
|
6494
|
+
this.touch();
|
|
6495
|
+
}
|
|
6496
|
+
return out;
|
|
6497
|
+
}
|
|
5101
6498
|
spend() {
|
|
5102
6499
|
const dayStart = new Date;
|
|
5103
6500
|
dayStart.setHours(0, 0, 0, 0);
|
|
@@ -5552,6 +6949,7 @@ class Store {
|
|
|
5552
6949
|
processes: this.memoised("processes", 5000, () => this.processes()),
|
|
5553
6950
|
incidents: this.memoised("incidents", 30000, () => this.incidents(20, { open: true })),
|
|
5554
6951
|
openIncidents: this.memoised("openIncidents", 30000, () => this.openIncidents()),
|
|
6952
|
+
questions: this.questions({ open: true, limit: 50 }),
|
|
5555
6953
|
resources: this.resources(),
|
|
5556
6954
|
seq: this.seq()
|
|
5557
6955
|
};
|
|
@@ -5627,13 +7025,13 @@ function rowToEvent(r) {
|
|
|
5627
7025
|
}
|
|
5628
7026
|
|
|
5629
7027
|
// packages/daemon/src/app.ts
|
|
5630
|
-
var VERSION = "0.
|
|
7028
|
+
var VERSION = "0.7.0";
|
|
5631
7029
|
var WEB_DIR = (() => {
|
|
5632
7030
|
if (process.env.SWARM_WEB_DIR)
|
|
5633
7031
|
return process.env.SWARM_WEB_DIR;
|
|
5634
|
-
const here =
|
|
5635
|
-
const dev =
|
|
5636
|
-
return
|
|
7032
|
+
const here = dirname3(fileURLToPath(import.meta.url));
|
|
7033
|
+
const dev = join9(here, "../../web/public");
|
|
7034
|
+
return existsSync6(join9(dev, "index.html")) ? dev : join9(here, "../web");
|
|
5637
7035
|
})();
|
|
5638
7036
|
var REPLAY_TAIL = 200;
|
|
5639
7037
|
var wireCache = new WeakMap;
|
|
@@ -5649,6 +7047,12 @@ function createApp(store = new Store) {
|
|
|
5649
7047
|
const app = new Hono2;
|
|
5650
7048
|
const forge2 = new ForgeService(store);
|
|
5651
7049
|
const runner = new Runner(store, store.home);
|
|
7050
|
+
const dispatcher = new Dispatcher(store, runner, forge2);
|
|
7051
|
+
store.onBudgetStop((projectId) => {
|
|
7052
|
+
dispatcher.clear(projectId);
|
|
7053
|
+
for (const run2 of runner.list(projectId))
|
|
7054
|
+
runner.stop(run2.id);
|
|
7055
|
+
});
|
|
5652
7056
|
app.get("/v1/health", (c) => c.json({ ok: true, version: VERSION }));
|
|
5653
7057
|
app.get("/v1/projects", (c) => c.json(store.snapshot().projects));
|
|
5654
7058
|
app.post("/v1/projects", async (c) => {
|
|
@@ -5677,13 +7081,13 @@ function createApp(store = new Store) {
|
|
|
5677
7081
|
const q = c.req.query("path");
|
|
5678
7082
|
let dir;
|
|
5679
7083
|
try {
|
|
5680
|
-
dir = realpathSync3(q &&
|
|
7084
|
+
dir = realpathSync3(q && existsSync6(q) ? q : homedir4());
|
|
5681
7085
|
} catch {
|
|
5682
7086
|
dir = homedir4();
|
|
5683
7087
|
}
|
|
5684
7088
|
try {
|
|
5685
|
-
const entries = readdirSync2(dir, { withFileTypes: true }).filter((e) => e.isDirectory() && !e.name.startsWith(".")).map((e) => ({ name: e.name, repo:
|
|
5686
|
-
const parent =
|
|
7089
|
+
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));
|
|
7090
|
+
const parent = dirname3(dir);
|
|
5687
7091
|
return c.json({ path: dir, parent: parent === dir ? null : parent, entries });
|
|
5688
7092
|
} catch (e) {
|
|
5689
7093
|
return c.json({ error: e.message, path: dir }, 400);
|
|
@@ -5793,10 +7197,84 @@ function createApp(store = new Store) {
|
|
|
5793
7197
|
model: b.model,
|
|
5794
7198
|
permissionMode: b.permissionMode,
|
|
5795
7199
|
allowedTools: b.allowedTools,
|
|
5796
|
-
maxTurns: b.maxTurns
|
|
7200
|
+
maxTurns: b.maxTurns,
|
|
7201
|
+
profile: b.profile
|
|
5797
7202
|
});
|
|
5798
7203
|
return r.ok ? c.json(r, 201) : c.json({ ok: false, error: r.reason }, 409);
|
|
5799
7204
|
});
|
|
7205
|
+
app.get("/v1/budget", (c) => {
|
|
7206
|
+
const project = c.req.query("project");
|
|
7207
|
+
if (!project)
|
|
7208
|
+
return c.json({ error: "project required" }, 400);
|
|
7209
|
+
return c.json(store.budgetFor(project) ?? { status: null, config: store.config(project).budget });
|
|
7210
|
+
});
|
|
7211
|
+
app.get("/v1/context", (c) => {
|
|
7212
|
+
const cwd = c.req.query("cwd");
|
|
7213
|
+
if (!cwd)
|
|
7214
|
+
return c.json({ error: "cwd required" }, 400);
|
|
7215
|
+
return c.json(store.contextFor(cwd, c.req.query("session") || null));
|
|
7216
|
+
});
|
|
7217
|
+
app.get("/v1/questions", (c) => c.json(store.questions({
|
|
7218
|
+
projectId: c.req.query("project") || undefined,
|
|
7219
|
+
sessionId: c.req.query("session") || undefined,
|
|
7220
|
+
open: c.req.query("open") === "1"
|
|
7221
|
+
})));
|
|
7222
|
+
app.post("/v1/questions", async (c) => {
|
|
7223
|
+
const b = await c.req.json().catch(() => ({}));
|
|
7224
|
+
if (!b.projectId)
|
|
7225
|
+
return c.json({ ok: false, error: "projectId required" }, 400);
|
|
7226
|
+
const r = store.ask(b.projectId, {
|
|
7227
|
+
sessionId: b.sessionId ?? null,
|
|
7228
|
+
text: b.text,
|
|
7229
|
+
options: b.options,
|
|
7230
|
+
askedBy: b.askedBy ?? null,
|
|
7231
|
+
cwd: b.cwd ?? null
|
|
7232
|
+
});
|
|
7233
|
+
return c.json(r, r.ok ? 201 : 400);
|
|
7234
|
+
});
|
|
7235
|
+
app.post("/v1/questions/:id/answer", async (c) => {
|
|
7236
|
+
const b = await c.req.json().catch(() => ({}));
|
|
7237
|
+
const r = store.answer(Number(c.req.param("id")), b.text, b.by ?? null);
|
|
7238
|
+
if (r.ok && r.question.sessionId) {
|
|
7239
|
+
const run2 = runner.get(r.question.sessionId);
|
|
7240
|
+
if (run2 && run2.sessionId === r.question.sessionId) {
|
|
7241
|
+
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}`);
|
|
7242
|
+
if (sent.ok)
|
|
7243
|
+
store.inbox(r.question.sessionId);
|
|
7244
|
+
}
|
|
7245
|
+
}
|
|
7246
|
+
return c.json(r, r.ok ? 200 : 409);
|
|
7247
|
+
});
|
|
7248
|
+
app.get("/v1/inbox", (c) => c.json(store.inbox(c.req.query("session") || null, { peek: c.req.query("peek") === "1" })));
|
|
7249
|
+
app.get("/v1/dispatch", (c) => {
|
|
7250
|
+
const project = c.req.query("project");
|
|
7251
|
+
if (!project)
|
|
7252
|
+
return c.json({ error: "project required" }, 400);
|
|
7253
|
+
return c.json({ entries: dispatcher.status(project), config: store.config(project).dispatch });
|
|
7254
|
+
});
|
|
7255
|
+
app.post("/v1/dispatch", async (c) => {
|
|
7256
|
+
const b = await c.req.json().catch(() => ({}));
|
|
7257
|
+
if (!b.projectId)
|
|
7258
|
+
return c.json({ ok: false, error: "projectId required" }, 400);
|
|
7259
|
+
if (!b.ready && !b.tasks?.length)
|
|
7260
|
+
return c.json({ ok: false, error: "tasks or ready:true required" }, 400);
|
|
7261
|
+
const r = await dispatcher.dispatch(b.projectId, b.ready ? "ready" : b.tasks, {
|
|
7262
|
+
owner: b.owner ?? "dispatch",
|
|
7263
|
+
max: b.max,
|
|
7264
|
+
maxParallel: b.maxParallel,
|
|
7265
|
+
permissionMode: b.permissionMode,
|
|
7266
|
+
model: b.model,
|
|
7267
|
+
maxTurns: b.maxTurns,
|
|
7268
|
+
profile: b.profile
|
|
7269
|
+
});
|
|
7270
|
+
return c.json(r, r.ok ? 202 : 409);
|
|
7271
|
+
});
|
|
7272
|
+
app.delete("/v1/dispatch", async (c) => {
|
|
7273
|
+
const b = await c.req.json().catch(() => ({}));
|
|
7274
|
+
if (!b.projectId)
|
|
7275
|
+
return c.json({ ok: false, error: "projectId required" }, 400);
|
|
7276
|
+
return c.json({ ok: true, cleared: dispatcher.clear(b.projectId, b.task) });
|
|
7277
|
+
});
|
|
5800
7278
|
app.post("/v1/runs/:id/send", async (c) => {
|
|
5801
7279
|
const b = await c.req.json().catch(() => ({}));
|
|
5802
7280
|
if (!b.text?.trim())
|
|
@@ -5848,10 +7326,31 @@ function createApp(store = new Store) {
|
|
|
5848
7326
|
const required = store.requiredGates(project);
|
|
5849
7327
|
return c.json({
|
|
5850
7328
|
required,
|
|
7329
|
+
executable: Object.keys(store.gateDefs(project)?.defs ?? {}),
|
|
5851
7330
|
runs,
|
|
5852
7331
|
status: task ? store.gateStatusFor(runs, required) : undefined
|
|
5853
7332
|
});
|
|
5854
7333
|
});
|
|
7334
|
+
app.post("/v1/gates/run", async (c) => {
|
|
7335
|
+
const b = await c.req.json().catch(() => ({}));
|
|
7336
|
+
if (!b.projectId || !b.task)
|
|
7337
|
+
return c.json({ ok: false, error: "projectId and task required" }, 400);
|
|
7338
|
+
const opts = { sessionId: b.sessionId ?? null, owner: "cli" };
|
|
7339
|
+
if (b.wait === false) {
|
|
7340
|
+
const projectId = b.projectId;
|
|
7341
|
+
const cfg = store.gateDefs(projectId);
|
|
7342
|
+
const names = b.gates?.length ? b.gates : (cfg?.required ?? []).filter((g) => cfg?.defs[g]);
|
|
7343
|
+
store.runGates(projectId, b.task, names, opts);
|
|
7344
|
+
return c.json({ ok: true, started: names, runs: [] }, 202);
|
|
7345
|
+
}
|
|
7346
|
+
const r = await store.runGates(b.projectId, b.task, b.gates, opts);
|
|
7347
|
+
const ok = r.started.length > 0 && r.runs.every((x) => x.verdict === "pass");
|
|
7348
|
+
return c.json({
|
|
7349
|
+
ok,
|
|
7350
|
+
...r,
|
|
7351
|
+
error: r.started.length ? undefined : r.skipped[0]?.reason ?? "no executable gates"
|
|
7352
|
+
}, r.started.length ? 200 : 409);
|
|
7353
|
+
});
|
|
5855
7354
|
app.post("/v1/gates", async (c) => {
|
|
5856
7355
|
const b = await c.req.json().catch(() => ({}));
|
|
5857
7356
|
if (!b.projectId)
|
|
@@ -5891,6 +7390,81 @@ function createApp(store = new Store) {
|
|
|
5891
7390
|
const r = store.release(b.projectId ?? "", b.task ?? "", b.force ?? false);
|
|
5892
7391
|
return c.json(r, r.ok ? 200 : 409);
|
|
5893
7392
|
});
|
|
7393
|
+
app.get("/v1/worktrees", async (c) => {
|
|
7394
|
+
const project = c.req.query("project");
|
|
7395
|
+
if (!project)
|
|
7396
|
+
return c.json({ error: "project required" }, 400);
|
|
7397
|
+
return c.json(await store.refreshWorktrees(project));
|
|
7398
|
+
});
|
|
7399
|
+
app.post("/v1/worktrees", async (c) => {
|
|
7400
|
+
const b = await c.req.json();
|
|
7401
|
+
if (!b.projectId || !b.name)
|
|
7402
|
+
return c.json({ error: "projectId and name required" }, 400);
|
|
7403
|
+
const r = store.createWorktree(b.projectId, b.name, b.baseRef, b.branch);
|
|
7404
|
+
return c.json(r, r.ok ? 201 : 409);
|
|
7405
|
+
});
|
|
7406
|
+
app.post("/v1/worktrees/remove", async (c) => {
|
|
7407
|
+
const b = await c.req.json();
|
|
7408
|
+
if (!b.projectId || !b.worktree)
|
|
7409
|
+
return c.json({ error: "projectId and worktree required" }, 400);
|
|
7410
|
+
const r = await store.removeWorktree(b.projectId, b.worktree, b.force ?? false);
|
|
7411
|
+
return c.json(r, r.ok ? 200 : 409);
|
|
7412
|
+
});
|
|
7413
|
+
app.post("/v1/worktrees/open", async (c) => {
|
|
7414
|
+
const b = await c.req.json();
|
|
7415
|
+
if (!b.projectId || !b.worktree)
|
|
7416
|
+
return c.json({ error: "projectId and worktree required" }, 400);
|
|
7417
|
+
await store.refreshWorktrees(b.projectId);
|
|
7418
|
+
const r = store.openWorktree(b.projectId, b.worktree);
|
|
7419
|
+
return c.json(r, r.ok ? 200 : 404);
|
|
7420
|
+
});
|
|
7421
|
+
app.get("/v1/worktrees/diff", async (c) => {
|
|
7422
|
+
const project = c.req.query("project");
|
|
7423
|
+
const ref = c.req.query("worktree");
|
|
7424
|
+
if (!project || !ref)
|
|
7425
|
+
return c.json({ error: "project and worktree required" }, 400);
|
|
7426
|
+
await store.refreshWorktrees(project);
|
|
7427
|
+
const w = store.findWorktree(project, ref);
|
|
7428
|
+
const p = store.project(project);
|
|
7429
|
+
if (!w || !p)
|
|
7430
|
+
return c.json({ error: `no worktree ${ref}` }, 404);
|
|
7431
|
+
const file = c.req.query("file") || undefined;
|
|
7432
|
+
const d = await worktreeDiff(p.root, w.path);
|
|
7433
|
+
if (file || c.req.query("patch") === "1")
|
|
7434
|
+
return c.json({ ...d, worktree: w.path, patch: await worktreePatch(w.path, d.base, file) });
|
|
7435
|
+
return c.json({ ...d, worktree: w.path });
|
|
7436
|
+
});
|
|
7437
|
+
app.get("/v1/prs/draft", async (c) => {
|
|
7438
|
+
const project = c.req.query("project");
|
|
7439
|
+
const ref = c.req.query("worktree") || c.req.query("task");
|
|
7440
|
+
if (!project || !ref)
|
|
7441
|
+
return c.json({ error: "project and worktree|task required" }, 400);
|
|
7442
|
+
const r = await store.prDraftFor(project, ref);
|
|
7443
|
+
return c.json(r, r.ok ? 200 : 404);
|
|
7444
|
+
});
|
|
7445
|
+
app.post("/v1/prs/open", async (c) => {
|
|
7446
|
+
const b = await c.req.json().catch(() => ({}));
|
|
7447
|
+
const ref = b.worktree || b.task;
|
|
7448
|
+
if (!b.projectId || !ref)
|
|
7449
|
+
return c.json({ ok: false, error: "projectId and worktree|task required" }, 400);
|
|
7450
|
+
const d = await store.prDraftFor(b.projectId, ref);
|
|
7451
|
+
if (!d.ok)
|
|
7452
|
+
return c.json(d, 404);
|
|
7453
|
+
const r = await forge2.openPR(b.projectId, d.worktree, {
|
|
7454
|
+
title: b.title?.trim() || d.title,
|
|
7455
|
+
body: b.body ?? d.body,
|
|
7456
|
+
isDraft: b.draft ?? false
|
|
7457
|
+
});
|
|
7458
|
+
if (r.ok)
|
|
7459
|
+
store.recordPrOpened(b.projectId, d.task, d.worktree.path, r.url);
|
|
7460
|
+
return c.json(r, r.ok ? 201 : 409);
|
|
7461
|
+
});
|
|
7462
|
+
app.post("/v1/worktrees/gc", async (c) => {
|
|
7463
|
+
const b = await c.req.json().catch(() => ({}));
|
|
7464
|
+
if (!b.projectId)
|
|
7465
|
+
return c.json({ error: "projectId required" }, 400);
|
|
7466
|
+
return c.json(await store.gcWorktrees(b.projectId, b.apply ?? false));
|
|
7467
|
+
});
|
|
5894
7468
|
app.post("/v1/claims/reap", async (c) => {
|
|
5895
7469
|
const b = await c.req.json().catch(() => ({}));
|
|
5896
7470
|
return c.json({ reaped: store.reap(b.projectId) });
|
|
@@ -5957,6 +7531,8 @@ function createApp(store = new Store) {
|
|
|
5957
7531
|
hookSpecificOutput: { hookEventName: "SessionStart", additionalContext: ctx }
|
|
5958
7532
|
});
|
|
5959
7533
|
}
|
|
7534
|
+
const sid = typeof raw2.session_id === "string" ? raw2.session_id : null;
|
|
7535
|
+
const answers = event === "UserPromptSubmit" || event === "PreToolUse" || event === "PostToolUse" ? store.answerContext(sid) : null;
|
|
5960
7536
|
if (event === "PreToolUse" && process.env.SWARM_GUARD !== "off") {
|
|
5961
7537
|
const guard = store.guardHook(raw2);
|
|
5962
7538
|
if (guard) {
|
|
@@ -5964,11 +7540,17 @@ function createApp(store = new Store) {
|
|
|
5964
7540
|
hookSpecificOutput: {
|
|
5965
7541
|
hookEventName: "PreToolUse",
|
|
5966
7542
|
permissionDecision: guard.action,
|
|
5967
|
-
permissionDecisionReason: `[swarm] ${guard.reason}
|
|
7543
|
+
permissionDecisionReason: `[swarm] ${guard.reason}`,
|
|
7544
|
+
...answers ? { additionalContext: answers } : {}
|
|
5968
7545
|
}
|
|
5969
7546
|
});
|
|
5970
7547
|
}
|
|
5971
7548
|
}
|
|
7549
|
+
if (answers)
|
|
7550
|
+
return c.json({
|
|
7551
|
+
additionalContext: answers,
|
|
7552
|
+
hookSpecificOutput: { hookEventName: event, additionalContext: answers }
|
|
7553
|
+
});
|
|
5972
7554
|
return c.json({});
|
|
5973
7555
|
});
|
|
5974
7556
|
app.post("/v1/events", async (c) => {
|
|
@@ -5997,18 +7579,18 @@ function createApp(store = new Store) {
|
|
|
5997
7579
|
});
|
|
5998
7580
|
});
|
|
5999
7581
|
});
|
|
6000
|
-
app.get("/", (c) => c.html(readFileSync4(
|
|
7582
|
+
app.get("/", (c) => c.html(readFileSync4(join9(WEB_DIR, "index.html"), "utf8")));
|
|
6001
7583
|
const MIME = { js: "text/javascript", css: "text/css" };
|
|
6002
7584
|
app.get("/:file{[a-z0-9-]+\\.(js|css)}", (c) => {
|
|
6003
7585
|
const f = c.req.param("file");
|
|
6004
|
-
const p =
|
|
6005
|
-
if (!
|
|
7586
|
+
const p = join9(WEB_DIR, f);
|
|
7587
|
+
if (!existsSync6(p))
|
|
6006
7588
|
return c.text(`${f} not built \u2014 run: bun run build:web`, 404);
|
|
6007
7589
|
return c.body(readFileSync4(p, "utf8"), 200, {
|
|
6008
7590
|
"content-type": MIME[f.split(".").pop() ?? ""] ?? "text/plain"
|
|
6009
7591
|
});
|
|
6010
7592
|
});
|
|
6011
|
-
return { app, store, forge: forge2, runner };
|
|
7593
|
+
return { app, store, forge: forge2, runner, dispatcher };
|
|
6012
7594
|
}
|
|
6013
7595
|
|
|
6014
7596
|
// packages/daemon/src/bin.ts
|
|
@@ -6044,6 +7626,8 @@ var tailer = setInterval(() => {
|
|
|
6044
7626
|
store.reapProcesses();
|
|
6045
7627
|
if (tick % 12 === 0)
|
|
6046
7628
|
store.sweepOrphans();
|
|
7629
|
+
if (tick % 6 === 0)
|
|
7630
|
+
store.checkBudgets();
|
|
6047
7631
|
}, 5000);
|
|
6048
7632
|
store.refreshAllWorktrees();
|
|
6049
7633
|
var wtRefresh = setInterval(() => void store.refreshAllWorktrees(), 15000);
|