toolroll 0.8.1 → 0.8.2

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.
@@ -0,0 +1,84 @@
1
+ import { type CheckoutCleanup } from "./storage.js";
2
+ import type { Store } from "./store.js";
3
+ import { type WorktreePool } from "./worktree.js";
4
+ /** A release candidate's checkout holds the build a deploy installs: kept a week after its task was done. */
5
+ export declare const CANDIDATE_KEEP_MS: number;
6
+ /** The worker's pass removes at most this many; a clean-up by hand has no cap. */
7
+ export declare const AUTO_MAX = 20;
8
+ export type KeepWhy = "in use" | "task not finished" | "waiting for review" | "release candidate" | "has changes" | "has commits" | "unreadable" | "git refused" | "not due yet" | "cleanup is off" | "no task";
9
+ export type CheckoutItem = {
10
+ path: string;
11
+ repo: string;
12
+ branch: string;
13
+ taskId: string | null;
14
+ bytes: number;
15
+ releasedAt: string | null;
16
+ /** null: it goes. */
17
+ why: KeepWhy | null;
18
+ /** With "not due yet" or "release candidate": when it may go. */
19
+ dueAt?: string;
20
+ };
21
+ export type CheckoutPlan = {
22
+ at: string;
23
+ cleanup: CheckoutCleanup;
24
+ manual: boolean;
25
+ go: CheckoutItem[];
26
+ stay: CheckoutItem[];
27
+ /** Every checkout on disk. */
28
+ count: number;
29
+ totalBytes: number;
30
+ freeBytes: number;
31
+ waitingReview: number;
32
+ withChanges: number;
33
+ };
34
+ type Status = {
35
+ keep: KeepWhy | null;
36
+ taskId: string;
37
+ finishedAt: string | null;
38
+ dueAt?: string;
39
+ };
40
+ /** Where every task stands for cleanup, by task_ref id and by each branch it or its runs used. */
41
+ export declare function taskStatuses(store: Store, now: Date): {
42
+ byRef: Map<number, Status>;
43
+ byBranch: Map<string, Status>;
44
+ };
45
+ /**
46
+ * What a clean-up would remove now, how much space it would free, and what stays and why. `manual` is a clean-up by
47
+ * hand (it doesn't wait for the setting); otherwise it is the worker's pass under the setting. Reads only.
48
+ */
49
+ export declare function checkoutPlan(store: Store, pool: WorktreePool, now: Date, options: {
50
+ manual: boolean;
51
+ repo?: string;
52
+ }): Promise<CheckoutPlan>;
53
+ /** Names exactly what a preview would remove, so a confirmation removes that and nothing else. */
54
+ export declare function previewDigest(plan: Pick<CheckoutPlan, "go">): string;
55
+ export type CleanResult = {
56
+ removed: CheckoutItem[];
57
+ kept: {
58
+ path: string;
59
+ why: KeepWhy;
60
+ }[];
61
+ freed: number;
62
+ };
63
+ /**
64
+ * Remove what may go: the worker's pass (`manual` false: under the setting, at most AUTO_MAX, one repository) or a
65
+ * clean-up by hand (no cap, every repository). Each checkout is asked again right before it goes; each removal is in
66
+ * the ledger.
67
+ */
68
+ export declare function cleanCheckouts(store: Store, pool: WorktreePool, now: () => Date, options: {
69
+ manual: boolean;
70
+ actor: string;
71
+ repo?: string;
72
+ only?: ReadonlySet<string>;
73
+ }): Promise<CleanResult>;
74
+ /** Throw away a checkout kept for its changes, on purpose; its branch stays. In the ledger. */
75
+ export declare function discardCheckout(store: Store, pool: WorktreePool, path: string, now: Date, actor: string): Promise<{
76
+ ok: true;
77
+ bytes: number;
78
+ } | {
79
+ ok: false;
80
+ message: string;
81
+ }>;
82
+ /** "Waiting for review", "Has changes" … as a sentence fragment for a person. */
83
+ export declare function whyWords(item: Pick<CheckoutItem, "why" | "dueAt">): string;
84
+ export {};
@@ -0,0 +1,245 @@
1
+ /**
2
+ * Checkout cleanup (Settings → Storage, `toolroll storage clean`): which task checkouts can go, and removing them.
3
+ *
4
+ * A finished task's clean checkout goes as the setting says: when its task is complete or cancelled (the default),
5
+ * 2 days or a week after that, or never (only a clean-up by hand). A clean-up by hand doesn't wait for the setting.
6
+ * Only the working copy goes: its branch, and so every commit, stays, and a later lease of the branch makes a new one.
7
+ *
8
+ * Always kept: a checkout in use, one whose task isn't finished (on hold, failed, a revision under way), a result
9
+ * still waiting for review, a release candidate for a week after it was done (a deploy installs from it), a checkout
10
+ * with changes (files Toolroll wrote itself aside), and one with commits on no branch. A checkout no task names
11
+ * (adopted after a crash) goes only in a clean-up by hand. Every removal is in the ledger.
12
+ */
13
+ import { createHash } from "node:crypto";
14
+ import { existsSync } from "node:fs";
15
+ import { basename } from "node:path";
16
+ import { taskBranches } from "./names.js";
17
+ import { COMPLETION_ACTION } from "./result-completion.js";
18
+ import { CLEANUP_CHOICES, bytesWords, diskBytes } from "./storage.js";
19
+ import { worktreePath } from "./worktree.js";
20
+ const DAY_MS = 86_400_000;
21
+ /** A release candidate's checkout holds the build a deploy installs: kept a week after its task was done. */
22
+ export const CANDIDATE_KEEP_MS = 7 * DAY_MS;
23
+ /** The worker's pass removes at most this many; a clean-up by hand has no cap. */
24
+ export const AUTO_MAX = 20;
25
+ /** Git inspections at once. */
26
+ const INSPECT_AT_ONCE = 8;
27
+ const STRENGTH = { "in use": 5, "task not finished": 4, "waiting for review": 3, "release candidate": 2 };
28
+ const strength = (one) => one === undefined ? -1 : one.keep === null ? 0 : STRENGTH[one.keep] ?? 1;
29
+ const stronger = (a, b) => strength(b) > strength(a) || (strength(b) === 0 && strength(a) === 0 && (b.finishedAt ?? "") > (a.finishedAt ?? "")) ? b : a;
30
+ /** Where every task stands for cleanup, by task_ref id and by each branch it or its runs used. */
31
+ export function taskStatuses(store, now) {
32
+ const db = store.handle;
33
+ const refs = db.prepare(`SELECT r.id AS ref, r.external_id AS id, r.revision_of AS revision_of, t.state AS state, t.updated_at AS updated_at,
34
+ EXISTS (SELECT 1 FROM hold h WHERE h.task_ref = r.id) AS held,
35
+ EXISTS (SELECT 1 FROM run live WHERE live.task_ref = r.id AND live.finished_at IS NULL) AS live,
36
+ EXISTS (SELECT 1 FROM task_scope s WHERE s.task_id = r.external_id AND s.candidate IS NOT NULL) AS candidate
37
+ FROM task_ref r LEFT JOIN task t ON t.id = r.external_id AND r.backend = 'built-in'`).all();
38
+ const runs = db.prepare("SELECT id, task_ref, branch FROM run").all();
39
+ const refOfRun = new Map(runs.map(row => [Number(row["id"]), Number(row["task_ref"])]));
40
+ // The completion names its family's root; its run names the result it completes.
41
+ const completed = new Map();
42
+ const completedRef = new Map();
43
+ for (const row of db.prepare("SELECT task_id, run_id, at FROM action_ledger WHERE action = ?").all(COMPLETION_ACTION)) {
44
+ const at = String(row["at"]);
45
+ if (row["task_id"] !== null) {
46
+ const id = String(row["task_id"]);
47
+ if ((completed.get(id) ?? "") < at)
48
+ completed.set(id, at);
49
+ }
50
+ const ref = row["run_id"] === null ? undefined : refOfRun.get(Number(row["run_id"]));
51
+ if (ref !== undefined && (completedRef.get(ref) ?? "") < at)
52
+ completedRef.set(ref, at);
53
+ }
54
+ const byRef = new Map();
55
+ const byId = new Map();
56
+ const parentOf = new Map();
57
+ for (const row of refs) {
58
+ const ref = Number(row["ref"]);
59
+ const id = String(row["id"]);
60
+ const state = row["state"] === null ? null : String(row["state"]);
61
+ const updated = row["updated_at"] === null ? null : String(row["updated_at"]);
62
+ let status;
63
+ if (Number(row["live"]) === 1)
64
+ status = { keep: "in use", taskId: id, finishedAt: null };
65
+ else if (state === null || Number(row["held"]) === 1 || (state !== "done" && state !== "cancelled"))
66
+ status = { keep: "task not finished", taskId: id, finishedAt: null };
67
+ else if (state === "cancelled")
68
+ status = { keep: null, taskId: id, finishedAt: updated };
69
+ else {
70
+ const completedAt = [completed.get(id), completedRef.get(ref)].filter((one) => one !== undefined).sort().at(-1) ?? null;
71
+ if (Number(row["candidate"]) === 1) {
72
+ const since = completedAt ?? updated ?? now.toISOString();
73
+ const until = new Date(Date.parse(since) + CANDIDATE_KEEP_MS).toISOString();
74
+ status = until > now.toISOString() ? { keep: "release candidate", taskId: id, finishedAt: since, dueAt: until } : { keep: null, taskId: id, finishedAt: since };
75
+ }
76
+ else
77
+ status = completedAt === null ? { keep: "waiting for review", taskId: id, finishedAt: null } : { keep: null, taskId: id, finishedAt: completedAt };
78
+ }
79
+ byRef.set(ref, status);
80
+ byId.set(id, stronger(byId.get(id), status));
81
+ if (row["revision_of"] !== null)
82
+ parentOf.set(id, String(row["revision_of"]));
83
+ }
84
+ // A revision under way keeps what its ancestors worked on: it builds on their branch.
85
+ for (const [id, status] of byId) {
86
+ if (status.keep === null || status.keep === "release candidate")
87
+ continue;
88
+ const seen = new Set([id]);
89
+ for (let parent = parentOf.get(id); parent !== undefined && !seen.has(parent); parent = parentOf.get(parent)) {
90
+ seen.add(parent);
91
+ const lifted = { ...status, taskId: parent };
92
+ byId.set(parent, stronger(byId.get(parent), lifted));
93
+ }
94
+ }
95
+ for (const row of refs)
96
+ byRef.set(Number(row["ref"]), stronger(byRef.get(Number(row["ref"])), { ...byId.get(String(row["id"])), taskId: String(row["id"]) }));
97
+ const byBranch = new Map();
98
+ const note = (branch, status) => byBranch.set(branch, stronger(byBranch.get(branch), status));
99
+ for (const row of refs)
100
+ for (const branch of taskBranches(String(row["id"])))
101
+ note(branch, byRef.get(Number(row["ref"])));
102
+ for (const row of runs) {
103
+ if (row["branch"] === null)
104
+ continue;
105
+ const status = byRef.get(Number(row["task_ref"]));
106
+ if (status !== undefined)
107
+ note(String(row["branch"]), status);
108
+ }
109
+ return { byRef, byBranch };
110
+ }
111
+ /** Where one checkout's task stands: by its task, its branch, or (branch unknown) the name a lease of a branch would give it. */
112
+ function statusOf(row, statuses) {
113
+ let found = stronger(row.taskRef === null ? undefined : statuses.byRef.get(row.taskRef), statuses.byBranch.get(row.branch));
114
+ if (found === undefined) {
115
+ const name = basename(row.path);
116
+ for (const [branch, status] of statuses.byBranch)
117
+ if (basename(worktreePath("/", row.repo, branch)) === name)
118
+ found = stronger(found, status);
119
+ }
120
+ return found;
121
+ }
122
+ /** Why a checkout stays before anyone looks at its files, or null: it may go (as far as its task and the setting say). */
123
+ function keptFor(pool, row, statuses, now, cleanup) {
124
+ const status = statusOf(row, statuses);
125
+ const taskId = status?.taskId ?? null;
126
+ if (row.releasedAt === null || row.runner !== null || pool.inUse(row.path).held)
127
+ return { why: "in use", taskId };
128
+ if (status !== undefined && status.keep !== null)
129
+ return { why: status.keep, taskId, ...(status.dueAt === undefined ? {} : { dueAt: status.dueAt }) };
130
+ if (cleanup === "manual")
131
+ return { why: null, taskId };
132
+ // A checkout no task names (adopted after a crash, say) has no task to finish: only a clean-up by hand takes it.
133
+ if (status === undefined)
134
+ return { why: "no task", taskId };
135
+ if (cleanup === "finished")
136
+ return { why: null, taskId };
137
+ if (cleanup === "never")
138
+ return { why: "cleanup is off", taskId };
139
+ const days = CLEANUP_CHOICES.find(one => one.value === cleanup).days;
140
+ const since = [status.finishedAt, row.releasedAt].filter((one) => one !== null).sort().at(-1);
141
+ const dueAt = new Date(Date.parse(since) + days * DAY_MS).toISOString();
142
+ return dueAt > now.toISOString() ? { why: "not due yet", taskId, dueAt } : { why: null, taskId };
143
+ }
144
+ async function eachAtMost(items, limit, work) {
145
+ const out = new Array(items.length);
146
+ let next = 0;
147
+ await Promise.all(Array.from({ length: Math.min(limit, items.length) }, async () => {
148
+ while (next < items.length) {
149
+ const at = next++;
150
+ out[at] = await work(items[at]);
151
+ }
152
+ }));
153
+ return out;
154
+ }
155
+ /**
156
+ * What a clean-up would remove now, how much space it would free, and what stays and why. `manual` is a clean-up by
157
+ * hand (it doesn't wait for the setting); otherwise it is the worker's pass under the setting. Reads only.
158
+ */
159
+ export async function checkoutPlan(store, pool, now, options) {
160
+ const cleanup = store.checkoutCleanup();
161
+ const rows = store.listWorktrees().filter(row => existsSync(row.path) && (options.repo === undefined || row.repo === options.repo));
162
+ const sizes = diskBytes(rows.map(row => row.path));
163
+ const statuses = taskStatuses(store, now);
164
+ const items = await eachAtMost(rows, INSPECT_AT_ONCE, async (row) => {
165
+ const kept = keptFor(pool, row, statuses, now, options.manual ? "manual" : cleanup);
166
+ let why = kept.why;
167
+ if (why === null) {
168
+ const found = await pool.inspect(row.path);
169
+ why = found === "clean" ? null : found;
170
+ }
171
+ return { path: row.path, repo: row.repo, branch: row.branch, taskId: kept.taskId, bytes: sizes.get(row.path) ?? 0, releasedAt: row.releasedAt, why, ...(kept.dueAt === undefined ? {} : { dueAt: kept.dueAt }) };
172
+ });
173
+ const go = items.filter(one => one.why === null).sort((a, b) => b.bytes - a.bytes);
174
+ const stay = items.filter(one => one.why !== null).sort((a, b) => (a.why ?? "").localeCompare(b.why ?? "") || b.bytes - a.bytes);
175
+ return {
176
+ at: now.toISOString(), cleanup, manual: options.manual, go, stay,
177
+ count: items.length, totalBytes: items.reduce((sum, one) => sum + one.bytes, 0), freeBytes: go.reduce((sum, one) => sum + one.bytes, 0),
178
+ waitingReview: stay.filter(one => one.why === "waiting for review").length, withChanges: stay.filter(one => one.why === "has changes").length,
179
+ };
180
+ }
181
+ /** Names exactly what a preview would remove, so a confirmation removes that and nothing else. */
182
+ export function previewDigest(plan) {
183
+ return createHash("sha256").update(plan.go.map(one => one.path).sort().join("\u0000"), "utf8").digest("hex").slice(0, 32);
184
+ }
185
+ /**
186
+ * Remove what may go: the worker's pass (`manual` false: under the setting, at most AUTO_MAX, one repository) or a
187
+ * clean-up by hand (no cap, every repository). Each checkout is asked again right before it goes; each removal is in
188
+ * the ledger.
189
+ */
190
+ export async function cleanCheckouts(store, pool, now, options) {
191
+ const cleanup = store.checkoutCleanup();
192
+ if (!options.manual && cleanup === "never")
193
+ return { removed: [], kept: [], freed: 0 };
194
+ // `only`: the checkouts a person saw in the preview; nothing they didn't see goes.
195
+ const rows = store.listWorktrees().filter(row => existsSync(row.path) && (options.repo === undefined || row.repo === options.repo) && (options.only === undefined || options.only.has(row.path)));
196
+ let statuses = taskStatuses(store, now());
197
+ const sizes = new Map();
198
+ const wanted = (row, fresh) => {
199
+ // Right before a checkout goes, where its task stands is read afresh, and its size taken for the ledger.
200
+ if (fresh)
201
+ statuses = taskStatuses(store, now());
202
+ const go = keptFor(pool, row, statuses, now(), options.manual ? "manual" : cleanup).why === null;
203
+ if (go && fresh)
204
+ sizes.set(row.path, diskBytes([row.path]).get(row.path) ?? 0);
205
+ return go;
206
+ };
207
+ const pruned = await pool.prune(rows, wanted, options.manual ? Infinity : AUTO_MAX);
208
+ const removed = [];
209
+ for (const row of pruned.removed) {
210
+ const taskId = row.taskRef === null ? null : store.externalIdFor(row.taskRef);
211
+ const bytes = sizes.get(row.path) ?? 0;
212
+ removed.push({ path: row.path, repo: row.repo, branch: row.branch, taskId, bytes, releasedAt: row.releasedAt, why: null });
213
+ store.recordAction({ at: now().toISOString(), actor: options.actor, repo: row.repo, taskId, runId: null, action: "checkout removed", outcome: "removed",
214
+ source: options.manual ? "request" : "work", detail: `${basename(row.path)} (released ${row.releasedAt?.slice(0, 10) ?? "?"}, about ${bytesWords(bytes)}); branch ${row.branch} kept` });
215
+ }
216
+ return { removed, kept: pruned.kept, freed: removed.reduce((sum, one) => sum + one.bytes, 0) };
217
+ }
218
+ /** Throw away a checkout kept for its changes, on purpose; its branch stays. In the ledger. */
219
+ export async function discardCheckout(store, pool, path, now, actor) {
220
+ const bytes = existsSync(path) ? diskBytes([path]).get(path) ?? 0 : 0;
221
+ const done = await pool.discardChanges(path);
222
+ if (!done.ok)
223
+ return done;
224
+ const taskId = done.row.taskRef === null ? null : store.externalIdFor(done.row.taskRef);
225
+ store.recordAction({ at: now.toISOString(), actor, repo: done.row.repo, taskId, runId: null, action: "checkout discarded", outcome: "removed", source: "request",
226
+ detail: `${basename(path)} and its uncommitted changes (about ${bytesWords(bytes)}); branch ${done.row.branch} kept` });
227
+ return { ok: true, bytes };
228
+ }
229
+ /** "Waiting for review", "Has changes" … as a sentence fragment for a person. */
230
+ export function whyWords(item) {
231
+ switch (item.why) {
232
+ case null: return "goes";
233
+ case "in use": return "in use";
234
+ case "task not finished": return "its task isn't finished";
235
+ case "waiting for review": return "its result is waiting for review";
236
+ case "release candidate": return `release candidate, kept until ${item.dueAt?.slice(0, 10) ?? "a week after"}`;
237
+ case "has changes": return "has changes";
238
+ case "has commits": return "has commits on no branch";
239
+ case "unreadable": return "git couldn't read it";
240
+ case "git refused": return "git wouldn't remove it";
241
+ case "not due yet": return `due ${item.dueAt?.slice(0, 10) ?? "later"}`;
242
+ case "cleanup is off": return "automatic cleanup is off";
243
+ case "no task": return "no task names it; only a clean-up by hand removes it";
244
+ }
245
+ }
@@ -9,14 +9,17 @@ export type FirstRunAction = {
9
9
  kind: "command";
10
10
  command: string;
11
11
  };
