toolroll 0.9.60 → 0.9.62

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.
Files changed (128) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/dist/agent-board.d.ts +208 -0
  3. package/dist/agent-board.js +454 -0
  4. package/dist/agent-fence.d.ts +19 -0
  5. package/dist/agent-fence.js +100 -8
  6. package/dist/agent-pulse.d.ts +43 -0
  7. package/dist/agent-pulse.js +214 -0
  8. package/dist/agent-turns.d.ts +52 -0
  9. package/dist/agent-turns.js +137 -0
  10. package/dist/agents-board.d.ts +101 -0
  11. package/dist/agents-board.js +225 -0
  12. package/dist/assignment-adapters.d.ts +1 -0
  13. package/dist/assignment-status.d.ts +1 -1
  14. package/dist/assignment.d.ts +4 -0
  15. package/dist/assignment.js +8 -6
  16. package/dist/browser/workspace.css +1 -1
  17. package/dist/browser/workspace.js +29 -25
  18. package/dist/browser-crew.js +2 -0
  19. package/dist/browser-workspace.d.ts +79 -1
  20. package/dist/browser-workspace.js +1 -0
  21. package/dist/builder.d.ts +8 -0
  22. package/dist/builder.js +113 -13
  23. package/dist/chat-channel.js +9 -0
  24. package/dist/chat-core.d.ts +6 -0
  25. package/dist/chat-core.js +8 -0
  26. package/dist/chat-delivery.js +22 -0
  27. package/dist/chat-messages.js +8 -1
  28. package/dist/chat-quiet.js +1 -0
  29. package/dist/chat-voice.d.ts +2 -0
  30. package/dist/chat-voice.js +2 -0
  31. package/dist/checkout-cleanup.d.ts +3 -0
  32. package/dist/checkout-cleanup.js +11 -2
  33. package/dist/cli.js +28 -11
  34. package/dist/contracts/console-api.d.ts +3 -1
  35. package/dist/contracts/console-api.js +5 -1
  36. package/dist/contracts/lead-tools.d.ts +16 -2
  37. package/dist/contracts/lead-tools.js +5 -1
  38. package/dist/desktop-update.d.ts +1 -1
  39. package/dist/evidence-pack.js +1 -1
  40. package/dist/evidence.d.ts +6 -0
  41. package/dist/evidence.js +14 -3
  42. package/dist/exec.d.ts +32 -0
  43. package/dist/exec.js +229 -10
  44. package/dist/invoke.js +110 -18
  45. package/dist/landed-cleanup.d.ts +170 -0
  46. package/dist/landed-cleanup.js +814 -0
  47. package/dist/lead-cli.js +22 -19
  48. package/dist/lead-contract.d.ts +1 -1
  49. package/dist/lead-contract.js +4 -4
  50. package/dist/lead-doors.js +45 -1
  51. package/dist/lead-tools.js +40 -7
  52. package/dist/live-input.d.ts +68 -0
  53. package/dist/live-input.js +254 -0
  54. package/dist/live.d.ts +1 -1
  55. package/dist/live.js +4 -2
  56. package/dist/maintenance.d.ts +2 -0
  57. package/dist/maintenance.js +5 -2
  58. package/dist/operate-remote-arguments.js +5 -1
  59. package/dist/operate-remote.js +1 -1
  60. package/dist/operate.d.ts +1 -1
  61. package/dist/operate.js +249 -26
  62. package/dist/orphan-sweep.d.ts +2 -0
  63. package/dist/orphan-sweep.js +5 -3
  64. package/dist/project-admission.d.ts +10 -6
  65. package/dist/project-admission.js +33 -11
  66. package/dist/project-delete-ui.d.ts +12 -0
  67. package/dist/project-delete-ui.js +32 -2
  68. package/dist/project-tools.d.ts +7 -11
  69. package/dist/project-tools.js +13 -37
  70. package/dist/provider.d.ts +65 -8
  71. package/dist/provider.js +80 -21
  72. package/dist/remote-main.d.ts +57 -0
  73. package/dist/remote-main.js +179 -0
  74. package/dist/result-completion.d.ts +7 -0
  75. package/dist/result-completion.js +9 -0
  76. package/dist/runner.d.ts +4 -2
  77. package/dist/runner.js +3 -3
  78. package/dist/serve.js +19 -1
  79. package/dist/server/live.d.ts +3 -0
  80. package/dist/server/live.js +35 -2
  81. package/dist/server/pages.js +59 -35
  82. package/dist/server/render-chat.js +12 -0
  83. package/dist/server/render-tasks.d.ts +15 -2
  84. package/dist/server/render-tasks.js +30 -7
  85. package/dist/server/route-table.js +4 -1
  86. package/dist/server/settings.js +5 -3
  87. package/dist/server/task-views.js +11 -0
  88. package/dist/server/tasks.js +63 -0
  89. package/dist/shared-deps.d.ts +9 -4
  90. package/dist/shared-deps.js +89 -13
  91. package/dist/staged-runtimes.d.ts +16 -4
  92. package/dist/staged-runtimes.js +160 -38
  93. package/dist/steer-live.d.ts +19 -0
  94. package/dist/steer-live.js +93 -0
  95. package/dist/steer-status.d.ts +8 -2
  96. package/dist/steer-status.js +19 -5
  97. package/dist/storage-sweep-child.d.ts +1 -0
  98. package/dist/storage-sweep-child.js +76 -0
  99. package/dist/storage-sweep-process.d.ts +48 -0
  100. package/dist/storage-sweep-process.js +204 -0
  101. package/dist/storage-sweep-schedule.d.ts +30 -0
  102. package/dist/storage-sweep-schedule.js +164 -0
  103. package/dist/storage-sweep.d.ts +31 -6
  104. package/dist/storage-sweep.js +165 -39
  105. package/dist/storage-ui.d.ts +4 -0
  106. package/dist/storage-ui.js +22 -7
  107. package/dist/storage.d.ts +6 -0
  108. package/dist/storage.js +35 -0
  109. package/dist/store.d.ts +200 -3
  110. package/dist/store.js +443 -17
  111. package/dist/surface.js +8 -4
  112. package/dist/sweep-quarantine.d.ts +28 -0
  113. package/dist/sweep-quarantine.js +153 -0
  114. package/dist/task-base.d.ts +35 -0
  115. package/dist/task-base.js +51 -0
  116. package/dist/task-status.d.ts +2 -0
  117. package/dist/task-status.js +5 -1
  118. package/dist/test-temp.d.ts +6 -3
  119. package/dist/test-temp.js +15 -9
  120. package/dist/toolroll-update.js +4 -2
  121. package/dist/work-index.d.ts +1 -0
  122. package/dist/work-index.js +32 -12
  123. package/dist/work-summary.d.ts +2 -2
  124. package/dist/workspace-revision.js +3 -1
  125. package/dist/worktree.d.ts +57 -3
  126. package/dist/worktree.js +201 -23
  127. package/package.json +1 -1
  128. package/scripts/provider-canary.mjs +235 -147
