trantor 0.18.116 → 0.18.118
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/.claude-plugin/plugin.json +1 -1
- package/bin/crew/processes.mjs +4 -2
- package/bin/crew/state.mjs +23 -1
- package/bin/disk.mjs +16 -12
- package/hub/auth.mjs +58 -14
- package/hub/public.mjs +14 -1
- package/hub/routes/session.mjs +149 -26
- package/hub.mjs +20 -4
- package/lib/disk.mjs +165 -32
- package/lib/seat-worktree.mjs +38 -1
- package/package.json +1 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "trantor",
|
|
3
|
-
"version": "0.18.
|
|
3
|
+
"version": "0.18.118",
|
|
4
4
|
"description": "Trantor — the hub-world for AI agent crews: live message bus, presence, project Kanban/flow board + crew orchestration for independent AI coding agents (Claude, Codex, Gemini, Kimi, DeepSeek)",
|
|
5
5
|
"mcpServers": {
|
|
6
6
|
"relay": {
|
package/bin/crew/processes.mjs
CHANGED
|
@@ -4,7 +4,7 @@ import { call } from "./core.mjs";
|
|
|
4
4
|
|
|
5
5
|
export const runnerRecord = (ctx, project, agent) => join(ctx.seatDir, `${project}-${agent}.runner.json`);
|
|
6
6
|
|
|
7
|
-
export function seatProcesses(ctx, project, agent) {
|
|
7
|
+
export function seatProcesses(ctx, project, agent, exec = call) {
|
|
8
8
|
if (ctx.env.CREW_NO_PROC_KILL === "1") return [];
|
|
9
9
|
let label = agent, dir = ctx.dir;
|
|
10
10
|
try {
|
|
@@ -12,7 +12,9 @@ export function seatProcesses(ctx, project, agent) {
|
|
|
12
12
|
label = record.label;
|
|
13
13
|
dir = record.dir;
|
|
14
14
|
} catch (error) { if (error.code !== "ENOENT") throw error; }
|
|
15
|
-
const
|
|
15
|
+
const snapshot = exec("ps", ["-axo", "pid=,ppid=,command="]);
|
|
16
|
+
if (snapshot.status !== 0) throw new Error("could not read crew processes");
|
|
17
|
+
const rows = snapshot.stdout.split("\n").map(line => {
|
|
16
18
|
const match = line.trim().match(/^(\d+)\s+(\d+)\s+(.+)$/);
|
|
17
19
|
return match ? { pid: Number(match[1]), parent: Number(match[2]), command: match[3] } : null;
|
|
18
20
|
}).filter(Boolean);
|
package/bin/crew/state.mjs
CHANGED
|
@@ -3,6 +3,7 @@ import { dirname, join } from "node:path";
|
|
|
3
3
|
import { seatProcesses, signalProcesses } from "./processes.mjs";
|
|
4
4
|
import { call, listPids, run } from "./core.mjs";
|
|
5
5
|
import { hostId } from "../../lib/project.mjs";
|
|
6
|
+
import { removeSeatWorktree } from "../../lib/seat-worktree.mjs";
|
|
6
7
|
import { signedPost } from "../../hooks/lib/api.mjs";
|
|
7
8
|
import { statePathFor as dockerStatePathFor, sweep as dockerSweep } from "../../lib/docker-janitor.mjs";
|
|
8
9
|
|
|
@@ -130,7 +131,7 @@ async function unregisterSeat(ctx, project, persona) {
|
|
|
130
131
|
return response.json;
|
|
131
132
|
}
|
|
132
133
|
|
|
133
|
-
export async function down(ctx, args, adapters, { unregister = unregisterSeat } = {}) {
|
|
134
|
+
export async function down(ctx, args, adapters, { unregister = unregisterSeat, removeWorktree = removeSeatWorktree, processes = seatProcesses } = {}) {
|
|
134
135
|
const options = parseDownArgs(ctx, args);
|
|
135
136
|
if (options.help) return 0;
|
|
136
137
|
if (options.error) return 1;
|
|
@@ -175,6 +176,27 @@ export async function down(ctx, args, adapters, { unregister = unregisterSeat }
|
|
|
175
176
|
`${JSON.stringify({ ts: Date.now(), agent: row.persona, project: row.project, stopped: true })}\n`);
|
|
176
177
|
}
|
|
177
178
|
}
|
|
179
|
+
// #12269: a stopped seat's worktree goes with it — `git worktree remove`, branch kept; a tree
|
|
180
|
+
// with uncommitted changes stays on disk and is named here so the operator can recover it.
|
|
181
|
+
for (const row of seats.values()) {
|
|
182
|
+
if (!row.project) continue;
|
|
183
|
+
if (!ctx.dry) {
|
|
184
|
+
try {
|
|
185
|
+
const tracked = readRows(ctx).some(current => current.project === row.project && current.persona === row.persona);
|
|
186
|
+
if (tracked || processes(ctx, row.project, row.persona).length) {
|
|
187
|
+
console.log(` worktree kept (crew row or process remains): ${row.persona}:${row.project}`);
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
} catch (error) {
|
|
191
|
+
console.log(` worktree kept (crew liveness unknown: ${error.message}): ${row.persona}:${row.project}`);
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
const r = await removeWorktree({ project: row.project, agent: row.persona, home: ctx.home || undefined, env: ctx.env, dry: ctx.dry });
|
|
196
|
+
if (r.status === "removed") console.log(` worktree removed: ${r.path} (branch seat/${row.persona} kept)`);
|
|
197
|
+
else if (r.status === "kept") console.log(` worktree kept (${r.reason}): ${r.path}`);
|
|
198
|
+
else if (r.status === "would-remove") console.log(` [dry] would remove worktree: ${r.path}`);
|
|
199
|
+
}
|
|
178
200
|
if (failures.length) {
|
|
179
201
|
console.log(`— hub row removal incomplete; retry Stop: ${failures.join("; ")}`);
|
|
180
202
|
return 1;
|
package/bin/disk.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// trantor disk — report where the disk went; --clean runs the safe tier ONLY;
|
|
3
|
-
// install wires a
|
|
4
|
-
// (safe clean, then exactly ONE summary message on the bus for the operator).
|
|
3
|
+
// install wires a daily 09:00 launchd job; --daily (or the legacy --weekly) is what
|
|
4
|
+
// that job runs (safe clean, then exactly ONE summary message on the bus for the operator).
|
|
5
5
|
import { spawnSync } from "node:child_process";
|
|
6
6
|
import { join, dirname } from "node:path";
|
|
7
7
|
import { fileURLToPath } from "node:url";
|
|
@@ -52,7 +52,7 @@ async function install() {
|
|
|
52
52
|
// bootout+bootstrap can race an existing job; load -w is the legacy fallback.
|
|
53
53
|
exec("launchctl", ["load", "-w", plistPath]);
|
|
54
54
|
}
|
|
55
|
-
console.log(`installed: ${plistPath}\nruns every
|
|
55
|
+
console.log(`installed: ${plistPath}\nruns every day at 09:00 (safe tier only); result lands as one bus message. Log: ${logPath}`);
|
|
56
56
|
}
|
|
57
57
|
|
|
58
58
|
function exec(file, args) {
|
|
@@ -64,19 +64,23 @@ if (args.includes("--help") || args.includes("-h") || args[0] === "help") {
|
|
|
64
64
|
console.log(`trantor disk — where the space went, and the provably safe slice to reclaim
|
|
65
65
|
|
|
66
66
|
trantor disk report only (also prints exactly what the safe tier would delete)
|
|
67
|
-
trantor disk --dry-run the same report, explicit
|
|
68
|
-
trantor disk --clean run the SAFE tier: unavailable simulators,
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
67
|
+
trantor disk --dry-run the same report, explicit — never cleans
|
|
68
|
+
trantor disk --clean run the SAFE tier: unavailable simulators, whole seat worktrees
|
|
69
|
+
idle 7+ days with no uncommitted changes (branch kept), build
|
|
70
|
+
output (DerivedData, *.xcresult, target/) idle 3+ days, stale
|
|
71
|
+
scratch (.agent-bus-out/.scratch entries idle 2+ days) and build
|
|
72
|
+
dirs in seat worktrees with no live turn, opencode sessions idle
|
|
73
|
+
>14 days. Everything else stays. Every removal is printed first.
|
|
72
74
|
trantor disk --json machine-readable report
|
|
73
|
-
trantor disk install
|
|
75
|
+
trantor disk install daily 09:00 launchd job (runs --daily: clean + one bus message)`);
|
|
74
76
|
process.exit(0);
|
|
75
77
|
}
|
|
76
78
|
if (args[0] === "install") { await install(); process.exit(0); }
|
|
77
79
|
|
|
78
|
-
|
|
79
|
-
const
|
|
80
|
+
// #12269: daily cadence (--daily; --weekly kept as the old plist's flag). --dry-run wins.
|
|
81
|
+
const periodic = args.includes("--weekly") || args.includes("--daily");
|
|
82
|
+
const dryRun = args.includes("--dry-run");
|
|
83
|
+
const passArgs = periodic && !dryRun ? [...args, "--clean"] : args;
|
|
80
84
|
const { report, results, json } = await runDisk({ args: passArgs });
|
|
81
85
|
if (json) {
|
|
82
86
|
process.stdout.write(`${JSON.stringify({ report, results }, null, 2)}\n`);
|
|
@@ -87,4 +91,4 @@ if (json) {
|
|
|
87
91
|
for (const r of results.filter((r) => !r.ok)) console.log(` FAILED ${r.type} ${r.path || r.id || ""}: ${r.error}`);
|
|
88
92
|
}
|
|
89
93
|
}
|
|
90
|
-
if (
|
|
94
|
+
if (periodic) await sendWeeklyMessage(summaryLine(report, results));
|
package/hub/auth.mjs
CHANGED
|
@@ -1,19 +1,60 @@
|
|
|
1
1
|
/* oxlint-disable anti-slop/no-runtime-typeof -- SAFETY: Auth normalizes untrusted request and persisted identity shapes at their established hub boundary; this split preserves those guards unchanged. */
|
|
2
|
-
import { verifyRequest, verifyEndorsement } from "../lib/identity.mjs";
|
|
2
|
+
import { verifyRequest, verifyEndorsement, HDR } from "../lib/identity.mjs";
|
|
3
3
|
import { readFileSync } from "node:fs";
|
|
4
|
+
import { StringDecoder } from "node:string_decoder";
|
|
4
5
|
import { timingSafeEqual, randomBytes } from "node:crypto";
|
|
5
6
|
import { publicView } from "../lib/identity.mjs";
|
|
6
7
|
import { IDENTITY_KINDS } from "../lib/store-contract.mjs";
|
|
7
8
|
|
|
8
9
|
export function createAuthRuntime({ state, markDirty, AUTH_MODE, SUPERSEDE_GRACE_MS, LOOPBACK_BIND, ENROLL_MODE, seenNonces = new Map() }) {
|
|
9
10
|
const now = () => Date.now();
|
|
10
|
-
|
|
11
|
+
// Route measurements and supported envelopes: docs/REQUEST-BODY-LIMITS.md.
|
|
12
|
+
const BODY_CAP_DEFAULT = 4 * 1024 * 1024;
|
|
13
|
+
const BODY_CAP_PROMPT = 16 * 1024 * 1024;
|
|
14
|
+
// More than 100x the measured 115,194-byte desktop snapshot.
|
|
15
|
+
const BODY_CAP_OWNER_CHAT = 16 * 1024 * 1024;
|
|
16
|
+
// About 6x the measured 10,659,828-byte real state.
|
|
17
|
+
const BODY_CAP_OWNER_IMPORT = 64 * 1024 * 1024;
|
|
18
|
+
const OWNER_BODY_CAPS = new Map([
|
|
19
|
+
["/session/chat", BODY_CAP_OWNER_CHAT],
|
|
20
|
+
["/import", BODY_CAP_OWNER_IMPORT],
|
|
21
|
+
["/handoff", BODY_CAP_DEFAULT], ["/task", BODY_CAP_DEFAULT], ["/task/update", BODY_CAP_DEFAULT],
|
|
22
|
+
]);
|
|
23
|
+
function bodyCap(P) { return P === "/session/prompt" ? BODY_CAP_PROMPT : BODY_CAP_DEFAULT; }
|
|
24
|
+
function ownerBodyCap(req, P) {
|
|
25
|
+
const cap = OWNER_BODY_CAPS.get(P) || bodyCap(P);
|
|
26
|
+
if (cap <= bodyCap(P) || !Object.values(HDR).every(h => req.headers[h])) return bodyCap(P);
|
|
27
|
+
// Header identity is only a read-budget hint. Larger bodies require strict owner auth before parsing.
|
|
28
|
+
const identity = findIdentity(req.headers[HDR.pubkey]) || findIdentity(req.headers["x-trantor-durable"]);
|
|
29
|
+
return identity?.scopes?.some(s => s.role === "owner") ? cap : bodyCap(P);
|
|
30
|
+
}
|
|
31
|
+
function rawBody(req, maxBytes = BODY_CAP_DEFAULT) {
|
|
32
|
+
if (req._bodyTooLarge) return Promise.resolve(undefined);
|
|
11
33
|
if (req._rawBody !== undefined) return Promise.resolve(req._rawBody);
|
|
12
|
-
return new Promise(r => {
|
|
34
|
+
return new Promise(r => {
|
|
35
|
+
let d = "", bytes = 0, done = false;
|
|
36
|
+
const decoder = new StringDecoder("utf8");
|
|
37
|
+
const finish = (v) => { if (!done) { done = true; r(v); } };
|
|
38
|
+
req.on("data", c => {
|
|
39
|
+
if (done) return;
|
|
40
|
+
bytes += c.length;
|
|
41
|
+
if (bytes > maxBytes) {
|
|
42
|
+
req._bodyTooLarge = true;
|
|
43
|
+
finish(undefined);
|
|
44
|
+
req.resume(); // keep draining (and discarding) so the socket can close cleanly
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
d += decoder.write(c);
|
|
48
|
+
});
|
|
49
|
+
req.on("end", () => { if (!req._bodyTooLarge) { req._bodyBytes = bytes; req._rawBody = d + decoder.end(); finish(req._rawBody); } });
|
|
50
|
+
req.on("error", () => finish(undefined));
|
|
51
|
+
});
|
|
13
52
|
}
|
|
14
|
-
async function body(req) {
|
|
53
|
+
async function body(req, maxBytes = BODY_CAP_DEFAULT) {
|
|
54
|
+
if (req._bodyTooLarge) return undefined;
|
|
15
55
|
if (req._jsonBody !== undefined) return req._jsonBody;
|
|
16
|
-
const d = await rawBody(req);
|
|
56
|
+
const d = await rawBody(req, maxBytes);
|
|
57
|
+
if (d === undefined) return undefined;
|
|
17
58
|
try { req._jsonBody = d ? JSON.parse(d) : {}; } catch { req._jsonBody = {}; }
|
|
18
59
|
return req._jsonBody;
|
|
19
60
|
}
|
|
@@ -53,7 +94,7 @@ function cmpSemver(a, b) {
|
|
|
53
94
|
}
|
|
54
95
|
const AUTH_HEADERS = ["x-trantor-pubkey", "x-trantor-sig", "x-trantor-ts", "x-trantor-nonce"];
|
|
55
96
|
const PUBLIC_ENDPOINTS = new Set(["/", "/ui", "/health", "/enroll"]);
|
|
56
|
-
const OWNER_ENDPOINTS = new Set(["/project/delete", "/sweep", "/reconcile", "/invite", "/import", "/policy", "/session/prompts", "/session/prompt/claim", "/session/prompt/ack"]);
|
|
97
|
+
const OWNER_ENDPOINTS = new Set(["/project/delete", "/sweep", "/reconcile", "/invite", "/import", "/policy", "/session/prompts", "/session/prompt/claim", "/session/prompt/ack", "/session/attachment"]);
|
|
57
98
|
const DEVICE_ENDPOINTS = new Set(["/device/register", "/device/unregister", "/devices"]);
|
|
58
99
|
const READ_ENDPOINTS = new Set(["/peers", "/tasks", "/events", "/inbox", "/peer", "/card", "/stream", "/history", "/projects", "/catchup", "/phases", "/recent", "/handoffs", "/verify-gates", "/claims", "/proposals", "/grants", "/overseer/context", "/overseer/status", "/session/chat", "/session/prompt", "/crew/roster"]);
|
|
59
100
|
const roleRank = { read: 1, write: 2, approver: 3, owner: 4 };
|
|
@@ -99,7 +140,7 @@ function isDutyIdentity(auth) {
|
|
|
99
140
|
const canRead = (auth, project) => AUTH_MODE !== "enforce" && !auth?.identity
|
|
100
141
|
? true
|
|
101
142
|
: isDutyIdentity(auth) || scopeAllows(auth?.identity, project, "read");
|
|
102
|
-
function projectFromRequest(P, q, b) {
|
|
143
|
+
function projectFromRequest(P, q, b, method) {
|
|
103
144
|
if (P === "/task/update" || P === "/card") {
|
|
104
145
|
const t = state.tasks.find(x => x.id === Number(b?.id ?? q?.id));
|
|
105
146
|
return t?.project || "";
|
|
@@ -117,8 +158,11 @@ function projectFromRequest(P, q, b) {
|
|
|
117
158
|
return p?.project || "";
|
|
118
159
|
}
|
|
119
160
|
// claim/ack and the single-prompt GET carry only an id — authorization runs against the
|
|
120
|
-
// PROMPT's project
|
|
121
|
-
|
|
161
|
+
// PROMPT's project (#12129); the attachment download is keyed the same way. POST
|
|
162
|
+
// /session/prompt is the exception: it CREATES a row in b.project, so an id in the body must
|
|
163
|
+
// not borrow an existing row's project to write elsewhere (#12161 review).
|
|
164
|
+
if (P === "/session/prompt" || P === "/session/prompt/claim" || P === "/session/prompt/ack" || P === "/session/attachment") {
|
|
165
|
+
if (P === "/session/prompt" && method === "POST") return canon(String(b?.project || q?.project || "").slice(0, 80));
|
|
122
166
|
const pr = (state.sessionPrompts || []).find(x => x.id === Number(b?.id ?? q?.id));
|
|
123
167
|
if (pr) return pr.project || "";
|
|
124
168
|
if (P !== "/session/prompt") return "";
|
|
@@ -198,11 +242,11 @@ function supersessionActive(rec) {
|
|
|
198
242
|
}
|
|
199
243
|
return insts.some(i => i !== rec && i.name === rec.name && !i.superseded && (i.lastSeen || 0) > cut);
|
|
200
244
|
}
|
|
201
|
-
async function authenticate(req, path) {
|
|
202
|
-
if (AUTH_MODE === "off") return { ok: true, mode: AUTH_MODE, trusted: true };
|
|
245
|
+
async function authenticate(req, path, strict = false) {
|
|
246
|
+
if (AUTH_MODE === "off" && !strict) return { ok: true, mode: AUTH_MODE, trusted: true };
|
|
203
247
|
const signed = hasAuthHeaders(req);
|
|
204
248
|
if (!signed) {
|
|
205
|
-
if (AUTH_MODE === "warn") {
|
|
249
|
+
if (AUTH_MODE === "warn" && !strict) {
|
|
206
250
|
process.stderr.write(`[trantor] auth warn: unsigned ${req.method} ${path}\n`);
|
|
207
251
|
return { ok: true, mode: AUTH_MODE, trusted: false, warning: "unsigned" };
|
|
208
252
|
}
|
|
@@ -210,7 +254,7 @@ async function authenticate(req, path) {
|
|
|
210
254
|
}
|
|
211
255
|
const raw = req.method === "GET" || req.method === "HEAD" ? undefined : await rawBody(req);
|
|
212
256
|
// Auth warn mode annotates signature, replay and enrollment failures; enforce mode rejects them.
|
|
213
|
-
const soft = (warning) => AUTH_MODE === "warn"
|
|
257
|
+
const soft = (warning) => AUTH_MODE === "warn" && !strict
|
|
214
258
|
? { ok: true, mode: AUTH_MODE, trusted: false, warning }
|
|
215
259
|
: { ok: false, code: 401, error: warning };
|
|
216
260
|
const verified = verifyRequest({ headers: req.headers, method: req.method, path, body: raw });
|
|
@@ -351,7 +395,7 @@ function canUseInboxSession(auth, session) {
|
|
|
351
395
|
}
|
|
352
396
|
|
|
353
397
|
return {
|
|
354
|
-
PUBLIC_ENDPOINTS, authPath, authenticate, authorize, body, rawBody, json,
|
|
398
|
+
PUBLIC_ENDPOINTS, authPath, authenticate, authorize, body, bodyCap, ownerBodyCap, rawBody, json,
|
|
355
399
|
canon, cleanScope, defaultScopesFor, findIdentity, scopeAllows, canRead, isDutyIdentity,
|
|
356
400
|
projectFromRequest, crossProjectGuard, filterReadable, filterDiscoverable,
|
|
357
401
|
inboxReadable, canUseInboxSession, overseerPolicy, subFp, PROPOSAL_CAP,
|
package/hub/public.mjs
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { isIP } from "node:net";
|
|
2
|
+
import { HDR } from "../lib/identity.mjs";
|
|
2
3
|
|
|
3
4
|
const ALLOWED = new Set([
|
|
4
5
|
...["/health", "/projects", "/catchup", "/tasks", "/card", "/events", "/stream",
|
|
@@ -31,7 +32,7 @@ export function publicClientIP(req) {
|
|
|
31
32
|
return normalizedIP(forwarded) || socket;
|
|
32
33
|
}
|
|
33
34
|
|
|
34
|
-
export function createPublicGate({ now = Date.now, log = line => process.stderr.write(`${line}\n`) } = {}) {
|
|
35
|
+
export function createPublicGate({ now = Date.now, log = line => process.stderr.write(`${line}\n`), canUpload = () => false } = {}) {
|
|
35
36
|
const clients = new Map();
|
|
36
37
|
const admissions = new WeakMap();
|
|
37
38
|
const refusals = new Map();
|
|
@@ -92,6 +93,18 @@ export function createPublicGate({ now = Date.now, log = line => process.stderr.
|
|
|
92
93
|
count("auth");
|
|
93
94
|
} else if (res.statusCode === 403) count(path === "/enroll" ? "enrollment" : "authorization");
|
|
94
95
|
});
|
|
96
|
+
// Only health is unsigned; reject absent signature fields before the handler reads a body.
|
|
97
|
+
if (path !== "/health" && Object.values(HDR).some(header => !req.headers[header])) {
|
|
98
|
+
res.writeHead(401, { "content-type": "application/json", "connection": "close" });
|
|
99
|
+
res.end(JSON.stringify({ error: "signature required" }));
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
102
|
+
// The photo endpoint has a larger body budget; unknown or read-only keys get no read budget.
|
|
103
|
+
if (req.method === "POST" && path === "/session/prompt" && !canUpload(req.headers[HDR.pubkey])) {
|
|
104
|
+
res.writeHead(401, { "content-type": "application/json", "connection": "close" });
|
|
105
|
+
res.end(JSON.stringify({ error: "enrolled phone write scope required" }));
|
|
106
|
+
return false;
|
|
107
|
+
}
|
|
95
108
|
return true;
|
|
96
109
|
}
|
|
97
110
|
|
package/hub/routes/session.mjs
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
// is MEMORY-ONLY (a restart answers have:0, the desktop resends its window); the prompt queue
|
|
3
3
|
// lives in state so a restart never strands a queued prompt. Live frames ride pushEventToStreams
|
|
4
4
|
// (scope-filtered, never logged); only chat.prompt.queued/.delivered/.failed are audit-appended.
|
|
5
|
+
import { mkdirSync, readdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
|
|
6
|
+
import { join } from "node:path";
|
|
5
7
|
import { setInterval } from "node:timers";
|
|
6
8
|
|
|
7
9
|
const CHAT_CAP = 400; // turns kept per project, highest idx wins
|
|
@@ -10,11 +12,22 @@ const PROMPT_CAP = 200;
|
|
|
10
12
|
const PROMPT_TEXT_MAX = 8000;
|
|
11
13
|
const EVENT_TEXT_MAX = 500;
|
|
12
14
|
const PROMPT_TTL_MS = Number(process.env.RELAY_PROMPT_TTL_MS || 120000);
|
|
15
|
+
// Claims cover the desktop's 240s crew timeout plus ack slack; heartbeats cover lock waits.
|
|
16
|
+
const CLAIM_TTL_MS = 300000;
|
|
13
17
|
const PROMPT_SWEEP_MS = Number(process.env.RELAY_PROMPT_SWEEP_MS || 30000);
|
|
14
18
|
const EXPIRED_REASON = "No Mac is relaying this project. Is the Trantor app open?";
|
|
19
|
+
const CLAIMED_EXPIRED_REASON = "The Mac claimed this prompt but never delivered it. Try sending it again.";
|
|
15
20
|
const ACK_OUTCOMES = new Set(["delivered", "blocked", "not_ready", "stalled", "no_agent", "stale"]);
|
|
16
21
|
const ANSWER_PICKS_MAX = 10;
|
|
17
22
|
const ANSWER_OTHER_MAX = 2000;
|
|
23
|
+
// #12161: photo attachments ride POST /session/prompt as base64 JPEGs. The phone downscales to
|
|
24
|
+
// the byte cap; the hub stores the bytes on disk next to its state (never in bus.json) and hands
|
|
25
|
+
// metadata to the claiming desktop, which downloads them through GET /session/attachment.
|
|
26
|
+
const ATTACHMENTS_MAX = 4;
|
|
27
|
+
const PROJECT_ATTACHMENT_BYTES_MAX = 64 * 1024 * 1024;
|
|
28
|
+
const ATTACHMENT_BYTES_MAX = 2 * 1024 * 1024;
|
|
29
|
+
const ATTACHMENT_B64_MAX = Math.ceil(ATTACHMENT_BYTES_MAX / 3) * 4;
|
|
30
|
+
const JPEG_MAGIC = Buffer.from([0xff, 0xd8, 0xff]);
|
|
18
31
|
// #12140: crew commands from the phone. The hub validates and queues; the desktop relay runs the
|
|
19
32
|
// trantor CLI and acks done|failed with the output tail. A seat spec is "agent[:project]".
|
|
20
33
|
const CREW_SPEC = /^[a-z][a-z0-9-]{0,30}(:[A-Za-z0-9._/-]{1,80})?$/;
|
|
@@ -24,7 +37,7 @@ const CREW_CLAUDE_REFUSAL = "The claude seat draws your own Claude quota; start
|
|
|
24
37
|
// oxlint-disable-next-line no-control-regex -- stripping ANSI escape sequences IS matching control characters
|
|
25
38
|
const stripAnsi = (s) => String(s).replace(/\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g, "");
|
|
26
39
|
|
|
27
|
-
export function createSessionRuntime({ state, markDirty, appendEvent, pushEventToStreams, notifier }) {
|
|
40
|
+
export function createSessionRuntime({ state, markDirty, appendEvent, pushEventToStreams, notifier, attachmentsDir }) {
|
|
28
41
|
const now = () => Date.now();
|
|
29
42
|
const mirrors = new Map(); // project -> { session, total, turns: Map(idx->turn), have, meta, updatedAt, host }
|
|
30
43
|
state.sessionPrompts ||= [];
|
|
@@ -32,21 +45,93 @@ export function createSessionRuntime({ state, markDirty, appendEvent, pushEventT
|
|
|
32
45
|
|
|
33
46
|
const findPrompt = (id) => state.sessionPrompts.find(p => p.id === Number(id));
|
|
34
47
|
|
|
35
|
-
//
|
|
36
|
-
|
|
48
|
+
// ---- attachments (#12161): bytes live on disk as <id>-<n>.jpg; the row keeps metadata ----
|
|
49
|
+
function attachmentPath(file) { return join(attachmentsDir, file); }
|
|
50
|
+
function unlinkAttachments(p) {
|
|
51
|
+
if (!attachmentsDir) return;
|
|
52
|
+
for (const a of p.attachments || []) { try { unlinkSync(attachmentPath(a.file)); } catch {} }
|
|
53
|
+
}
|
|
54
|
+
// Startup/sweep GC: remove files no queued-or-claimed row references (evicted rows, restarts,
|
|
55
|
+
// lost races). Cheap: a directory listing every sweep.
|
|
56
|
+
function sweepAttachments() {
|
|
57
|
+
if (!attachmentsDir) return;
|
|
58
|
+
let live;
|
|
59
|
+
try { live = new Set(state.sessionPrompts.filter(p => p.status === "queued" || p.status === "claimed")
|
|
60
|
+
.flatMap(p => (p.attachments || []).map(a => a.file))); } catch { return; }
|
|
61
|
+
let entries;
|
|
62
|
+
try { entries = readdirSync(attachmentsDir); } catch { return; }
|
|
63
|
+
for (const name of entries) {
|
|
64
|
+
if (!live.has(name)) { try { unlinkSync(attachmentPath(name)); } catch {} }
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
try { mkdirSync(attachmentsDir, { recursive: true }); } catch {}
|
|
68
|
+
sweepAttachments();
|
|
69
|
+
// Validate and decode the wire shape: [{name, data(base64 JPEG)}]. Returns rows for the prompt
|
|
70
|
+
// (files not yet written) or { error }.
|
|
71
|
+
function parseAttachments(raw) {
|
|
72
|
+
if (raw == null) return { attachments: [] };
|
|
73
|
+
if (!Array.isArray(raw) || raw.length > ATTACHMENTS_MAX) return { error: `at most ${ATTACHMENTS_MAX} attachments` };
|
|
74
|
+
const attachments = [];
|
|
75
|
+
for (const a of raw) {
|
|
76
|
+
const name = String(a?.name || "photo.jpg").slice(0, 120);
|
|
77
|
+
// Coerce, then judge the value: any non-base64 or non-JPEG bytes dies at the magic check,
|
|
78
|
+
// so the wire shape never needs a runtime type probe.
|
|
79
|
+
const data = String(a?.data ?? "");
|
|
80
|
+
if (!data.length || data.length > ATTACHMENT_B64_MAX || !/^[A-Za-z0-9+/=\s]+$/.test(data)) {
|
|
81
|
+
return { error: "attachment must be base64 JPEG of 2 MB or less" };
|
|
82
|
+
}
|
|
83
|
+
const bytes = Buffer.from(data, "base64");
|
|
84
|
+
if (!bytes.length || bytes.length > ATTACHMENT_BYTES_MAX || !bytes.subarray(0, 3).equals(JPEG_MAGIC)) {
|
|
85
|
+
return { error: "attachment must be a JPEG of 2 MB or less" };
|
|
86
|
+
}
|
|
87
|
+
attachments.push({ name, size: bytes.length, bytes });
|
|
88
|
+
}
|
|
89
|
+
return { attachments };
|
|
90
|
+
}
|
|
91
|
+
function storeAttachments(id, attachments) {
|
|
92
|
+
if (!attachmentsDir) {
|
|
93
|
+
if (attachments.length) throw new Error("attachment storage is not configured");
|
|
94
|
+
return [];
|
|
95
|
+
}
|
|
96
|
+
mkdirSync(attachmentsDir, { recursive: true });
|
|
97
|
+
return attachments.map((a, n) => {
|
|
98
|
+
const file = `${id}-${n}.jpg`;
|
|
99
|
+
writeFileSync(attachmentPath(file), a.bytes);
|
|
100
|
+
return { name: a.name, size: a.size, file };
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Queued work expires quickly; claimed work keeps its bytes while the relay renews its lease.
|
|
37
105
|
function expireQueued() {
|
|
38
106
|
let changed = false;
|
|
39
107
|
for (const p of state.sessionPrompts) {
|
|
40
|
-
|
|
41
|
-
p.status
|
|
42
|
-
|
|
108
|
+
const claimed = p.status === "claimed";
|
|
109
|
+
const from = p.status === "queued" ? p.ts : claimed ? (p.renewedAt || p.claimedAt) : 0;
|
|
110
|
+
if (!from || now() - from <= (claimed ? CLAIM_TTL_MS : PROMPT_TTL_MS)) continue;
|
|
111
|
+
p.status = "expired"; p.outcome = "expired";
|
|
112
|
+
p.error = claimed ? CLAIMED_EXPIRED_REASON : EXPIRED_REASON;
|
|
113
|
+
unlinkAttachments(p);
|
|
114
|
+
appendEvent("chat.prompt.failed", p.project, p.by, { promptId: p.id, outcome: "expired", error: p.error });
|
|
43
115
|
changed = true;
|
|
44
116
|
}
|
|
45
117
|
if (changed) markDirty();
|
|
46
118
|
return changed;
|
|
47
119
|
}
|
|
120
|
+
// Only terminal rows can be discarded; live work keeps its queue slot and attachment bytes.
|
|
121
|
+
function trimPrompts() {
|
|
122
|
+
while (state.sessionPrompts.length > PROMPT_CAP) {
|
|
123
|
+
const ti = state.sessionPrompts.findIndex(x => x.status !== "queued" && x.status !== "claimed");
|
|
124
|
+
if (ti < 0) break;
|
|
125
|
+
const [evicted] = state.sessionPrompts.splice(ti, 1);
|
|
126
|
+
unlinkAttachments(evicted);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
function queueFull() {
|
|
130
|
+
return state.sessionPrompts.filter(p => p.status === "queued" || p.status === "claimed").length >= PROMPT_CAP;
|
|
131
|
+
}
|
|
48
132
|
const sweep = setInterval(() => {
|
|
49
|
-
|
|
133
|
+
sweepAttachments();
|
|
134
|
+
if (state.sessionPrompts.some(p => p.status === "queued" || p.status === "claimed")) expireQueued();
|
|
50
135
|
}, PROMPT_SWEEP_MS);
|
|
51
136
|
sweep.unref?.();
|
|
52
137
|
|
|
@@ -140,10 +225,11 @@ export function createSessionRuntime({ state, markDirty, appendEvent, pushEventT
|
|
|
140
225
|
const isChat = P === "/session/chat";
|
|
141
226
|
const isPrompts = P === "/session/prompts";
|
|
142
227
|
const isPrompt = P === "/session/prompt" || P === "/session/prompt/claim" || P === "/session/prompt/ack";
|
|
228
|
+
const isAttachment = P === "/session/attachment";
|
|
143
229
|
const isAnswer = P === "/session/answer";
|
|
144
230
|
const isCrewCommand = P === "/crew/command";
|
|
145
231
|
const isCrewRoster = P === "/crew/roster";
|
|
146
|
-
if (!isChat && !isPrompts && !isPrompt && !isAnswer && !isCrewCommand && !isCrewRoster) return false;
|
|
232
|
+
if (!isChat && !isPrompts && !isPrompt && !isAttachment && !isAnswer && !isCrewCommand && !isCrewRoster) return false;
|
|
147
233
|
const { body, json, canon, scopeAllows } = ctx;
|
|
148
234
|
|
|
149
235
|
// #12137: the phone's answer to an AskUserQuestion joins the SAME queue as a plain prompt,
|
|
@@ -161,15 +247,13 @@ export function createSessionRuntime({ state, markDirty, appendEvent, pushEventT
|
|
|
161
247
|
const other = String(b.other || "").slice(0, ANSWER_OTHER_MAX);
|
|
162
248
|
if (!picks.length && !other.trim()) return json(res, 400, { error: "picks or other required" });
|
|
163
249
|
expireQueued();
|
|
250
|
+
if (queueFull()) return json(res, 429, { error: "prompt queue full; try again after pending work finishes" });
|
|
164
251
|
state.sessionPromptSeq += 1;
|
|
165
252
|
const text = other.trim() ? other : `picks ${picks.join(", ")}`;
|
|
166
253
|
const p = { id: state.sessionPromptSeq, project, kind: "answer", session, toolId, picks, other,
|
|
167
254
|
text, by: String(auth?.identity?.name || "").slice(0, 120), ts: now(), status: "queued" };
|
|
168
255
|
state.sessionPrompts.push(p);
|
|
169
|
-
|
|
170
|
-
const ti = state.sessionPrompts.findIndex(x => x.status !== "queued" && x.status !== "claimed");
|
|
171
|
-
state.sessionPrompts.splice(ti >= 0 ? ti : 0, 1);
|
|
172
|
-
}
|
|
256
|
+
trimPrompts();
|
|
173
257
|
markDirty();
|
|
174
258
|
appendEvent("chat.prompt.queued", project, p.by, { promptId: p.id, kind: "answer", toolId, text: text.slice(0, EVENT_TEXT_MAX) });
|
|
175
259
|
pushEventToStreams({ ts: p.ts, type: "chat.prompt", id: p.id, project });
|
|
@@ -201,6 +285,7 @@ export function createSessionRuntime({ state, markDirty, appendEvent, pushEventT
|
|
|
201
285
|
if (action === "up" && !seats.length) return json(res, 400, { error: "up needs at least one seat" });
|
|
202
286
|
if (action === "swap" && (!from || !to)) return json(res, 400, { error: "swap needs from and to" });
|
|
203
287
|
expireQueued();
|
|
288
|
+
if (queueFull()) return json(res, 429, { error: "prompt queue full; try again after pending work finishes" });
|
|
204
289
|
state.sessionPromptSeq += 1;
|
|
205
290
|
// down with an empty seat list means the whole project.
|
|
206
291
|
const text = action === "swap" ? `swap ${from} ${to}` : seats.length ? `${action} ${seats.join(" ")}` : `${action} ${project}`;
|
|
@@ -209,10 +294,7 @@ export function createSessionRuntime({ state, markDirty, appendEvent, pushEventT
|
|
|
209
294
|
if (from) p.from = from;
|
|
210
295
|
if (to) p.to = to;
|
|
211
296
|
state.sessionPrompts.push(p);
|
|
212
|
-
|
|
213
|
-
const ti = state.sessionPrompts.findIndex(x => x.status !== "queued" && x.status !== "claimed");
|
|
214
|
-
state.sessionPrompts.splice(ti >= 0 ? ti : 0, 1);
|
|
215
|
-
}
|
|
297
|
+
trimPrompts();
|
|
216
298
|
markDirty();
|
|
217
299
|
appendEvent("chat.prompt.queued", project, p.by, { promptId: p.id, kind: "crew", action, text: text.slice(0, EVENT_TEXT_MAX) });
|
|
218
300
|
pushEventToStreams({ ts: p.ts, type: "chat.prompt", id: p.id, project });
|
|
@@ -268,26 +350,41 @@ export function createSessionRuntime({ state, markDirty, appendEvent, pushEventT
|
|
|
268
350
|
const text = String(b.text || "");
|
|
269
351
|
if (!project || !text.trim()) return json(res, 400, { error: "project and non-empty text required" });
|
|
270
352
|
if (text.length > PROMPT_TEXT_MAX) return json(res, 400, { error: `text must be <= ${PROMPT_TEXT_MAX} chars` });
|
|
353
|
+
const parsed = parseAttachments(b.attachments);
|
|
354
|
+
if (parsed.error) return json(res, 400, { error: parsed.error });
|
|
271
355
|
expireQueued();
|
|
356
|
+
if (queueFull()) return json(res, 429, { error: "prompt queue full; try again after pending work finishes" });
|
|
357
|
+
const liveBytes = state.sessionPrompts
|
|
358
|
+
.filter(p => p.project === project && (p.status === "queued" || p.status === "claimed"))
|
|
359
|
+
.reduce((total, p) => total + (p.attachments || []).reduce((sum, a) => sum + a.size, 0), 0);
|
|
360
|
+
const incomingBytes = parsed.attachments.reduce((sum, a) => sum + a.bytes.length, 0);
|
|
361
|
+
if (liveBytes + incomingBytes > PROJECT_ATTACHMENT_BYTES_MAX) {
|
|
362
|
+
return json(res, 413, { error: "project queued and claimed attachments exceed 64 MiB" });
|
|
363
|
+
}
|
|
272
364
|
state.sessionPromptSeq += 1;
|
|
273
365
|
const p = { id: state.sessionPromptSeq, project, text, by: String(auth?.identity?.name || "").slice(0, 120), ts: now(), status: "queued" };
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
state.sessionPrompts.splice(ti >= 0 ? ti : 0, 1);
|
|
366
|
+
try {
|
|
367
|
+
p.attachments = storeAttachments(p.id, parsed.attachments);
|
|
368
|
+
} catch (error) {
|
|
369
|
+
return json(res, 500, { error: String(error?.message || error) });
|
|
279
370
|
}
|
|
371
|
+
state.sessionPrompts.push(p);
|
|
372
|
+
trimPrompts();
|
|
280
373
|
markDirty();
|
|
281
|
-
appendEvent("chat.prompt.queued", project, p.by,
|
|
374
|
+
if (p.attachments.length) appendEvent("chat.prompt.queued", project, p.by,
|
|
375
|
+
{ promptId: p.id, text: text.slice(0, EVENT_TEXT_MAX), attachments: p.attachments.length });
|
|
376
|
+
else appendEvent("chat.prompt.queued", project, p.by, { promptId: p.id, text: text.slice(0, EVENT_TEXT_MAX) });
|
|
282
377
|
pushEventToStreams({ ts: p.ts, type: "chat.prompt", id: p.id, project });
|
|
283
378
|
return json(res, 200, { id: p.id, status: "queued" });
|
|
284
379
|
}
|
|
285
380
|
|
|
286
381
|
// The public view of a queue row: plain prompts are kind "prompt"; answers carry their
|
|
287
382
|
// session, toolId, picks and other; crew commands carry action, seats, from and to —
|
|
288
|
-
// so the claiming desktop has everything it needs.
|
|
383
|
+
// so the claiming desktop has everything it needs. Attachments ride as metadata only;
|
|
384
|
+
// the bytes go to the claimer through GET /session/attachment.
|
|
289
385
|
const promptView = (p) => {
|
|
290
386
|
const view = { id: p.id, project: p.project, text: p.text, by: p.by, ts: p.ts, status: p.status, kind: p.kind || "prompt" };
|
|
387
|
+
if (p.attachments?.length) view.attachments = p.attachments.map(({ name, size }) => ({ name, size }));
|
|
291
388
|
if (p.kind === "answer") {
|
|
292
389
|
view.session = p.session; view.toolId = p.toolId; view.picks = p.picks;
|
|
293
390
|
if (p.other && p.other.trim()) view.other = p.other; // absent, never "", when nothing was typed
|
|
@@ -315,6 +412,15 @@ export function createSessionRuntime({ state, markDirty, appendEvent, pushEventT
|
|
|
315
412
|
expireQueued();
|
|
316
413
|
const p = findPrompt(b.id);
|
|
317
414
|
if (!p) return json(res, 404, { error: "prompt not found" });
|
|
415
|
+
// Renewal never grants a second delivery; only the original host can extend a live claim.
|
|
416
|
+
if (b.renew === true) {
|
|
417
|
+
if (p.status !== "claimed" || p.host !== String(b.host || "").slice(0, 120)) {
|
|
418
|
+
return json(res, 409, { error: "no matching live claim", status: p.status });
|
|
419
|
+
}
|
|
420
|
+
p.renewedAt = now();
|
|
421
|
+
markDirty();
|
|
422
|
+
return json(res, 200, { ok: true });
|
|
423
|
+
}
|
|
318
424
|
// The first claim wins; a second claim — even from the same host — gets 409.
|
|
319
425
|
if (p.status !== "queued") return json(res, 409, { error: `prompt already ${p.status}`, status: p.status });
|
|
320
426
|
p.status = "claimed"; p.host = String(b.host || "").slice(0, 120); p.claimedAt = now();
|
|
@@ -327,6 +433,9 @@ export function createSessionRuntime({ state, markDirty, appendEvent, pushEventT
|
|
|
327
433
|
const b = await body(req);
|
|
328
434
|
const p = findPrompt(b.id);
|
|
329
435
|
if (!p) return json(res, 404, { error: "prompt not found" });
|
|
436
|
+
if (p.status !== "claimed" || p.host !== b.host) {
|
|
437
|
+
return json(res, 409, { error: "no matching live claim", status: p.status });
|
|
438
|
+
}
|
|
330
439
|
const outcome = String(b.outcome || "");
|
|
331
440
|
// #12140: a crew command acks done|failed with the CLI output tail, and the status IS the
|
|
332
441
|
// outcome — never "delivered". Other kinds keep the delivered/blocked/… vocabulary.
|
|
@@ -335,9 +444,6 @@ export function createSessionRuntime({ state, markDirty, appendEvent, pushEventT
|
|
|
335
444
|
} else if (!ACK_OUTCOMES.has(outcome)) {
|
|
336
445
|
return json(res, 400, { error: `outcome must be one of: ${[...ACK_OUTCOMES].join(", ")}` });
|
|
337
446
|
}
|
|
338
|
-
if (p.status === "delivered" || p.status === "done" || p.status === "failed" || p.status === "expired") {
|
|
339
|
-
return json(res, 409, { error: `prompt already ${p.status}`, status: p.status });
|
|
340
|
-
}
|
|
341
447
|
const error = String(b.error || "").slice(0, EVENT_TEXT_MAX);
|
|
342
448
|
p.outcome = outcome; p.error = error; p.ackedAt = now();
|
|
343
449
|
if (p.kind === "crew") {
|
|
@@ -346,6 +452,8 @@ export function createSessionRuntime({ state, markDirty, appendEvent, pushEventT
|
|
|
346
452
|
} else {
|
|
347
453
|
p.status = outcome === "delivered" ? "delivered" : "failed";
|
|
348
454
|
}
|
|
455
|
+
// A terminal row will never be claimed or downloaded again: its bytes go now.
|
|
456
|
+
unlinkAttachments(p);
|
|
349
457
|
markDirty();
|
|
350
458
|
const extra = { promptId: p.id, outcome };
|
|
351
459
|
if (error) extra.error = error;
|
|
@@ -361,6 +469,21 @@ export function createSessionRuntime({ state, markDirty, appendEvent, pushEventT
|
|
|
361
469
|
return json(res, 200, { id: p.id, status: p.status, outcome: p.outcome || "", output: p.output || "", error: p.error || "" });
|
|
362
470
|
}
|
|
363
471
|
|
|
472
|
+
// The claiming desktop fetches one attachment's bytes. Owner-gated in auth.mjs, absent from
|
|
473
|
+
// the public listener's allowlist — phones upload, they never read back.
|
|
474
|
+
if (req.method === "GET" && P === "/session/attachment") {
|
|
475
|
+
const p = findPrompt(q.id);
|
|
476
|
+
const n = Number(q.n);
|
|
477
|
+
const meta = p && Number.isInteger(n) ? (p.attachments || [])[n] : null;
|
|
478
|
+
let bytes = null;
|
|
479
|
+
if (meta && attachmentsDir) { try { bytes = readFileSync(attachmentPath(meta.file)); } catch {} }
|
|
480
|
+
if (!bytes) return json(res, 404, { error: "attachment not found" });
|
|
481
|
+
if (res.headersSent || res.writableEnded || res.destroyed) return true;
|
|
482
|
+
res.writeHead(200, { "content-type": "image/jpeg", "content-length": bytes.length });
|
|
483
|
+
res.end(bytes);
|
|
484
|
+
return true;
|
|
485
|
+
}
|
|
486
|
+
|
|
364
487
|
return false;
|
|
365
488
|
}
|
|
366
489
|
|
package/hub.mjs
CHANGED
|
@@ -134,6 +134,7 @@ const context = {
|
|
|
134
134
|
const sessionChat = createSessionRuntime({
|
|
135
135
|
state: store.state, markDirty: store.markDirty,
|
|
136
136
|
appendEvent: events.appendEvent, pushEventToStreams: events.pushEventToStreams, notifier,
|
|
137
|
+
attachmentsDir: join(DATA_DIR, "attachments"),
|
|
137
138
|
});
|
|
138
139
|
const routes = [routeDevices, routeAdmin, routeCards, routeInsights, routeMessages, sessionChat.route];
|
|
139
140
|
|
|
@@ -150,14 +151,27 @@ function requestHandler(runtime, ctx, gate = null) {
|
|
|
150
151
|
const q = Object.fromEntries(u.searchParams);
|
|
151
152
|
const P = u.pathname;
|
|
152
153
|
if (gate && !gate.admit(req, res, P)) return;
|
|
154
|
+
const cap = gate ? runtime.bodyCap(P) : runtime.ownerBodyCap(req, P);
|
|
155
|
+
let ownerAuth;
|
|
156
|
+
if (req.method === "POST") {
|
|
157
|
+
const raw = await runtime.rawBody(req, cap);
|
|
158
|
+
if (raw === undefined) return runtime.json(res, 413, { error: `request body over the ${cap} byte cap` });
|
|
159
|
+
if (req._bodyBytes > runtime.bodyCap(P)) {
|
|
160
|
+
ownerAuth = await runtime.authenticate(req, runtime.authPath(u), true);
|
|
161
|
+
if (!ownerAuth.ok) return runtime.json(res, ownerAuth.code || 401, { error: ownerAuth.error });
|
|
162
|
+
}
|
|
163
|
+
}
|
|
153
164
|
const b0 = req.method === "POST" ? await runtime.body(req) : {};
|
|
165
|
+
if (ownerAuth && !runtime.scopeAllows(ownerAuth.identity, runtime.projectFromRequest(P, q, b0, req.method), "owner")) {
|
|
166
|
+
return runtime.json(res, 403, { error: "large request body requires a project owner" });
|
|
167
|
+
}
|
|
154
168
|
if (runtime.handleEnrollment({ req, res, P, u, b0, inviteOnly: !!gate })) return;
|
|
155
|
-
const auth = runtime.PUBLIC_ENDPOINTS.has(P)
|
|
169
|
+
const auth = ownerAuth || (runtime.PUBLIC_ENDPOINTS.has(P)
|
|
156
170
|
? { ok: true, mode: ctx.AUTH_MODE, trusted: false }
|
|
157
|
-
: await runtime.authenticate(req, runtime.authPath(u));
|
|
171
|
+
: await runtime.authenticate(req, runtime.authPath(u)));
|
|
158
172
|
if (runtime.handleInvite({ req, res, P, auth, b0 })) return;
|
|
159
173
|
if (!auth.ok) return runtime.json(res, auth.code || 401, { error: auth.error || "unauthorized" });
|
|
160
|
-
const authorization = runtime.authorize(auth, req.method, P, runtime.projectFromRequest(P, q, b0));
|
|
174
|
+
const authorization = runtime.authorize(auth, req.method, P, runtime.projectFromRequest(P, q, b0, req.method));
|
|
161
175
|
if (!authorization.ok) return runtime.json(res, authorization.code || 403, { error: authorization.error || "forbidden" });
|
|
162
176
|
if (gate && P === "/stream" && !gate.openStream(req, res)) return;
|
|
163
177
|
if (req.method === "GET" && P === "/_op") {
|
|
@@ -181,6 +195,8 @@ server.listen(PORT, HOST, () => console.error(`[trantor] hub on http://${HOST}:$
|
|
|
181
195
|
|
|
182
196
|
if (process.env.RELAY_PUBLIC_PORT) {
|
|
183
197
|
const publicPort = Number(process.env.RELAY_PUBLIC_PORT);
|
|
184
|
-
const
|
|
198
|
+
const publicGate = createPublicGate({ canUpload: pubkey =>
|
|
199
|
+
publicAuth.scopeAllows(publicAuth.findIdentity(pubkey), "", "write") });
|
|
200
|
+
const publicServer = http.createServer(requestHandler(publicAuth, publicContext, publicGate));
|
|
185
201
|
publicServer.listen(publicPort, "127.0.0.1", () => console.error(`[trantor] public hub on http://127.0.0.1:${publicServer.address().port} (auth: enforce)`));
|
|
186
202
|
}
|
package/lib/disk.mjs
CHANGED
|
@@ -4,8 +4,10 @@
|
|
|
4
4
|
// REPORT-only; our own deletions are fs.rm on paths asserted under $HOME, never rm -rf.
|
|
5
5
|
import { spawnSync } from "node:child_process";
|
|
6
6
|
import * as nodeFs from "node:fs";
|
|
7
|
-
import { join, resolve, sep, basename, dirname } from "node:path";
|
|
7
|
+
import { join, resolve, sep, basename, dirname, relative } from "node:path";
|
|
8
8
|
import { homedir } from "node:os";
|
|
9
|
+
import { readRows } from "../bin/crew/state.mjs";
|
|
10
|
+
import { seatProcesses } from "../bin/crew/processes.mjs";
|
|
9
11
|
|
|
10
12
|
export const DAY = 24 * 60 * 60 * 1000;
|
|
11
13
|
export const BUILD_DIRS = [".next", "target", "desktop/src-tauri/target"];
|
|
@@ -14,6 +16,12 @@ export const SESSION_IDLE_MAX = 14 * DAY;
|
|
|
14
16
|
// the 10-10 incident was 27 GB of it. Top-level entries untouched this long are safe tier.
|
|
15
17
|
export const SCRATCH_DIRS = [".agent-bus-out", ".scratch"];
|
|
16
18
|
export const SCRATCH_IDLE_MAX = 2 * DAY;
|
|
19
|
+
// #12269: whole seat worktrees idle this long with no uncommitted changes are pruned;
|
|
20
|
+
// build output parked inside the scratch dirs gets its own, shorter window.
|
|
21
|
+
export const WORKTREE_IDLE_MAX = 7 * DAY;
|
|
22
|
+
export const BUILD_OUTPUT_IDLE_MAX = 3 * DAY;
|
|
23
|
+
export const BUILD_OUTPUT_NAMES = ["DerivedData", "target"];
|
|
24
|
+
export const BUILD_OUTPUT_SUFFIX = ".xcresult";
|
|
17
25
|
|
|
18
26
|
const GB = 1024 ** 3;
|
|
19
27
|
const KB = 1024;
|
|
@@ -54,10 +62,17 @@ function defaults(d = {}) {
|
|
|
54
62
|
home: d.home || homedir(),
|
|
55
63
|
now: d.now || Date.now(),
|
|
56
64
|
worktreesRoot: d.worktreesRoot || null,
|
|
65
|
+
log: d.log || console.log,
|
|
57
66
|
entryBudget: d.entryBudget || 400_000,
|
|
58
67
|
};
|
|
59
68
|
}
|
|
60
69
|
|
|
70
|
+
// The seat worktrees root: injected in tests, else the real one under HOME. Every delete
|
|
71
|
+
// below this line re-checks its path is under this root before touching anything.
|
|
72
|
+
export function worktreesRootFor(d) {
|
|
73
|
+
return d.worktreesRoot || join(d.home || homedir(), ".agent-bus", "worktrees");
|
|
74
|
+
}
|
|
75
|
+
|
|
61
76
|
// Recursive size walk. Skips symlinks entirely — a worktree symlink must not make
|
|
62
77
|
// us measure (or later rm) outside its root. Budget caps pathological trees. Dirs are
|
|
63
78
|
// statted too: a rename into an old tree updates the DIR mtime, not the file's, and
|
|
@@ -109,24 +124,26 @@ export function gitTracked(exec, worktree, rel) {
|
|
|
109
124
|
return gitOut(exec, ["-C", worktree, "ls-files", "--", rel]).trim().length > 0;
|
|
110
125
|
}
|
|
111
126
|
|
|
112
|
-
//
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
export function turnStampPath(home, project, seat) {
|
|
116
|
-
return join(home, ".agent-bus", `turnstamp-${seat}-${project}.json`);
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
function seatTurnLive(fs, home, now, project, seat) {
|
|
127
|
+
// #12269: a seat is dead only when both crew ownership and its runner processes are gone.
|
|
128
|
+
function seatRetentionReason(d, project, seat, worktree) {
|
|
129
|
+
const bus = join(d.home, ".agent-bus");
|
|
120
130
|
try {
|
|
121
|
-
const
|
|
122
|
-
|
|
123
|
-
|
|
131
|
+
const ctx = { statePath: join(bus, "crew-windows.txt"), seatDir: join(bus, "seats"), env: {} };
|
|
132
|
+
if (readRows(ctx).some(row => row.project === project && row.persona === seat)) {
|
|
133
|
+
return `KEPT: ${seat}:${project} has a crew state row`;
|
|
134
|
+
}
|
|
135
|
+
const trees = gitOut(d.exec, ["-C", worktree, "worktree", "list", "--porcelain"]);
|
|
136
|
+
ctx.dir = trees.match(/^worktree (.+)$/m)?.[1] || worktree;
|
|
137
|
+
if (seatProcesses(ctx, project, seat, d.exec).length) return `KEPT: ${seat}:${project} has live crew processes`;
|
|
138
|
+
return "";
|
|
139
|
+
} catch (error) {
|
|
140
|
+
return `KEPT: cannot establish crew liveness for ${seat}:${project}: ${error.message}`;
|
|
141
|
+
}
|
|
124
142
|
}
|
|
125
143
|
|
|
126
144
|
export function scanSeatWorktrees(d) {
|
|
127
145
|
const { fs, exec, home, now } = d;
|
|
128
|
-
const root = d
|
|
129
|
-
const live = liveCommandLines(exec);
|
|
146
|
+
const root = worktreesRootFor(d);
|
|
130
147
|
const seats = [], plan = [], kept = [];
|
|
131
148
|
let projects;
|
|
132
149
|
try { projects = fs.readdirSync(root, { withFileTypes: true }).filter((e) => e.isDirectory()); }
|
|
@@ -137,10 +154,8 @@ export function scanSeatWorktrees(d) {
|
|
|
137
154
|
catch { continue; }
|
|
138
155
|
for (const seat of seatDirs) {
|
|
139
156
|
const wt = join(root, proj.name, seat.name);
|
|
140
|
-
const
|
|
141
|
-
const
|
|
142
|
-
? "KEPT: a live seat process uses this worktree"
|
|
143
|
-
: "KEPT: the seat's turn stamp is fresh — a turn may be live";
|
|
157
|
+
const liveReason = seatRetentionReason(d, proj.name, seat.name, wt);
|
|
158
|
+
const isLive = Boolean(liveReason);
|
|
144
159
|
const size = dirSize(fs, wt, d);
|
|
145
160
|
seats.push({ project: proj.name, seat: seat.name, path: wt, bytes: size.bytes, live: isLive });
|
|
146
161
|
for (const rel of BUILD_DIRS) {
|
|
@@ -180,6 +195,101 @@ export function scanSeatWorktrees(d) {
|
|
|
180
195
|
return { root, seats, plan, kept };
|
|
181
196
|
}
|
|
182
197
|
|
|
198
|
+
// #12269: old clean worktrees can go only after crew state and processes both release the seat.
|
|
199
|
+
export function scanStaleWorktrees(d) {
|
|
200
|
+
const { fs, exec, home, now } = d;
|
|
201
|
+
const root = worktreesRootFor(d);
|
|
202
|
+
const plan = [], kept = [];
|
|
203
|
+
let projects;
|
|
204
|
+
try { projects = fs.readdirSync(root, { withFileTypes: true }).filter((e) => e.isDirectory()); }
|
|
205
|
+
catch { return { root, plan, kept }; }
|
|
206
|
+
for (const proj of projects) {
|
|
207
|
+
let seatDirs;
|
|
208
|
+
try { seatDirs = fs.readdirSync(join(root, proj.name), { withFileTypes: true }).filter((e) => e.isDirectory()); }
|
|
209
|
+
catch { continue; }
|
|
210
|
+
for (const seat of seatDirs) {
|
|
211
|
+
const wt = join(root, proj.name, seat.name);
|
|
212
|
+
const reason = seatRetentionReason(d, proj.name, seat.name, wt);
|
|
213
|
+
if (reason) {
|
|
214
|
+
kept.push({ path: wt, reason });
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
if (gitOut(exec, ["-C", wt, "status", "--porcelain"]).trim()) {
|
|
218
|
+
kept.push({ path: wt, reason: `KEPT: ${proj.name}/${seat.name} has uncommitted changes` });
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
const size = dirSize(fs, wt, d);
|
|
222
|
+
if (size.maxMtime > now - WORKTREE_IDLE_MAX) {
|
|
223
|
+
kept.push({ path: wt, reason: `KEPT: ${proj.name}/${seat.name} was touched within ${WORKTREE_IDLE_MAX / DAY} days` });
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
plan.push({ type: "seat-worktree", path: wt, project: proj.name, seat: seat.name, bytes: size.bytes });
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
plan.sort((a, b) => b.bytes - a.bytes);
|
|
230
|
+
return { root, plan, kept };
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// #12269: build output parked INSIDE a seat's scratch dirs — DerivedData, target/, *.xcresult —
|
|
234
|
+
// idle past the build-output window is deletable even when it is not a top-level scratch entry
|
|
235
|
+
// (that case the 2-day scratch rule already owns). Top-level entries are never matched here, so
|
|
236
|
+
// the two scans cannot plan the same path. First match wins per subtree; git-tracked paths stay.
|
|
237
|
+
export function scanNestedBuildOutput(d) {
|
|
238
|
+
const { fs, exec, home, now } = d;
|
|
239
|
+
const root = worktreesRootFor(d);
|
|
240
|
+
const plan = [], kept = [];
|
|
241
|
+
const isOutputDir = (name) => BUILD_OUTPUT_NAMES.includes(name) || name.endsWith(BUILD_OUTPUT_SUFFIX);
|
|
242
|
+
let projects;
|
|
243
|
+
try { projects = fs.readdirSync(root, { withFileTypes: true }).filter((e) => e.isDirectory()); }
|
|
244
|
+
catch { return { plan, kept }; }
|
|
245
|
+
for (const proj of projects) {
|
|
246
|
+
let seatDirs;
|
|
247
|
+
try { seatDirs = fs.readdirSync(join(root, proj.name), { withFileTypes: true }).filter((e) => e.isDirectory()); }
|
|
248
|
+
catch { continue; }
|
|
249
|
+
for (const seat of seatDirs) {
|
|
250
|
+
const wt = join(root, proj.name, seat.name);
|
|
251
|
+
const reason = seatRetentionReason(d, proj.name, seat.name, wt);
|
|
252
|
+
if (reason) { kept.push({ path: wt, reason }); continue; }
|
|
253
|
+
for (const scratch of SCRATCH_DIRS) {
|
|
254
|
+
const dir = join(wt, scratch);
|
|
255
|
+
const stack = [];
|
|
256
|
+
try {
|
|
257
|
+
for (const top of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
258
|
+
if (!top.isDirectory()) continue;
|
|
259
|
+
let entries;
|
|
260
|
+
try { entries = fs.readdirSync(join(dir, top.name), { withFileTypes: true }); } catch { continue; }
|
|
261
|
+
for (const e of entries) if (e.isDirectory()) stack.push(join(dir, top.name, e.name));
|
|
262
|
+
}
|
|
263
|
+
} catch { continue; }
|
|
264
|
+
let seen = 0;
|
|
265
|
+
while (stack.length) {
|
|
266
|
+
if (seen++ > d.entryBudget) break;
|
|
267
|
+
const p = stack.pop();
|
|
268
|
+
if (!isOutputDir(basename(p))) {
|
|
269
|
+
let entries;
|
|
270
|
+
try { entries = fs.readdirSync(p, { withFileTypes: true }); } catch { continue; }
|
|
271
|
+
for (const e of entries) if (e.isDirectory()) stack.push(join(p, e.name));
|
|
272
|
+
continue;
|
|
273
|
+
}
|
|
274
|
+
let st;
|
|
275
|
+
try { st = fs.statSync(p); } catch { continue; }
|
|
276
|
+
if (!st.isDirectory()) continue;
|
|
277
|
+
const rel = relative(wt, p);
|
|
278
|
+
if (gitTracked(exec, wt, rel)) { kept.push({ path: p, reason: `KEPT: ${rel} is git-tracked in ${proj.name}/${seat.name}` }); continue; }
|
|
279
|
+
const stat = dirSize(fs, p, d);
|
|
280
|
+
if (stat.maxMtime > now - BUILD_OUTPUT_IDLE_MAX) {
|
|
281
|
+
kept.push({ path: p, reason: `KEPT: ${rel} was touched within ${BUILD_OUTPUT_IDLE_MAX / DAY} days` });
|
|
282
|
+
continue;
|
|
283
|
+
}
|
|
284
|
+
plan.push({ type: "build-output", path: p, worktree: wt, project: proj.name, seat: seat.name, bytes: stat.bytes });
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
plan.sort((a, b) => b.bytes - a.bytes);
|
|
290
|
+
return { plan, kept };
|
|
291
|
+
}
|
|
292
|
+
|
|
183
293
|
export function scanOpencodeSessions(d) {
|
|
184
294
|
const { exec, now } = d;
|
|
185
295
|
const live = liveCommandLines(exec);
|
|
@@ -345,6 +455,8 @@ export function buildDiskReport(d) {
|
|
|
345
455
|
const { fs, home, now } = dep;
|
|
346
456
|
const space = freeSpace(fs, "/");
|
|
347
457
|
const seats = scanSeatWorktrees({ ...dep, entryBudget: dep.entryBudget });
|
|
458
|
+
const stale = scanStaleWorktrees({ ...dep, entryBudget: dep.entryBudget });
|
|
459
|
+
const nested = scanNestedBuildOutput({ ...dep, entryBudget: dep.entryBudget });
|
|
348
460
|
const sessions = scanOpencodeSessions(dep);
|
|
349
461
|
const sims = scanSimulators(dep);
|
|
350
462
|
const dbPath = join(home, ".local", "share", "opencode", "opencode.db");
|
|
@@ -360,16 +472,17 @@ export function buildDiskReport(d) {
|
|
|
360
472
|
hf: reportHfCache(dep),
|
|
361
473
|
claudeWorktrees: reportClaudeWorktrees(dep),
|
|
362
474
|
bigFiles: collectBigFiles(dep),
|
|
363
|
-
plan: [...sims.plan, ...seats.plan, ...sessions.plan],
|
|
364
|
-
kept: [...seats.kept, ...sessions.kept],
|
|
475
|
+
plan: [...sims.plan, ...seats.plan, ...stale.plan, ...nested.plan, ...sessions.plan],
|
|
476
|
+
kept: [...seats.kept, ...stale.kept, ...nested.kept, ...sessions.kept],
|
|
365
477
|
};
|
|
366
478
|
return report;
|
|
367
479
|
}
|
|
368
480
|
|
|
369
|
-
// Bytes the safe tier's ON-DISK items (build dirs, scratch entries
|
|
370
|
-
// the summary line's number. simctl/opencode items free space
|
|
481
|
+
// Bytes the safe tier's ON-DISK items (build dirs, scratch entries, whole worktrees, nested
|
|
482
|
+
// build output) would free — the summary line's number. simctl/opencode items free space
|
|
483
|
+
// inside their own stores.
|
|
371
484
|
export function freedOnDiskBytes(plan) {
|
|
372
|
-
return (plan || []).filter((p) =>
|
|
485
|
+
return (plan || []).filter((p) => ["build-dir", "scratch-entry", "seat-worktree", "build-output"].includes(p.type)).reduce((n, p) => n + (p.bytes || 0), 0);
|
|
373
486
|
}
|
|
374
487
|
|
|
375
488
|
function fileSize(fs, p) {
|
|
@@ -408,25 +521,44 @@ export function formatHuman(r) {
|
|
|
408
521
|
return `${L.join("\n")}\n`;
|
|
409
522
|
}
|
|
410
523
|
|
|
411
|
-
// Execute the safe tier.
|
|
412
|
-
//
|
|
413
|
-
//
|
|
524
|
+
// Execute the safe tier. Every planned item is PRINTED before it is acted on (#12269), so the
|
|
525
|
+
// log is a receipt, not a mystery. simctl/opencode run as their own CLIs (injected in tests);
|
|
526
|
+
// on-disk items are removed by the injected `remove` (default fs.rmSync) or `git worktree
|
|
527
|
+
// remove`, only after assertUnderHome AND an is-under-worktrees-root re-check on the exact path.
|
|
414
528
|
export async function runClean(r, d) {
|
|
415
529
|
const dep = defaults(d);
|
|
416
|
-
const { exec, home } = dep;
|
|
530
|
+
const { exec, home, log } = dep;
|
|
417
531
|
const remove = dep.remove || ((p, opts) => dep.fs.rmSync(p, opts));
|
|
532
|
+
const root = worktreesRootFor(dep);
|
|
418
533
|
const results = [];
|
|
419
534
|
for (const item of r.plan) {
|
|
535
|
+
const label = item.path || (item.type === "opencode-session" ? `session ${item.id}` : item.type === "simulators" ? "unavailable simulators" : item.type);
|
|
536
|
+
log(`janitor: removing ${item.type} ${label}${item.bytes ? ` (~${fmtBytes(item.bytes)})` : ""}`);
|
|
420
537
|
try {
|
|
538
|
+
if (item.path) {
|
|
539
|
+
const p = assertUnderHome(item.path, home);
|
|
540
|
+
if (!isUnder(p, root)) throw new Error(`refused: ${p} is not under the seat worktrees root`);
|
|
541
|
+
const [project, seat] = relative(root, p).split(sep);
|
|
542
|
+
if (!project || !seat) throw new Error("refused: path does not identify a seat worktree");
|
|
543
|
+
const reason = seatRetentionReason(dep, project, seat, join(root, project, seat));
|
|
544
|
+
if (reason) throw new Error(reason);
|
|
545
|
+
}
|
|
421
546
|
if (item.type === "simulators") {
|
|
422
547
|
exec("xcrun", ["simctl", "delete", "unavailable"]);
|
|
423
548
|
results.push({ type: item.type, ok: true });
|
|
424
549
|
} else if (item.type === "opencode-session") {
|
|
425
550
|
exec("opencode", ["session", "delete", item.id]);
|
|
426
551
|
results.push({ type: item.type, id: item.id, ok: true });
|
|
427
|
-
} else if (item.type === "
|
|
552
|
+
} else if (item.type === "seat-worktree") {
|
|
553
|
+
const p = assertUnderHome(item.path, home);
|
|
554
|
+
if (!isUnder(p, root)) throw new Error(`refused: ${p} is not under the seat worktrees root`);
|
|
555
|
+
if (gitOut(exec, ["-C", p, "status", "--porcelain"]).trim()) throw new Error("worktree has uncommitted changes");
|
|
556
|
+
const g = exec("git", ["-C", p, "worktree", "remove", p]);
|
|
557
|
+
if (g.status !== 0) throw new Error(String(g.stderr || "git worktree remove failed").trim().split("\n")[0] || "git worktree remove failed");
|
|
558
|
+
results.push({ type: item.type, path: p, bytes: item.bytes || 0, ok: true });
|
|
559
|
+
} else if (item.type === "build-dir" || item.type === "build-output" || item.type === "scratch-entry") {
|
|
428
560
|
const p = assertUnderHome(item.path, home);
|
|
429
|
-
if (!isUnder(p,
|
|
561
|
+
if (!isUnder(p, root)) throw new Error(`refused: ${p} is not under the seat worktrees root`);
|
|
430
562
|
remove(p, { recursive: true, force: true });
|
|
431
563
|
results.push({ type: item.type, path: p, bytes: item.bytes || 0, ok: true });
|
|
432
564
|
}
|
|
@@ -438,6 +570,7 @@ export async function runClean(r, d) {
|
|
|
438
570
|
}
|
|
439
571
|
|
|
440
572
|
export function launchdPlist({ nodePath, scriptPath, logPath }) {
|
|
573
|
+
// #12269: daily at 09:00 — the weekly cadence lost the race against 100 GB/week of seat junk.
|
|
441
574
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
442
575
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
443
576
|
<plist version="1.0">
|
|
@@ -447,11 +580,10 @@ export function launchdPlist({ nodePath, scriptPath, logPath }) {
|
|
|
447
580
|
<array>
|
|
448
581
|
<string>${nodePath}</string>
|
|
449
582
|
<string>${scriptPath}</string>
|
|
450
|
-
<string>--
|
|
583
|
+
<string>--daily</string>
|
|
451
584
|
</array>
|
|
452
585
|
<key>StartCalendarInterval</key>
|
|
453
586
|
<dict>
|
|
454
|
-
<key>Weekday</key><integer>1</integer>
|
|
455
587
|
<key>Hour</key><integer>9</integer>
|
|
456
588
|
<key>Minute</key><integer>0</integer>
|
|
457
589
|
</dict>
|
|
@@ -464,7 +596,8 @@ export function launchdPlist({ nodePath, scriptPath, logPath }) {
|
|
|
464
596
|
|
|
465
597
|
export async function runDisk(d = {}) {
|
|
466
598
|
const dep = defaults(d);
|
|
467
|
-
|
|
599
|
+
// --dry-run always wins: report only, never clean — whichever flag asked for cleaning.
|
|
600
|
+
const clean = d.args?.includes("--clean") && !d.args?.includes("--dry-run");
|
|
468
601
|
const json = d.args?.includes("--json");
|
|
469
602
|
const report = buildDiskReport(dep);
|
|
470
603
|
const results = clean ? await runClean(report, dep) : [];
|
package/lib/seat-worktree.mjs
CHANGED
|
@@ -66,10 +66,24 @@ export function ensureSeatWorktree({ sourceDir, project, agent, home = homedir()
|
|
|
66
66
|
}
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
-
|
|
69
|
+
// #12269: a branch that already exists is the seat's history — down removed the worktree, not
|
|
70
|
+
// the branch. Reset it to HEAD only when it holds nothing HEAD lacks (fully harvested); else
|
|
71
|
+
// re-create at the branch tip so a re-upped seat name reuses the freed worktree with its commits.
|
|
72
|
+
const branchTip = gitOut(["-C", root, "rev-parse", "--verify", "--quiet", `refs/heads/${branch}`], root);
|
|
73
|
+
const add = ["worktree", "add"];
|
|
74
|
+
let reuseBranch = false;
|
|
75
|
+
if (branchTip) {
|
|
76
|
+
const merged = spawnSync("git", ["-C", root, "merge-base", "--is-ancestor", branch, "HEAD"], { stdio: "ignore", timeout: 8000 }).status === 0;
|
|
77
|
+
if (merged) add.push("--no-track", "-B", branch, seatDir, "HEAD");
|
|
78
|
+
else { add.push(seatDir, branch); reuseBranch = true; }
|
|
79
|
+
} else {
|
|
80
|
+
add.push("--no-track", "-B", branch, seatDir, "HEAD");
|
|
81
|
+
}
|
|
82
|
+
const r = spawnSync("git", ["-C", root, ...add], {
|
|
70
83
|
encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 30000,
|
|
71
84
|
});
|
|
72
85
|
if (r.status === 0) {
|
|
86
|
+
if (reuseBranch) log(`\x1b[2m[runner]\x1b[0m ${branch} re-created at its own tip (${branchTip.slice(0, 7)}) — unintegrated commits kept`);
|
|
73
87
|
// branch.<b>.base names the real base for the Review lens; push.autoSetupRemote makes the
|
|
74
88
|
// first plain push set its upstream (#5403).
|
|
75
89
|
if (base) spawnSync("git", ["-C", seatDir, "config", `branch.${branch}.base`, base], { stdio: "ignore", timeout: 5000 });
|
|
@@ -77,10 +91,33 @@ export function ensureSeatWorktree({ sourceDir, project, agent, home = homedir()
|
|
|
77
91
|
if (!pushAuto) spawnSync("git", ["-C", seatDir, "config", "push.autoSetupRemote", "true"], { stdio: "ignore", timeout: 5000 });
|
|
78
92
|
return { dir: seatDir, created: true, root, dirty: false };
|
|
79
93
|
}
|
|
94
|
+
if (reuseBranch) {
|
|
95
|
+
const reason = String(r.stderr || "").trim().split("\n")[0] || "git worktree add failed";
|
|
96
|
+
log(`\x1b[33m[runner]\x1b[0m ${branch} holds unintegrated commits and could not be re-created (${reason}) — using ${sourceDir} rather than resetting the branch`);
|
|
97
|
+
return { dir: sourceDir, created: false };
|
|
98
|
+
}
|
|
80
99
|
log(`\x1b[33m[runner]\x1b[0m could not create ${branch} worktree — using ${sourceDir}`);
|
|
81
100
|
return { dir: sourceDir, created: false };
|
|
82
101
|
}
|
|
83
102
|
|
|
103
|
+
// #12269: down/retire removes a stopped seat's worktree. The branch always stays; a tree with
|
|
104
|
+
// uncommitted changes is kept and named — git's own refusal is the race-free dirty check.
|
|
105
|
+
export function removeSeatWorktree({ project, agent, home = homedir(), env = process.env, dry = false }) {
|
|
106
|
+
const path = seatWorktreeDir(project, agent, home);
|
|
107
|
+
if (!existsSync(path)) return { status: "absent", path };
|
|
108
|
+
if (env?.CREW_NO_PROC_KILL === "1") return { status: "kept", path, reason: "CREW_NO_PROC_KILL=1 (process kill and removals guarded)" };
|
|
109
|
+
if (dry) return { status: "would-remove", path };
|
|
110
|
+
const r = spawnSync("git", ["-C", path, "worktree", "remove", path], {
|
|
111
|
+
encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 60000,
|
|
112
|
+
});
|
|
113
|
+
if (r.status === 0) return { status: "removed", path };
|
|
114
|
+
const dirty = spawnSync("git", ["-C", path, "status", "--porcelain"], {
|
|
115
|
+
encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 30000,
|
|
116
|
+
}).stdout || "";
|
|
117
|
+
if (dirty.trim()) return { status: "kept", path, reason: "uncommitted changes" };
|
|
118
|
+
return { status: "kept", path, reason: String(r.stderr || "git worktree remove failed").trim().split("\n")[0] };
|
|
119
|
+
}
|
|
120
|
+
|
|
84
121
|
// The declaration: { link: [<relative sibling path>], provision: [{ path, mode, template? }],
|
|
85
122
|
// preflight: "<shell command>" }. null when the project declares nothing (unchanged behaviour).
|
|
86
123
|
export function readWorktreeDeclaration(root) {
|