12
+ /** `checking`: the answer is not in yet, so the step is shown neither done nor to do. */
12
13
  export type FirstRunStep = {
13
14
  key: "agent" | "project" | "task";
14
15
  title: string;
15
16
  done: boolean;
16
17
  action: FirstRunAction | null;
18
+ checking?: true;
17
19
  };
20
+ /** agentSignedIn is null until this machine's first sign-in check has answered. */
18
21
  export type FirstRunFacts = {
19
- agentSignedIn: boolean;
22
+ agentSignedIn: boolean | null;
20
23
  projects: number;
21
24
  hasTask: boolean;
22
25
  firstResultAt: string | null;
package/dist/first-run.js CHANGED
@@ -15,7 +15,8 @@ export function firstRunSteps(facts) {
15
15
  return null;
16
16
  const project = facts.projects > 0;
17
17
  return [
18
- { key: "agent", title: "Agent signed in", done: facts.agentSignedIn, action: facts.agentSignedIn ? null : { kind: "command", command: SIGN_IN_COMMAND } },
18
+ facts.agentSignedIn === null ? { key: "agent", title: "Agent signed in", done: false, action: null, checking: true }
19
+ : { key: "agent", title: "Agent signed in", done: facts.agentSignedIn, action: facts.agentSignedIn ? null : { kind: "command", command: SIGN_IN_COMMAND } },
19
20
  { key: "project", title: "Project added", done: project, action: project ? null : { kind: "link", label: "Add a project", href: "/projects" } },
20
21
  { key: "task", title: "Your first task", done: facts.hasTask, action: facts.hasTask ? null : { kind: "link", label: "New task", href: "/tasks/new" } },
21
22
  ];
package/dist/mcp.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { TASK_SCOPE_TEXT_SCHEMA } from "./task-text.js";
2
2
  import { dirname, join } from 'node:path';
3
+ import { statSync } from 'node:fs';
3
4
  import { repositoryContextRead } from './repository-context.js';
4
5
  import { ASSIGNMENT_TOOLS, assignmentForCoordinator } from "./assignment-adapters.js";
5
6
  /**
@@ -367,12 +368,30 @@ function proposeTools() {
367
368
  make("answer", "Propose an answer to an open decision you read with get_decision, with a rationale. The approver confirms where every consequence and the builder's recommendation are shown; an irreversible option needs their explicit confirmation.", { decision: { type: "integer", minimum: 1 }, option: { type: "string", minLength: 1, maxLength: 64 }, rationale: { type: "string", maxLength: 400 } }, ["decision", "option", "rationale"]),
368
369
  ];
369
370
  }
371
+ function fileIdentity(path) {
372
+ if (path === null)
373
+ return null;
374
+ try {
375
+ const at = statSync(path);
376
+ return { path, dev: at.dev, ino: at.ino };
377
+ }
378
+ catch {
379
+ return null;
380
+ }
381
+ }
382
+ /** Still the same file at the same path: not moved aside, deleted, or replaced by a rename. */
383
+ function sameFile(was) {
384
+ const now = fileIdentity(was.path);
385
+ return now !== null && now.dev === was.dev && now.ino === was.ino;
386
+ }
370
387
  /** Serve until EOF. The store is already open through the non-migrating
371
388
  * door; the token was startup-verified by the caller (and dies here again
372
389
  * if it does not hold). */
373
390
  export function serveMcp(store, token, io, clock = () => new Date(), enrolled = null, evidenceRoot) {
374
391
  /** Per connection: which decisions this credential read in full (v3). */
375
392
  const readDecisions = new Set();
393
+ /** The database file this server opened, by identity (null in memory). */
394
+ const database = fileIdentity(store.databaseFile());
376
395
  const auth = authenticateCoordinator(store, token);
377
396
  if (!auth.ok) {
378
397
  return {
@@ -414,6 +433,13 @@ export function serveMcp(store, token, io, clock = () => new Date(), enrolled =
414
433
  tools: TOOLS.map(tool => ({ name: tool.name, description: tool.description, inputSchema: tool.inputSchema })),
415
434
  });
416
435
  const callTool = (id, era, params) => {
436
+ // A restore or rollback puts a different file at the database's path: this server's connection still reads and
437
+ // writes the one it replaced, so it answers nothing more and exits (the agent starts it again on the new one).
438
+ if (database !== null && !sameFile(database)) {
439
+ error(id, -32000, "the database was replaced underneath this server (a Toolroll restore or rollback) — restart it");
440
+ io.exit(0);
441
+ return;
442
+ }
417
443
  // ONE snapshot per call (review finding 3): the version check, the
418
444
  // credential re-read, and every data read share a transaction, so a
419
445
  // concurrent migration cannot slip between them; filing's own
package/dist/operate.d.ts CHANGED
@@ -22,10 +22,6 @@ export declare const EXIT: {
22
22
  readonly usage: 2;
23
23
  readonly refused: 3;
24
24
  };
25
- /** Storage retention: how long a finished task's clean checkout is kept after it was let go, and a result marked
26
- * complete's or a release candidate's after it was (its checkout holds the build a deploy installs). */
27
- export declare const CHECKOUT_KEEP_MS: number;
28
- export declare const RESULT_KEEP_MS: number;
29
25
  export type OperateOptions = {
30
26
  /** Native-shell proof key, passed in memory rather than command-line arguments. */
31
27
  desktopIdentity?: string;
package/dist/operate.js CHANGED
@@ -3,7 +3,8 @@ import { parseProtectedPaths } from "./approval-policy.js";
3
3
  import { rulesSummary } from "./approval-rules-ui.js";
4
4
  import { evidencePack, exportDay, ledgerExportChunks, standaloneEvidenceHtml } from "./evidence-pack.js";
5
5
  import { matchesOutsideCheckpoint } from "./ledger-chain.js";
6
- import { bytesWords, storageReport } from "./storage.js";
6
+ import { CLEANUP_CHOICES, bytesWords, cleanupWords, parseCleanup, storageReport } from "./storage.js";
7
+ import { checkoutPlan, cleanCheckouts, discardCheckout, whyWords } from "./checkout-cleanup.js";
7
8
  import { MIN_DAYS, RETENTION_KINDS, countWords, dailyRetention, isRetentionKind, lastSweepAt, parsePeriod, periodWords, retentionPlan, sweepWords } from "./retention.js";
8
9
  import { checkPolicy, parseList, policyParts } from "./policy.js";
9
10
  import { billingOf, budgetHoldWords, budgetLabel, budgetStates, monthNamed, monthOf, spendItems, teammateNames, usd as spendUsd } from "./spend.js";
@@ -193,10 +194,6 @@ import { PACKAGE_VERSION } from "./version.js";
193
194
  * caller deciding whether to try the next task or wake somebody up.
194
195
  */
195
196
  export const EXIT = { ok: 0, failed: 1, usage: 2, refused: 3 };
196
- /** Storage retention: how long a finished task's clean checkout is kept after it was let go, and a result marked
197
- * complete's or a release candidate's after it was (its checkout holds the build a deploy installs). */
198
- export const CHECKOUT_KEEP_MS = 2 * 24 * 60 * 60_000;
199
- export const RESULT_KEEP_MS = 7 * 24 * 60 * 60_000;
200
197
  const STATES = ["queued", "running", "done", "failed", "cancelled"];
201
198
  export const OPERATE_HELP = `toolroll — operating the queue
202
199
 
@@ -213,6 +210,9 @@ export const OPERATE_HELP = `toolroll — operating the queue
213
210
  --not-requester on|off, --protect none|project, --protect-paths "a/**,b/**"
214
211
  toolroll task evidence <id> the task's evidence pack as JSON (--html for a printable page; --out <file>)
215
212
  toolroll storage where the disk goes: database, build checkouts, releases, evidence
213
+ toolroll storage clean [--yes] preview removing finished tasks' clean checkouts (their branches stay); --yes removes them
214
+ toolroll storage cleanup <when> remove a finished task's clean checkout: finished|2d|7d|never (instance operator)
215
+ toolroll storage discard <path> --yes throw away a checkout kept for its changes; its branch stays
216
216
  toolroll monitoring where the audit stream and traces go, and how each destination is doing
217
217
  toolroll check-progress <run> current or final approved check progress
218
218
  toolroll spend [--month YYYY-MM] [--csv] what agent work cost, by project, person, teammate and model
@@ -823,13 +823,8 @@ async function dispatch(command, positional, flags, context) {
823
823
  return exportCommand(positional, flags, context);
824
824
  case "policy":
825
825
  return policyCommand(positional, flags, context);
826
- case "storage": {
827
- const report = storageReport(context.store, context.databaseFile, context.clock(), CHECKOUT_KEEP_MS);
828
- return succeed(context.write, context.json, "storage", report, () => [
829
- `${report.folder}: ${bytesWords(report.total)}`,
830
- ...report.lines.map(one => ` ${one.what.padEnd(17)} ${bytesWords(one.bytes).padStart(8)}${one.count === undefined ? "" : ` (${one.count})`}${one.note === undefined ? "" : ` ${one.note}`}`),
831
- ]);
832
- }
826
+ case "storage":
827
+ return storageCommand(positional, flags, context);
833
828
  case "monitoring": {
834
829
  // Where the audit stream and traces go, and how each destination is doing. Settings → Monitoring changes it.
835
830
  const settings = readMonitoring(dirname(context.databaseFile));
@@ -4313,13 +4308,9 @@ async function reconcileCommand(flags, context) {
4313
4308
  reaped: reaped.map(claim => claim.leaseId),
4314
4309
  });
4315
4310
  }
4316
- // Storage retention: finished work's clean checkouts go two days after they were let go (their branches stay); a
4317
- // result marked complete, or a release candidate, a week after (a deploy installs the build in its checkout).
4318
- const pruned = await worktrees.prune(repo, clock(), CHECKOUT_KEEP_MS, () => store.keptBranches(repo, clock(), RESULT_KEEP_MS));
4319
- for (const row of pruned.removed) {
4320
- store.recordAction({ at: clock().toISOString(), actor: "worker", repo, taskId: row.taskRef === null ? null : store.externalIdFor(row.taskRef), runId: null,
4321
- action: "checkout removed", outcome: "removed", source: "work", detail: `${basename(row.path)} (released ${row.releasedAt?.slice(0, 10) ?? "?"})` });
4322
- }
4311
+ // Checkout cleanup (Settings → Storage): finished work's clean checkouts go as the setting says (by default when
4312
+ // the task is complete or cancelled); their branches stay. Each removal is in the ledger.
4313
+ const pruned = await cleanCheckouts(store, worktrees, clock, { manual: false, actor: "worker", repo });
4323
4314
  // Retention (Settings → Retention): once a day, what's older than its setting goes; one ledger entry says what.
4324
4315
  let retention = null;
4325
4316
  try {
@@ -4341,7 +4332,7 @@ async function reconcileCommand(flags, context) {
4341
4332
  reaped: reaped.map(claim => claim.leaseId),
4342
4333
  adopted: adoption.adopted,
4343
4334
  forgotten: adoption.forgotten,
4344
- checkoutsRemoved: pruned.removed.map(row => row.path),
4335
+ checkoutsRemoved: pruned.removed.map(one => one.path),
4345
4336
  checkoutsKept: pruned.kept,
4346
4337
  liveViewsSwept: liveSwept.removed.length,
4347
4338
  retention: retention === null ? null : { counts: retention.counts, freedBytes: retention.freed },
@@ -4352,7 +4343,7 @@ async function reconcileCommand(flags, context) {
4352
4343
  ...(reaped.length === 0 ? [] : [`Reaped ${reaped.length} expired lease(s).`]),
4353
4344
  ...adoption.adopted.map(path => `Adopted ${path} — released, unverified, somebody should look.`),
4354
4345
  ...adoption.forgotten.map(path => `Forgot ${path} — its directory is gone.`),
4355
- ...(pruned.removed.length === 0 ? [] : [`Removed ${pruned.removed.length} checkout(s) nobody has used for two days; their branches stay.`]),
4346
+ ...(pruned.removed.length === 0 ? [] : [`Removed ${pruned.removed.length} finished task checkout(s), about ${bytesWords(pruned.freed)}; their branches stay.`]),
4356
4347
  ...(liveSwept.removed.length === 0 ? [] : [`Cleared ${liveSwept.removed.length} finished live view(s).`]),
4357
4348
  ...(retention === null || retained.length === 0 ? [] : [`Retention removed ${sweepWords(retention.counts, retention.freed)}.`]),
4358
4349
  ]);
@@ -11323,6 +11314,97 @@ async function retentionCommand(positional, flags, context) {
11323
11314
  const periods = store.setRetentionPeriod(kind, days, acting.name, context.clock());
11324
11315
  return succeed(context.write, context.json, command, { periods }, () => [`${label(kind)}: kept ${days === null ? "forever" : `for ${periodWords(days)}`}.`]);
11325
11316
  }
11317
+ /** `storage`, `storage cleanup [<when>]`, `storage clean [--yes]`, `storage discard <path> --yes`: where the disk goes,
11318
+ * and task checkouts: when a finished task's clean checkout is removed, cleaning up now, and throwing away one kept for
11319
+ * its changes. Changing anything takes an instance operator. */
11320
+ async function storageCommand(positional, flags, context) {
11321
+ const [action, ...rest] = positional;
11322
+ const command = `storage ${action ?? ""}`.trim();
11323
+ const store = context.store;
11324
+ const changes = (action === "cleanup" && rest.length > 0) || (action === "clean" && flags.has("yes")) || action === "discard";
11325
+ const allowed = new Set([...(changes ? ["as", "token", "token-file", "token-env"] : []), ...(action === "clean" || action === "discard" ? ["yes"] : []), "db", "json"]);
11326
+ for (const name of flags.keys())
11327
+ if (!allowed.has(name))
11328
+ return fail(context.write, context.json, command, "usage", `--${name} is not a ${command} option.`, EXIT.usage);
11329
+ const pool = new WorktreePool(store, { root: join(dirname(context.databaseFile), "worktrees"), ...(context.gitRunner === undefined ? {} : { runner: context.gitRunner }) });
11330
+ const operator = async () => {
11331
+ const acting = await askCredentials(flags, context);
11332
+ const verified = acting === null ? null : authenticateApprover(store, acting.name, acting.token);
11333
+ return acting !== null && verified !== null && verified.ok && store.isInstanceOperator(acting.name) ? acting.name : null;
11334
+ };
11335
+ const refused = (what) => fail(context.write, context.json, command, "refused", `An instance operator ${what}: pass --as and --token (or use the remembered login).`, EXIT.refused);
11336
+ const summary = (plan) => [
11337
+ `Checkouts: ${plan.count}, ${bytesWords(plan.totalBytes)}. ${plan.waitingReview} waiting for review, ${plan.withChanges} kept because they have changes.`,
11338
+ plan.go.length === 0 ? "A clean up now would free nothing." : `A clean up now would free about ${bytesWords(plan.freeBytes)} (${plan.go.length} checkout${plan.go.length === 1 ? "" : "s"}): toolroll storage clean`,
11339
+ `A finished task's clean checkout is removed ${cleanupWords(plan.cleanup)}; its branch stays.`,
11340
+ ];
11341
+ const itemLine = (one) => ` ${bytesWords(one.bytes).padStart(8)} ${one.path}${one.why === null ? "" : ` (${whyWords(one)})`}`;
11342
+ if (action === undefined) {
11343
+ const plan = await checkoutPlan(store, pool, context.clock(), { manual: true });
11344
+ const report = storageReport(store, context.databaseFile, plan.go.length === 0 ? "nothing to clean up" : `about ${bytesWords(plan.freeBytes)} to clean up`);
11345
+ return succeed(context.write, context.json, "storage", { ...report, checkouts: checkoutSummary(plan) }, () => [
11346
+ `${report.folder}: ${bytesWords(report.total)}`,
11347
+ ...report.lines.map(one => ` ${one.what.padEnd(17)} ${bytesWords(one.bytes).padStart(8)}${one.count === undefined ? "" : ` (${one.count})`}${one.note === undefined ? "" : ` ${one.note}`}`),
11348
+ ...summary(plan),
11349
+ ]);
11350
+ }
11351
+ if (action === "cleanup") {
11352
+ if (rest.length === 0) {
11353
+ const cleanup = store.checkoutCleanup();
11354
+ return succeed(context.write, context.json, command, { cleanup }, () => [`A finished task's clean checkout is removed ${cleanupWords(cleanup)}; its branch stays.`]);
11355
+ }
11356
+ const cleanup = rest.length === 1 ? parseCleanup(rest[0]) : undefined;
11357
+ if (cleanup === undefined)
11358
+ return fail(context.write, context.json, command, "usage", `Use storage cleanup <${CLEANUP_CHOICES.map(one => one.value).join("|")}>.`, EXIT.usage);
11359
+ const actor = await operator();
11360
+ if (actor === null)
11361
+ return refused("sets checkout cleanup");
11362
+ const saved = store.setCheckoutCleanup(cleanup, actor, context.clock());
11363
+ return succeed(context.write, context.json, command, { cleanup: saved }, () => [`Saved. A finished task's clean checkout is removed ${cleanupWords(saved)}; its branch stays.`]);
11364
+ }
11365
+ if (action === "clean") {
11366
+ if (rest.length > 0)
11367
+ return fail(context.write, context.json, command, "usage", "Use storage clean to preview, then storage clean --yes.", EXIT.usage);
11368
+ if (!flags.has("yes")) {
11369
+ const plan = await checkoutPlan(store, pool, context.clock(), { manual: true });
11370
+ return succeed(context.write, context.json, command, { preview: true, go: plan.go, stay: plan.stay, freeBytes: plan.freeBytes }, () => [
11371
+ ...(plan.go.length === 0 ? ["Nothing to clean up."] : [`Would remove ${plan.go.length} checkout${plan.go.length === 1 ? "" : "s"}, freeing about ${bytesWords(plan.freeBytes)} (their branches stay):`, ...plan.go.map(itemLine)]),
11372
+ ...(plan.stay.length === 0 ? [] : [`Stays (${plan.stay.length}):`, ...plan.stay.map(itemLine)]),
11373
+ plan.go.length === 0 ? "Nothing was removed." : "Nothing was removed. Run toolroll storage clean --yes to remove them.",
11374
+ ]);
11375
+ }
11376
+ const actor = await operator();
11377
+ if (actor === null)
11378
+ return refused("cleans up checkouts");
11379
+ const done = await cleanCheckouts(store, pool, context.clock, { manual: true, actor });
11380
+ return succeed(context.write, context.json, command, { removed: done.removed, kept: done.kept, freedBytes: done.freed }, () => [
11381
+ done.removed.length === 0 ? "Nothing to clean up." : `Removed ${done.removed.length} checkout${done.removed.length === 1 ? "" : "s"}, about ${bytesWords(done.freed)}; their branches stay.`,
11382
+ ...done.removed.map(itemLine),
11383
+ ...done.kept.map(one => ` kept ${one.path} (${whyWords({ why: one.why })})`),
11384
+ ]);
11385
+ }
11386
+ if (action === "discard") {
11387
+ const [path, ...extra] = rest;
11388
+ if (path === undefined || extra.length > 0)
11389
+ return fail(context.write, context.json, command, "usage", "Use storage discard <path> --yes.", EXIT.usage);
11390
+ const target = resolve(path);
11391
+ if (store.getWorktree(target) === null)
11392
+ return fail(context.write, context.json, command, "not-found", `${target} is not a task checkout. toolroll storage clean lists them.`, EXIT.refused);
11393
+ if (!flags.has("yes"))
11394
+ return fail(context.write, context.json, command, "usage", `This throws away ${target} and its uncommitted changes (its branch stays). Add --yes to discard it.`, EXIT.usage);
11395
+ const actor = await operator();
11396
+ if (actor === null)
11397
+ return refused("discards a checkout");
11398
+ const done = await discardCheckout(store, pool, target, context.clock(), actor);
11399
+ if (!done.ok)
11400
+ return fail(context.write, context.json, command, "refused", done.message, EXIT.refused);
11401
+ return succeed(context.write, context.json, command, { discarded: target, freedBytes: done.bytes }, () => [`Discarded ${target}, about ${bytesWords(done.bytes)}; its branch stays.`]);
11402
+ }
11403
+ return fail(context.write, context.json, command, "usage", "Use storage, storage cleanup <when>, storage clean [--yes] or storage discard <path> --yes.", EXIT.usage);
11404
+ }
11405
+ function checkoutSummary(plan) {
11406
+ return { count: plan.count, totalBytes: plan.totalBytes, waitingReview: plan.waitingReview, withChanges: plan.withChanges, cleanBytes: plan.freeBytes, cleanCount: plan.go.length, cleanup: plan.cleanup };
11407
+ }
11326
11408
  /** `policy show | set` (sprint 8): the organisation policy. Setting it takes an instance operator's credentials; each rule
11327
11409
  * that changed is in the ledger, before → after. A flag left out keeps its rule; "any" lifts it. */
11328
11410
  async function policyCommand(positional, flags, context) {
package/dist/prompt.d.ts CHANGED
@@ -7,12 +7,17 @@
7
7
  */
8
8
  /** Whether asking is even possible: a human on both ends of the pipe. */
9
9
  export declare function interactive(): boolean;
10
+ /** The program names of this process's ancestors, nearest first: each one's command and, for a script run by node
11
+ * or python, the script's name. Empty where `ps` cannot say. */
12
+ export declare function ancestorPrograms(): string[];
10
13
  /**
11
14
  * Whether a coding agent is running this command. An agent's shell can be a
12
15
  * real terminal, yet whatever is printed lands in its transcript and nobody
13
- * types at the prompt, so it is treated like no terminal at all.
16
+ * types at the prompt, so it is treated like no terminal at all. The agent is
17
+ * known by a variable it sets or, for one that sets none, by the program above
18
+ * this one. `programs` is read only for this process's own environment.
14
19
  */
15
- export declare function underAgent(env?: Record<string, string | undefined>): boolean;
20
+ export declare function underAgent(env?: Record<string, string | undefined>, programs?: () => readonly string[]): boolean;
16
21
  export declare function ask(question: string): Promise<string>;
17
22
  /** Like ask, but what is typed never echoes — passwords live here. */
18
23
  /**