@@ -1,27 +1,39 @@
1
1
  /**
2
- * The daily storage sweep: the plane cleans up after itself without anyone running a command. Once a day (one worker
3
- * takes the day: the cursor moves in the same transaction) it removes test temp folders nothing touched for a day
4
- * (test-temp.ts), stops orphans Toolroll left running (orphan-sweep.ts), removes finished checkouts the cleanup
2
+ * The storage sweep: the plane cleans up after itself without anyone running a command. About once a day, when nothing
3
+ * is running or waiting (storage-sweep-schedule.ts: one service takes it, the cursor moves in the same transaction),
4
+ * it removes test temp folders nothing touched for a day (test-temp.ts), stops orphans Toolroll left running (orphan-sweep.ts), removes finished checkouts the cleanup
5
5
  * setting lets go (checkout-cleanup.ts), keeps the current, previous and newest staged runtimes (staged-runtimes.ts)
6
6
  * and removes shared dependency installs no checkout has used for a week (shared-deps.ts).
7
7
  *
8
+ * Nothing live work uses goes (`liveWork`): while a run is open, a lease current or a checkout in use, each removal
9
+ * and each signal first asks again whether that work uses the folder, and when its processes can't be read nothing
10
+ * goes. Every scan and removal runs in a separate child: synchronous disk work must never delay the worker's lease
11
+ * heartbeats (90 s). On Oct 12 the sweep starved those heartbeats and three builds ended interrupted half a second
12
+ * after it finished. Once a run has ended, the next sweep removes what
13
+ * it no longer uses.
14
+ *
8
15
  * The everyday work stays where it happens: suites remove their own temp roots and processes, a run's processes stop
9
16
  * when it ends and a finished checkout goes on the worker's next pass. This is the net for what slipped through.
10
17
  *
11
18
  * What each sweep did is saved (the latest few) and in the ledger: `toolroll storage` and Settings → Storage show it.
12
19
  * A clean-up by hand (`storage clean --yes`) is saved the same way. A part that failed, or had more than one pass
13
- * takes, brings the next sweep forward to an hour later. `TOOLROLL_STORAGE_SWEEP=off` turns the automatic sweep off
14
- * (the unit tests do, so a test never sweeps the machine it runs on).
20
+ * takes, brings the next sweep forward to an hour later; it still waits for nothing to be running.
21
+ * `TOOLROLL_STORAGE_SWEEP=off` turns the automatic sweep off (the unit tests do, so a test never sweeps the machine
22
+ * it runs on).
15
23
  */
16
- import { dirname } from "node:path";
24
+ import { realpathSync } from "node:fs";
25
+ import { hostname } from "node:os";
26
+ import { dirname, join, resolve, sep } from "node:path";
17
27
  import { cleanCheckouts } from "./checkout-cleanup.js";
18
- import { durationWords, sweepOrphans, ownerRunning } from "./orphan-sweep.js";
28
+ import { durationWords, locateProcesses, sweepOrphans, ownerRunning } from "./orphan-sweep.js";
19
29
  import { processCensus } from "./run-closeout.js";
20
- import { pruneUnusedShared, sharedDepsRoot, sharedUse, SHARED_UNUSED_MS } from "./shared-deps.js";
21
- import { pruneStagedRuntimes, runtimesInUse, stagePlan } from "./staged-runtimes.js";
30
+ import { linkedKey, pruneUnusedShared, sharedDepsRoot, sharedUse, SHARED_UNUSED_MS } from "./shared-deps.js";
31
+ import { pruneStagedRuntimes, runtimesInUse, runtimeScriptPaths, stagePlan } from "./staged-runtimes.js";
22
32
  import { bytesWords, diskBytes } from "./storage.js";
23
33
  import { removeStaleTestTemp } from "./test-temp.js";
24
- const CURSOR = "storage-sweep";
34
+ import { worktreeProcessOccupancy } from "./worktree.js";
35
+ import { runSweepChild } from "./storage-sweep-process.js";
36
+ export const SWEEP_CURSOR = "storage-sweep";
25
37
  export const SWEEP_EVERY_MS = 86_400_000;
26
38
  /** A sweep that failed somewhere, or left work for later, runs again this soon. */
27
39
  export const RETRY_MS = 60 * 60_000;
@@ -34,37 +46,151 @@ const ITEMS_MAX = 20;
34
46
  export function storageSweepOff(env = process.env) {
35
47
  return (env["TOOLROLL_STORAGE_SWEEP"] ?? "").trim().toLowerCase() === "off";
36
48
  }
37
- export function storageSweepDue(store, now) {
38
- return now.getTime() - store.serviceCursor(CURSOR) >= SWEEP_EVERY_MS;
49
+ export function sweepFailure(at, actor, withPool, reason) {
50
+ const problem = reason === "timeout" ? "The sweep process exceeded its time limit; some cleanup may have completed. Cleanup will retry later."
51
+ : reason === "stopped" ? "The sweep process was stopped; some cleanup may have completed. Cleanup will retry later."
52
+ : "The sweep process did not return a valid result; some cleanup may have completed.";
53
+ return { at, source: "automatic", actor, parts: sweepKinds(withPool).map(kind => ({ kind, count: 0, bytes: 0, failed: 0, items: [], problem })) };
39
54
  }
40
- /** The daily sweep, when it's due and on: one worker takes it. Null when it wasn't this pass's to run. */
41
- export async function dailyStorageSweep(store, deps, now) {
42
- if (storageSweepOff())
43
- return null;
44
- const at = now();
45
- const mine = store.transact(() => {
46
- if (!storageSweepDue(store, at))
47
- return false;
48
- store.setServiceCursor(CURSOR, at.getTime(), at);
49
- return true;
50
- });
51
- if (!mine)
55
+ export function liveWork(store, depsRoot, now, where) {
56
+ const db = store.handle;
57
+ const tasks = new Set();
58
+ const paths = [];
59
+ for (const row of db.prepare("SELECT task_ref, worktree FROM run WHERE finished_at IS NULL").all()) {
60
+ tasks.add(Number(row["task_ref"]));
61
+ if (typeof row["worktree"] === "string" && row["worktree"] !== "")
62
+ paths.push(row["worktree"]);
63
+ }
64
+ for (const row of db.prepare("SELECT task_ref FROM claim WHERE released_at IS NULL AND expires_at > ?").all(now.toISOString()))
65
+ tasks.add(Number(row["task_ref"]));
66
+ const pids = new Set();
67
+ for (const row of store.listWorktrees()) {
68
+ let held;
69
+ try {
70
+ held = worktreeProcessOccupancy(row.path);
71
+ }
72
+ catch {
73
+ held = { held: true, by: 0 };
74
+ }
75
+ if (row.releasedAt !== null && row.runner === null && !held.held && (row.taskRef === null || !tasks.has(row.taskRef)))
76
+ continue;
77
+ paths.push(row.path);
78
+ if (held.held && held.by > 1)
79
+ pids.add(held.by);
80
+ }
81
+ if (tasks.size === 0 && paths.length === 0)
82
+ return { live: false, paths: [], blind: false };
83
+ const witnessed = db.prepare(`SELECT p.pid AS pid FROM run_process p JOIN run r ON r.id = p.run
84
+ WHERE r.finished_at IS NULL AND p.exited_at IS NULL AND p.pid IS NOT NULL AND p.host = ?`).all(hostname());
85
+ for (const row of witnessed)
86
+ pids.add(Number(row["pid"]));
87
+ const from = pids.size === 0 ? [] : where([...pids].sort((a, b) => a - b));
88
+ if (from === null)
89
+ return { live: true, paths, blind: true };
90
+ paths.push(...from);
91
+ // A checkout (or a run's folder inside one) links its node_modules to a shared install: that install is in use.
92
+ for (const path of [...paths]) {
93
+ for (let at = resolve(path), hops = 0; hops < 32; at = dirname(at), hops++) {
94
+ const key = linkedKey(at, depsRoot);
95
+ if (key !== null) {
96
+ paths.push(join(depsRoot, key));
97
+ break;
98
+ }
99
+ if (dirname(at) === at)
100
+ break;
101
+ }
102
+ }
103
+ return { live: true, paths, blind: false };
104
+ }
105
+ /** Where the processes run from, with their groups and every process under them; null when that can't be read. */
106
+ function processFolders(pids, census, locate, scripts) {
107
+ try {
108
+ const rows = census();
109
+ const take = new Set(rows.filter(row => pids.includes(row.pid) || pids.includes(row.pgid)).map(row => row.pid));
110
+ for (let grew = true; grew;) {
111
+ grew = false;
112
+ for (const row of rows)
113
+ if (!take.has(row.pid) && take.has(row.ppid) && row.ppid > 1) {
114
+ take.add(row.pid);
115
+ grew = true;
116
+ }
117
+ }
118
+ if (take.size === 0)
119
+ return [];
120
+ const located = locate([...take]);
121
+ for (const pid of take) {
122
+ if (located.get(pid)?.cwd !== undefined)
123
+ continue;
124
+ // A vanished process is fine. An unreadable live working folder is not evidence that it uses nothing.
125
+ try {
126
+ process.kill(pid, 0);
127
+ return null;
128
+ }
129
+ catch (error) {
130
+ if (error.code !== "ESRCH")
131
+ return null;
132
+ }
133
+ }
134
+ return [...[...located.values()].flatMap(one => [one.cwd, one.exe]).filter((one) => one !== undefined && one !== ""), ...scripts([...take])];
135
+ }
136
+ catch {
52
137
  return null;
53
- const record = await sweepStorage(store, deps, now);
54
- // Something failed or was left for later: the next sweep comes an hour from now, not tomorrow.
55
- if (record.parts.some(one => one.failed > 0 || one.more === true || one.problem !== undefined)) {
56
- store.setServiceCursor(CURSOR, Math.max(0, at.getTime() - SWEEP_EVERY_MS + RETRY_MS), now());
57
138
  }
58
- return record;
59
139
  }
140
+ const forms = (path) => {
141
+ const out = [resolve(path)];
142
+ try {
143
+ out.push(realpathSync(path));
144
+ }
145
+ catch { /* gone */ }
146
+ return out;
147
+ };
148
+ /**
149
+ * Whether live work uses `path`, asked again at every call, including process children. A cached process look
150
+ * cannot protect a child launched between the pre-rename and post-rename guards. All this work is in the child.
151
+ */
152
+ export function liveUse(store, deps, now) {
153
+ const census = deps.census ?? (() => processCensus());
154
+ const locate = deps.locate ?? locateProcesses;
155
+ const where = (pids) => processFolders(pids, census, locate, deps.scripts ?? (pids => runtimeScriptPaths(dirname(deps.databaseFile), pids)));
156
+ return path => {
157
+ const work = liveWork(store, sharedDepsRoot(deps.databaseFile), now(), where);
158
+ if (!work.live)
159
+ return false;
160
+ if (work.blind)
161
+ return true;
162
+ const outer = forms(path);
163
+ return work.paths.some(one => forms(one).some(inner => outer.some(base => inner === base || inner.startsWith(base + sep))));
164
+ };
165
+ }
166
+ const nextTurn = () => new Promise(resolve => setImmediate(resolve));
60
167
  const capped = (items) => items.slice(0, ITEMS_MAX);
61
168
  const failure = (error) => error instanceof Error ? error.message : String(error);
62
- /** One sweep, now, every part; saved and in the ledger. Each part's failure is its own: the others still run. */
63
- export async function sweepStorage(store, deps, now) {
169
+ /** Parent side: no filesystem/process scans here. The executor seam is for deterministic unit tests only. */
170
+ export async function sweepStorage(store, deps, now, execute = runSweepChild) {
171
+ const at = now().toISOString();
172
+ const actor = deps.actor ?? "worker";
173
+ let record;
174
+ try {
175
+ record = await execute({ version: 1, databaseFile: deps.databaseFile, poolRoot: deps.pool?.root ?? null, tempRoots: [...deps.tempRoots], actor, at });
176
+ }
177
+ catch {
178
+ // Never save child stdout, argv or exception text: those may contain private process inputs.
179
+ record = sweepFailure(at, actor, deps.pool !== null, "failed");
180
+ }
181
+ return saveSweep(store, record);
182
+ }
183
+ export function sweepKinds(withPool) {
184
+ return ["test temp", "orphans", ...(withPool ? ["checkouts"] : []), "runtimes", "dependencies"];
185
+ }
186
+ /** Child side (also a focused unit-test seam): every scan and removal, without saving the parent's result. */
187
+ export async function executeSweep(store, deps, now) {
64
188
  const actor = deps.actor ?? "worker";
65
189
  const read = deps.census ?? (() => processCensus());
66
190
  const parts = [];
191
+ const spare = liveUse(store, deps, now);
67
192
  const part = async (kind, work) => {
193
+ await nextTurn();
68
194
  try {
69
195
  parts.push({ kind, ...await work() });
70
196
  }
@@ -79,12 +205,12 @@ export async function sweepStorage(store, deps, now) {
79
205
  alive = ownerRunning(read());
80
206
  }
81
207
  catch { /* owned roots stay */ }
82
- const done = removeStaleTestTemp(deps.tempRoots, now(), { ownerAlive: alive, max: TEMP_MAX });
208
+ const done = await removeStaleTestTemp(deps.tempRoots, now(), { ownerAlive: alive, max: TEMP_MAX, spare });
83
209
  return { count: done.removed.length, bytes: 0, failed: done.failed.length, ...(done.more ? { more: true } : {}), items: capped(done.removed) };
84
210
  });
85
211
  await part("orphans", async () => {
86
212
  const done = await sweepOrphans(store, now, {
87
- actor, pool: deps.pool, tempRoots: deps.tempRoots, census: read,
213
+ actor, pool: deps.pool, tempRoots: deps.tempRoots, census: read, spare,
88
214
  ...(deps.locate === undefined ? {} : { locate: deps.locate }), ...(deps.graceMs === undefined ? {} : { graceMs: deps.graceMs }),
89
215
  });
90
216
  return {
@@ -95,16 +221,16 @@ export async function sweepStorage(store, deps, now) {
95
221
  if (deps.pool !== null) {
96
222
  const pool = deps.pool;
97
223
  await part("checkouts", async () => {
98
- const done = await cleanCheckouts(store, pool, now, { manual: false, actor });
224
+ const done = await cleanCheckouts(store, pool, now, { manual: false, actor, spare });
99
225
  return { count: done.removed.length, bytes: done.freed, failed: done.kept.filter(one => one.why === "git refused").length, items: capped(done.removed.map(one => one.path)) };
100
226
  });
101
227
  }
102
228
  const stateDir = dirname(deps.databaseFile);
103
229
  await part("runtimes", async () => {
104
- const inUse = deps.inUse ?? (() => runtimesInUse());
230
+ const inUse = deps.inUse ?? (() => runtimesInUse([], undefined, undefined, stateDir));
105
231
  const plan = stagePlan(stateDir, inUse(), now());
106
232
  const sizes = diskBytes(plan.go.map(one => one.path));
107
- const done = pruneStagedRuntimes(stateDir, now(), { inUse });
233
+ const done = pruneStagedRuntimes(stateDir, now(), { inUse, spare });
108
234
  return {
109
235
  count: done.removed.length, bytes: done.removed.reduce((sum, path) => sum + (sizes.get(path) ?? 0), 0), failed: done.failed.length,
110
236
  ...(done.refused === null ? {} : { problem: `kept every staged runtime: ${done.refused}` }), items: capped(done.removed),
@@ -115,10 +241,10 @@ export async function sweepStorage(store, deps, now) {
115
241
  const checkouts = () => store.listWorktrees().map(row => ({ path: row.path, repo: row.repo }));
116
242
  const candidates = sharedUse(root, checkouts()).filter(one => one.checkouts.length === 0 && now().getTime() - Date.parse(one.usedAt) > SHARED_UNUSED_MS);
117
243
  const sizes = diskBytes(candidates.map(one => one.dir));
118
- const done = pruneUnusedShared(root, checkouts, now());
244
+ const done = await pruneUnusedShared(root, checkouts, now(), SHARED_UNUSED_MS, { spare });
119
245
  return { count: done.removed.length, bytes: done.removed.reduce((sum, one) => sum + (sizes.get(one.dir) ?? 0), 0), failed: done.failed.length, items: capped(done.removed.map(one => one.dir)) };
120
246
  });
121
- return saveSweep(store, { at: now().toISOString(), source: "automatic", actor, parts });
247
+ return { at: now().toISOString(), source: "automatic", actor, parts };
122
248
  }
123
249
  /** Save what a sweep or a clean-up by hand did (the latest few are kept), with one ledger entry saying it. */
124
250
  export function saveSweep(store, record) {
@@ -153,7 +279,7 @@ export function lastSweep(store, source) {
153
279
  export function nextSweepAt(store) {
154
280
  if (storageSweepOff())
155
281
  return null;
156
- const last = store.serviceCursor(CURSOR);
282
+ const last = store.serviceCursor(SWEEP_CURSOR);
157
283
  return last === 0 ? null : new Date(last + SWEEP_EVERY_MS).toISOString();
158
284
  }
159
285
  const NOUNS = {
@@ -1,6 +1,8 @@
1
1
  import { type Html } from "./html.js";
2
2
  import { type CheckoutPlan } from "./checkout-cleanup.js";
3
3
  import { type SweepRecord } from "./storage-sweep.js";
4
+ import { type SweepSchedule } from "./storage-sweep-schedule.js";
5
+ import type { LandedSummary } from "./landed-cleanup.js";
4
6
  export declare const STORAGE_CSS: string;
5
7
  export type StorageView = {
6
8
  plan: CheckoutPlan;
@@ -8,7 +10,9 @@ export type StorageView = {
8
10
  sweep?: {
9
11
  last: SweepRecord | null;
10
12
  off: boolean;
13
+ schedule?: SweepSchedule;
11
14
  };
15
+ landed?: LandedSummary;
12
16
  };
13
17
  export declare function storageHtml(view: StorageView, notice: {
14
18
  said?: string | null;
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * Settings → Storage: how much space task checkouts use, what a clean-up would free now, a Clean up button whose
3
3
  * preview (what goes, what stays and why, with paths) sits behind the password, what the last storage sweep removed
4
- * and when (its paths in a disclosure), and when a finished task's clean checkout goes by itself. An instance
5
- * operator's page.
4
+ * and when (its paths in a disclosure) and why the next one waits, what landed work's teardown did and still waits on (in a disclosure, with the
5
+ * one-time command for older work), and when a finished task's clean checkout goes by itself. An instance operator's page.
6
6
  */
7
7
  import { homedir } from "node:os";
8
8
  import { basename } from "node:path";
@@ -10,6 +10,7 @@ import { html, postForm } from "./html.js";
10
10
  import { previewDigest, whyWords } from "./checkout-cleanup.js";
11
11
  import { CLEANUP_CHOICES, bytesWords } from "./storage.js";
12
12
  import { sweepDetails, sweepWords } from "./storage-sweep.js";
13
+ import { sweepScheduleWords } from "./storage-sweep-schedule.js";
13
14
  const plural = (count, one, many = `${one}s`) => `${count} ${count === 1 ? one : many}`;
14
15
  export const STORAGE_CSS = `.storage{max-width:720px;min-width:0;overflow-wrap:anywhere}.storage h2{font-size:1.0625rem;margin:24px 0 4px}` +
15
16
  `.storage .storage-state{display:grid;gap:12px}.storage .storage-state h2{margin:0}.storage .storage-state p{margin:0}` +
@@ -29,8 +30,12 @@ export const STORAGE_CSS = `.storage{max-width:720px;min-width:0;overflow-wrap:a
29
30
  `.storage form.setting button[type=submit],.storage .preview form.remove button[type=submit]{background:var(--primary);color:var(--primary-foreground);border-color:var(--primary);font-weight:600}` +
30
31
  `.storage .sweep{display:grid;gap:4px}.storage .sweep p{margin:0}.storage .sweep .problem{color:var(--destructive)}` +
31
32
  `.storage details.sweep-details>summary{cursor:pointer;min-height:2.25rem;display:inline-flex;align-items:center;font-size:.8125rem;color:var(--muted-foreground)}` +
33
+ `.storage details.landed>summary{cursor:pointer;min-height:2.25rem;display:inline-flex;align-items:center;font-size:.8125rem;color:var(--muted-foreground)}` +
34
+ `.storage details.landed .landed-body{display:grid;gap:6px;margin-top:4px;font-size:.8125rem}.storage details.landed p{margin:0}` +
35
+ `.storage details.landed ul{margin:0;padding-left:18px;overflow-wrap:anywhere}.storage details.landed .target{font-family:var(--font-mono, ui-monospace, monospace);font-size:.6875rem}` +
36
+ `.storage details.landed code{font-family:var(--font-mono, ui-monospace, monospace);font-size:.75rem;overflow-wrap:anywhere}` +
32
37
  `.storage details.sweep-details ul{margin:4px 0 0;padding-left:18px;font-family:var(--font-mono, ui-monospace, monospace);font-size:.6875rem;color:var(--muted-foreground);overflow-wrap:anywhere}` +
33
- `@media (max-width:900px){.storage details.sweep-details>summary{min-height:44px}.storage details.clean-up>summary,.storage form button{min-height:44px}.storage label{max-width:none}.storage select,.storage input[type=password]{min-height:44px;font-size:16px}.storage .preview form.remove button{width:100%}}`;
38
+ `@media (max-width:900px){.storage details.sweep-details>summary,.storage details.landed>summary{min-height:44px}.storage details.clean-up>summary,.storage form button{min-height:44px}.storage label{max-width:none}.storage select,.storage input[type=password]{min-height:44px;font-size:16px}.storage .preview form.remove button{width:100%}}`;
34
39
  /** Paths under the home folder as ~/…, as the checkout list shows them. */
35
40
  function homeAsTilde(line) {
36
41
  const home = homedir();
@@ -41,15 +46,25 @@ function whenWords(at) {
41
46
  const date = new Date(at);
42
47
  return Number.isNaN(date.getTime()) ? at : date.toLocaleString("en-US", { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" });
43
48
  }
44
- /** One line: when the last sweep ran and what it did; its paths and problems behind Details. */
49
+ /** When the last sweep ran and what it did, then why the next one waits or when it is due; paths and problems behind Details. */
45
50
  function sweepHtml(sweep) {
46
51
  const last = sweep.last;
52
+ const schedule = sweep.schedule === undefined ? (sweep.off ? html `<p class="meta" data-sweep-state="off">Automatic sweep: off.</p>` : "")
53
+ : html `<p class="meta" data-sweep-state="${sweep.schedule.state}">Automatic sweep: ${sweepScheduleWords(sweep.schedule, whenWords)}</p>`;
47
54
  if (last === null)
48
- return html `<p class="meta" data-last-sweep="">${sweep.off ? "Automatic sweep is off." : html `Toolroll sweeps leftovers once a day. It hasn't run yet.`}</p>`;
55
+ return html `<div class="sweep" data-last-sweep=""><p>Not swept yet.</p>${schedule}</div>`;
49
56
  const who = last.source === "manual" ? `Cleaned up by ${last.actor}` : "Swept";
50
57
  const details = sweepDetails(last.parts);
51
58
  const problem = last.parts.some(one => one.failed > 0 || one.problem !== undefined);
52
- return html `<div class="sweep" data-last-sweep="${last.at}"><p${problem ? html ` class="problem"` : ""}>${who} <time datetime="${last.at}">${whenWords(last.at)}</time>. ${sweepWords(last.parts)}</p>${details.length === 0 ? "" : html `<details class="sweep-details"><summary>Details</summary><ul>${details.map(line => html `<li>${homeAsTilde(line)}</li>`)}</ul></details>`}</div>`;
59
+ return html `<div class="sweep" data-last-sweep="${last.at}"><p${problem ? html ` class="problem"` : ""}>${who} <time datetime="${last.at}">${whenWords(last.at)}</time>. ${sweepWords(last.parts)}</p>${schedule}${details.length === 0 ? "" : html `<details class="sweep-details"><summary>Details</summary><ul>${details.map(line => html `<li>${homeAsTilde(line)}</li>`)}</ul></details>`}</div>`;
60
+ }
61
+ /** Landed work, on demand: what its teardown did, what it waits on and why, and the one-time command for older work. */
62
+ function landedHtml(landed) {
63
+ const home = homedir();
64
+ const done = landed.completed + landed.checkouts + landed.branches === 0 ? "No landed work cleaned up yet."
65
+ : `${plural(landed.completed, "task")} marked complete; ${plural(landed.checkouts, "checkout")} and ${plural(landed.branches, "local branch", "local branches")} removed.`;
66
+ const waiting = landed.waiting.length === 0 ? "" : html `<p>Waiting (${landed.waiting.length}):</p><ul data-landed-waiting>${landed.waiting.map(one => html `<li><span class="target">${home !== "" && one.target.startsWith(`${home}/`) ? `~${one.target.slice(home.length)}` : one.target}</span>${one.why === null ? "" : html `: ${one.why}`}</li>`)}</ul>`;
67
+ return html `<details class="landed" data-landed><summary>Landed work</summary><div class="landed-body"><p>${done}</p>${waiting}<p class="meta">For work that landed before this: <code>toolroll storage clean --landed</code> shows what would go; add <code>--yes</code> to do it.</p></div></details>`;
53
68
  }
54
69
  function item(one) {
55
70
  const discard = one.why === "has changes"
@@ -70,5 +85,5 @@ export function storageHtml(view, notice) {
70
85
  const preview = html `<details class="clean-up" data-clean-up${notice.problem?.startsWith("Enter your Toolroll password to clean") ? html ` open` : ""}><summary>Clean up</summary><div class="preview">${plan.go.length === 0 ? html `<p>Nothing to remove now.</p>` :
71
86
  html `<h3>Removes ${plural(plan.go.length, "checkout")}, about ${bytesWords(plan.freeBytes)}</h3><p class="meta">Their branches stay, so nothing committed is lost.</p><ul class="checkouts" data-goes>${plan.go.map(one => item(one))}</ul>`}${plan.stay.length === 0 ? "" : html `<h3>Stays (${plan.stay.length})</h3><ul class="checkouts" data-stays>${plan.stay.map(one => item(one))}</ul>`}${remove}</div></details>`;
72
87
  const options = CLEANUP_CHOICES.map(one => html `<option value="${one.value}"${one.value === plan.cleanup ? html ` selected` : ""}>${one.label}</option>`);
73
- return html `<section class="storage">${note}<div class="card storage-state"><h2 data-checkout-bytes="${plan.totalBytes}">Checkouts use ${bytesWords(plan.totalBytes)}</h2><p class="meta">${facts}</p><p data-clean-bytes="${plan.freeBytes}">${frees}</p>${view.sweep === undefined ? "" : sweepHtml(view.sweep)}${preview}</div><h2>Automatic cleanup</h2>${postForm("/settings/storage", html `<label>Remove a finished task's clean checkout<select name="cleanup">${options}</select></label><p class="meta">Its branch always stays. Checkouts with changes, commits on no branch, in use, or waiting for review are kept.</p><label>Your Toolroll password<input type="password" name="password" autocomplete="current-password" required></label><button type="submit">Save</button>`, { attrs: { class: "setting" } })}</section>`;
88
+ return html `<section class="storage">${note}<div class="card storage-state"><h2 data-checkout-bytes="${plan.totalBytes}">Checkouts use ${bytesWords(plan.totalBytes)}</h2><p class="meta">${facts}</p><p data-clean-bytes="${plan.freeBytes}">${frees}</p>${view.sweep === undefined ? "" : sweepHtml(view.sweep)}${view.landed === undefined ? "" : landedHtml(view.landed)}${preview}</div><h2>Automatic cleanup</h2>${postForm("/settings/storage", html `<label>Remove a finished task's clean checkout<select name="cleanup">${options}</select></label><p class="meta">Its branch stays. Checkouts with changes, commits on no branch, in use, or waiting for review are kept. When a task's work is already in main, Toolroll marks it complete and removes its checkout and local branches.</p><label>Your Toolroll password<input type="password" name="password" autocomplete="current-password" required></label><button type="submit">Save</button>`, { attrs: { class: "setting" } })}</section>`;
74
89
  }
package/dist/storage.d.ts CHANGED
@@ -2,6 +2,12 @@ import type { Store } from "./store.js";
2
2
  import { type CheckoutCleanup } from "./contracts/storage.js";
3
3
  /** Settings → Storage: when a finished task's clean checkout is removed. One row, or none (the default). */
4
4
  export declare const CHECKOUT_CLEANUP_SCHEMA = "\nCREATE TABLE IF NOT EXISTS checkout_cleanup_setting (\n id INTEGER PRIMARY KEY CHECK (id = 1),\n cleanup TEXT NOT NULL CHECK (cleanup IN ('finished', '2d', '7d', 'never')),\n updated_by TEXT NOT NULL,\n updated_at TEXT NOT NULL\n);\n";
5
+ /**
6
+ * Landed work's teardown (landed-cleanup.ts): the intent is written first, with the completion it follows, and each
7
+ * checkout and local branch it names is one item, finished at the next reconcile when a crash cut it short. A missing
8
+ * resource finishes its item. New tables only (v121).
9
+ */
10
+ export declare const LANDED_CLEANUP_SCHEMA = "\nCREATE TABLE IF NOT EXISTS landed_cleanup (\n id INTEGER PRIMARY KEY,\n repo TEXT NOT NULL,\n task_id TEXT,\n run_id INTEGER,\n receipt TEXT,\n head TEXT NOT NULL,\n how TEXT NOT NULL CHECK (how IN ('merged', 'in-main', 'content-in-main')),\n reason TEXT NOT NULL,\n completed INTEGER NOT NULL DEFAULT 0 CHECK (completed IN (0, 1)),\n actor TEXT NOT NULL,\n created_at TEXT NOT NULL,\n finished_at TEXT\n);\nCREATE UNIQUE INDEX IF NOT EXISTS landed_cleanup_result ON landed_cleanup(run_id) WHERE run_id IS NOT NULL;\nCREATE TABLE IF NOT EXISTS landed_cleanup_item (\n id INTEGER PRIMARY KEY,\n cleanup INTEGER NOT NULL REFERENCES landed_cleanup(id) ON DELETE CASCADE,\n kind TEXT NOT NULL CHECK (kind IN ('checkout', 'branch')),\n repo TEXT NOT NULL,\n target TEXT NOT NULL,\n expect TEXT NOT NULL,\n state TEXT NOT NULL DEFAULT 'pending' CHECK (state IN ('pending', 'done', 'kept')),\n why TEXT,\n done_at TEXT\n);\nCREATE UNIQUE INDEX IF NOT EXISTS landed_cleanup_pending ON landed_cleanup_item(kind, repo, target) WHERE state = 'pending';\nCREATE INDEX IF NOT EXISTS landed_cleanup_item_of ON landed_cleanup_item(cleanup, state);\n";
5
11
  /** What each storage sweep or clean-up by hand did (storage-sweep.ts): the latest few. */
6
12
  export declare const STORAGE_SWEEP_SCHEMA = "\nCREATE TABLE IF NOT EXISTS storage_sweep (\n id INTEGER PRIMARY KEY,\n at TEXT NOT NULL,\n source TEXT NOT NULL CHECK (source IN ('automatic', 'manual')),\n actor TEXT NOT NULL,\n result TEXT NOT NULL\n);\n";
7
13
  export type { CheckoutCleanup } from "./contracts/storage.js";
package/dist/storage.js CHANGED
@@ -21,6 +21,41 @@ CREATE TABLE IF NOT EXISTS checkout_cleanup_setting (
21
21
  updated_at TEXT NOT NULL
22
22
  );
23
23
  `;
24
+ /**
25
+ * Landed work's teardown (landed-cleanup.ts): the intent is written first, with the completion it follows, and each
26
+ * checkout and local branch it names is one item, finished at the next reconcile when a crash cut it short. A missing
27
+ * resource finishes its item. New tables only (v121).
28
+ */
29
+ export const LANDED_CLEANUP_SCHEMA = `
30
+ CREATE TABLE IF NOT EXISTS landed_cleanup (
31
+ id INTEGER PRIMARY KEY,
32
+ repo TEXT NOT NULL,
33
+ task_id TEXT,
34
+ run_id INTEGER,
35
+ receipt TEXT,
36
+ head TEXT NOT NULL,
37
+ how TEXT NOT NULL CHECK (how IN ('merged', 'in-main', 'content-in-main')),
38
+ reason TEXT NOT NULL,
39
+ completed INTEGER NOT NULL DEFAULT 0 CHECK (completed IN (0, 1)),
40
+ actor TEXT NOT NULL,
41
+ created_at TEXT NOT NULL,
42
+ finished_at TEXT
43
+ );
44
+ CREATE UNIQUE INDEX IF NOT EXISTS landed_cleanup_result ON landed_cleanup(run_id) WHERE run_id IS NOT NULL;
45
+ CREATE TABLE IF NOT EXISTS landed_cleanup_item (
46
+ id INTEGER PRIMARY KEY,
47
+ cleanup INTEGER NOT NULL REFERENCES landed_cleanup(id) ON DELETE CASCADE,
48
+ kind TEXT NOT NULL CHECK (kind IN ('checkout', 'branch')),
49
+ repo TEXT NOT NULL,
50
+ target TEXT NOT NULL,
51
+ expect TEXT NOT NULL,
52
+ state TEXT NOT NULL DEFAULT 'pending' CHECK (state IN ('pending', 'done', 'kept')),
53
+ why TEXT,
54
+ done_at TEXT
55
+ );
56
+ CREATE UNIQUE INDEX IF NOT EXISTS landed_cleanup_pending ON landed_cleanup_item(kind, repo, target) WHERE state = 'pending';
57
+ CREATE INDEX IF NOT EXISTS landed_cleanup_item_of ON landed_cleanup_item(cleanup, state);
58
+ `;
24
59
  /** What each storage sweep or clean-up by hand did (storage-sweep.ts): the latest few. */
25
60
  export const STORAGE_SWEEP_SCHEMA = `
26
61
  CREATE TABLE IF NOT EXISTS storage_sweep (