toolroll 0.9.60 → 0.9.61

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 (118) hide show
  1. package/CHANGELOG.md +15 -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/contracts/console-api.d.ts +3 -1
  34. package/dist/contracts/console-api.js +5 -1
  35. package/dist/contracts/lead-tools.d.ts +16 -2
  36. package/dist/contracts/lead-tools.js +5 -1
  37. package/dist/desktop-update.d.ts +1 -1
  38. package/dist/evidence-pack.js +1 -1
  39. package/dist/evidence.d.ts +6 -0
  40. package/dist/evidence.js +14 -3
  41. package/dist/exec.d.ts +32 -0
  42. package/dist/exec.js +229 -10
  43. package/dist/invoke.js +110 -18
  44. package/dist/landed-cleanup.d.ts +157 -0
  45. package/dist/landed-cleanup.js +707 -0
  46. package/dist/lead-cli.js +22 -19
  47. package/dist/lead-contract.js +1 -1
  48. package/dist/lead-doors.js +45 -1
  49. package/dist/lead-tools.js +40 -7
  50. package/dist/live-input.d.ts +68 -0
  51. package/dist/live-input.js +254 -0
  52. package/dist/live.d.ts +1 -1
  53. package/dist/live.js +4 -2
  54. package/dist/maintenance.d.ts +2 -0
  55. package/dist/maintenance.js +5 -2
  56. package/dist/operate-remote-arguments.js +5 -1
  57. package/dist/operate-remote.js +1 -1
  58. package/dist/operate.d.ts +1 -1
  59. package/dist/operate.js +188 -16
  60. package/dist/orphan-sweep.d.ts +2 -0
  61. package/dist/orphan-sweep.js +5 -3
  62. package/dist/project-delete-ui.d.ts +11 -0
  63. package/dist/project-delete-ui.js +29 -2
  64. package/dist/project-tools.d.ts +7 -11
  65. package/dist/project-tools.js +13 -37
  66. package/dist/provider.d.ts +65 -8
  67. package/dist/provider.js +80 -21
  68. package/dist/result-completion.d.ts +7 -0
  69. package/dist/result-completion.js +9 -0
  70. package/dist/runner.d.ts +4 -2
  71. package/dist/runner.js +3 -3
  72. package/dist/serve.js +19 -1
  73. package/dist/server/live.d.ts +3 -0
  74. package/dist/server/live.js +35 -2
  75. package/dist/server/pages.js +59 -35
  76. package/dist/server/render-chat.js +12 -0
  77. package/dist/server/render-tasks.d.ts +12 -2
  78. package/dist/server/render-tasks.js +27 -7
  79. package/dist/server/route-table.js +4 -1
  80. package/dist/server/settings.js +2 -1
  81. package/dist/server/task-views.js +9 -0
  82. package/dist/server/tasks.js +63 -0
  83. package/dist/shared-deps.d.ts +9 -4
  84. package/dist/shared-deps.js +89 -13
  85. package/dist/staged-runtimes.d.ts +16 -4
  86. package/dist/staged-runtimes.js +160 -38
  87. package/dist/steer-live.d.ts +19 -0
  88. package/dist/steer-live.js +93 -0
  89. package/dist/steer-status.d.ts +8 -2
  90. package/dist/steer-status.js +19 -5
  91. package/dist/storage-sweep-child.d.ts +1 -0
  92. package/dist/storage-sweep-child.js +76 -0
  93. package/dist/storage-sweep-process.d.ts +41 -0
  94. package/dist/storage-sweep-process.js +187 -0
  95. package/dist/storage-sweep.d.ts +30 -6
  96. package/dist/storage-sweep.js +214 -28
  97. package/dist/storage-ui.d.ts +2 -0
  98. package/dist/storage-ui.js +16 -4
  99. package/dist/storage.d.ts +6 -0
  100. package/dist/storage.js +35 -0
  101. package/dist/store.d.ts +163 -2
  102. package/dist/store.js +350 -15
  103. package/dist/surface.js +4 -2
  104. package/dist/sweep-quarantine.d.ts +28 -0
  105. package/dist/sweep-quarantine.js +153 -0
  106. package/dist/task-status.d.ts +2 -0
  107. package/dist/task-status.js +5 -1
  108. package/dist/test-temp.d.ts +6 -3
  109. package/dist/test-temp.js +15 -9
  110. package/dist/toolroll-update.js +3 -2
  111. package/dist/work-index.d.ts +1 -0
  112. package/dist/work-index.js +32 -12
  113. package/dist/work-summary.d.ts +2 -2
  114. package/dist/workspace-revision.js +3 -1
  115. package/dist/worktree.d.ts +3 -1
  116. package/dist/worktree.js +93 -10
  117. package/package.json +1 -1
  118. package/scripts/provider-canary.mjs +235 -147
@@ -5,6 +5,13 @@
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
  *
@@ -13,14 +20,19 @@
13
20
  * takes, brings the next sweep forward to an hour later. `TOOLROLL_STORAGE_SWEEP=off` turns the automatic sweep off
14
21
  * (the unit tests do, so a test never sweeps the machine it runs on).
15
22
  */
16
- import { dirname } from "node:path";
23
+ import { realpathSync } from "node:fs";
24
+ import { hostname } from "node:os";
25
+ import { dirname, join, resolve, sep } from "node:path";
17
26
  import { cleanCheckouts } from "./checkout-cleanup.js";
18
- import { durationWords, sweepOrphans, ownerRunning } from "./orphan-sweep.js";
27
+ import { durationWords, locateProcesses, sweepOrphans, ownerRunning } from "./orphan-sweep.js";
19
28
  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";
29
+ import { linkedKey, pruneUnusedShared, sharedDepsRoot, sharedUse, SHARED_UNUSED_MS } from "./shared-deps.js";
30
+ import { pruneStagedRuntimes, runtimesInUse, runtimeScriptPaths, stagePlan } from "./staged-runtimes.js";
22
31
  import { bytesWords, diskBytes } from "./storage.js";
32
+ import { openStoreNoMigrate } from "./store.js";
23
33
  import { removeStaleTestTemp } from "./test-temp.js";
34
+ import { worktreeProcessOccupancy } from "./worktree.js";
35
+ import { runSweepChild, storageSweepSupervisor } from "./storage-sweep-process.js";
24
36
  const 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. */
@@ -37,34 +49,208 @@ export function storageSweepOff(env = process.env) {
37
49
  export function storageSweepDue(store, now) {
38
50
  return now.getTime() - store.serviceCursor(CURSOR) >= SWEEP_EVERY_MS;
39
51
  }
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())
52
+ /** Claim and launch only. A hung child never holds reconciliation or the caller's store open. */
53
+ export function dailyStorageSweep(store, deps, now, supervisor = storageSweepSupervisor(deps.databaseFile)) {
54
+ if (storageSweepOff() || supervisor.running || !storageSweepDue(store, now()))
43
55
  return null;
56
+ // This handle belongs to the flight, not to the command that has already returned by the time it finishes.
57
+ const connection = openStoreNoMigrate(deps.databaseFile);
58
+ if (!connection.ok)
59
+ throw new Error("Sweep result store unavailable");
60
+ const results = connection.store;
44
61
  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)
62
+ let completed = null;
63
+ let owned = false;
64
+ try {
65
+ const mine = store.transact(() => {
66
+ if (!storageSweepDue(store, at))
67
+ return false;
68
+ store.setServiceCursor(CURSOR, at.getTime(), at);
69
+ return true;
70
+ });
71
+ if (!mine)
72
+ return null;
73
+ const request = { version: 1, databaseFile: deps.databaseFile, poolRoot: deps.pool?.root ?? null, tempRoots: [...deps.tempRoots], actor: deps.actor ?? "worker", at: at.toISOString() };
74
+ owned = supervisor.start(request, result => {
75
+ const record = result.ok ? result.record : sweepFailure(request.at, request.actor, deps.pool !== null, result.reason);
76
+ try {
77
+ results.transact(() => {
78
+ if (!results.schemaCurrent())
79
+ throw new Error("Sweep result store changed");
80
+ saveSweep(results, record);
81
+ // Only this claim can reschedule itself. A later claim must never be overwritten by a late callback.
82
+ if (record.parts.some(one => one.failed > 0 || one.more === true || one.problem !== undefined) && results.serviceCursor(CURSOR) === at.getTime()) {
83
+ results.setServiceCursor(CURSOR, Math.max(0, now().getTime() - SWEEP_EVERY_MS + RETRY_MS), now());
84
+ }
85
+ });
86
+ completed = record;
87
+ }
88
+ catch {
89
+ // No exception/path from a process or database is safe to copy into a log.
90
+ console.error("Could not save the storage sweep result.");
91
+ }
92
+ finally {
93
+ results.close();
94
+ }
95
+ }, deps.owner);
96
+ if (!owned) {
97
+ store.transact(() => {
98
+ if (store.serviceCursor(CURSOR) === at.getTime())
99
+ store.setServiceCursor(CURSOR, Math.max(0, at.getTime() - SWEEP_EVERY_MS + RETRY_MS), now());
100
+ });
101
+ }
102
+ return completed;
103
+ }
104
+ finally {
105
+ if (!owned)
106
+ results.close();
107
+ }
108
+ }
109
+ function sweepFailure(at, actor, withPool, reason) {
110
+ const problem = reason === "timeout" ? "The sweep process exceeded its time limit; some cleanup may have completed. Cleanup will retry later."
111
+ : reason === "stopped" ? "The sweep process was stopped; some cleanup may have completed. Cleanup will retry later."
112
+ : "The sweep process did not return a valid result; some cleanup may have completed.";
113
+ return { at, source: "automatic", actor, parts: sweepKinds(withPool).map(kind => ({ kind, count: 0, bytes: 0, failed: 0, items: [], problem })) };
114
+ }
115
+ export function liveWork(store, depsRoot, now, where) {
116
+ const db = store.handle;
117
+ const tasks = new Set();
118
+ const paths = [];
119
+ for (const row of db.prepare("SELECT task_ref, worktree FROM run WHERE finished_at IS NULL").all()) {
120
+ tasks.add(Number(row["task_ref"]));
121
+ if (typeof row["worktree"] === "string" && row["worktree"] !== "")
122
+ paths.push(row["worktree"]);
123
+ }
124
+ for (const row of db.prepare("SELECT task_ref FROM claim WHERE released_at IS NULL AND expires_at > ?").all(now.toISOString()))
125
+ tasks.add(Number(row["task_ref"]));
126
+ const pids = new Set();
127
+ for (const row of store.listWorktrees()) {
128
+ let held;
129
+ try {
130
+ held = worktreeProcessOccupancy(row.path);
131
+ }
132
+ catch {
133
+ held = { held: true, by: 0 };
134
+ }
135
+ if (row.releasedAt !== null && row.runner === null && !held.held && (row.taskRef === null || !tasks.has(row.taskRef)))
136
+ continue;
137
+ paths.push(row.path);
138
+ if (held.held && held.by > 1)
139
+ pids.add(held.by);
140
+ }
141
+ if (tasks.size === 0 && paths.length === 0)
142
+ return { live: false, paths: [], blind: false };
143
+ const witnessed = db.prepare(`SELECT p.pid AS pid FROM run_process p JOIN run r ON r.id = p.run
144
+ WHERE r.finished_at IS NULL AND p.exited_at IS NULL AND p.pid IS NOT NULL AND p.host = ?`).all(hostname());
145
+ for (const row of witnessed)
146
+ pids.add(Number(row["pid"]));
147
+ const from = pids.size === 0 ? [] : where([...pids].sort((a, b) => a - b));
148
+ if (from === null)
149
+ return { live: true, paths, blind: true };
150
+ paths.push(...from);
151
+ // A checkout (or a run's folder inside one) links its node_modules to a shared install: that install is in use.
152
+ for (const path of [...paths]) {
153
+ for (let at = resolve(path), hops = 0; hops < 32; at = dirname(at), hops++) {
154
+ const key = linkedKey(at, depsRoot);
155
+ if (key !== null) {
156
+ paths.push(join(depsRoot, key));
157
+ break;
158
+ }
159
+ if (dirname(at) === at)
160
+ break;
161
+ }
162
+ }
163
+ return { live: true, paths, blind: false };
164
+ }
165
+ /** Where the processes run from, with their groups and every process under them; null when that can't be read. */
166
+ function processFolders(pids, census, locate, scripts) {
167
+ try {
168
+ const rows = census();
169
+ const take = new Set(rows.filter(row => pids.includes(row.pid) || pids.includes(row.pgid)).map(row => row.pid));
170
+ for (let grew = true; grew;) {
171
+ grew = false;
172
+ for (const row of rows)
173
+ if (!take.has(row.pid) && take.has(row.ppid) && row.ppid > 1) {
174
+ take.add(row.pid);
175
+ grew = true;
176
+ }
177
+ }
178
+ if (take.size === 0)
179
+ return [];
180
+ const located = locate([...take]);
181
+ for (const pid of take) {
182
+ if (located.get(pid)?.cwd !== undefined)
183
+ continue;
184
+ // A vanished process is fine. An unreadable live working folder is not evidence that it uses nothing.
185
+ try {
186
+ process.kill(pid, 0);
187
+ return null;
188
+ }
189
+ catch (error) {
190
+ if (error.code !== "ESRCH")
191
+ return null;
192
+ }
193
+ }
194
+ return [...[...located.values()].flatMap(one => [one.cwd, one.exe]).filter((one) => one !== undefined && one !== ""), ...scripts([...take])];
195
+ }
196
+ catch {
52
197
  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
198
  }
58
- return record;
59
199
  }
200
+ const forms = (path) => {
201
+ const out = [resolve(path)];
202
+ try {
203
+ out.push(realpathSync(path));
204
+ }
205
+ catch { /* gone */ }
206
+ return out;
207
+ };
208
+ /**
209
+ * Whether live work uses `path`, asked again at every call, including process children. A cached process look
210
+ * cannot protect a child launched between the pre-rename and post-rename guards. All this work is in the child.
211
+ */
212
+ export function liveUse(store, deps, now) {
213
+ const census = deps.census ?? (() => processCensus());
214
+ const locate = deps.locate ?? locateProcesses;
215
+ const where = (pids) => processFolders(pids, census, locate, deps.scripts ?? (pids => runtimeScriptPaths(dirname(deps.databaseFile), pids)));
216
+ return path => {
217
+ const work = liveWork(store, sharedDepsRoot(deps.databaseFile), now(), where);
218
+ if (!work.live)
219
+ return false;
220
+ if (work.blind)
221
+ return true;
222
+ const outer = forms(path);
223
+ return work.paths.some(one => forms(one).some(inner => outer.some(base => inner === base || inner.startsWith(base + sep))));
224
+ };
225
+ }
226
+ const nextTurn = () => new Promise(resolve => setImmediate(resolve));
60
227
  const capped = (items) => items.slice(0, ITEMS_MAX);
61
228
  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) {
229
+ /** Parent side: no filesystem/process scans here. The executor seam is for deterministic unit tests only. */
230
+ export async function sweepStorage(store, deps, now, execute = runSweepChild) {
231
+ const at = now().toISOString();
232
+ const actor = deps.actor ?? "worker";
233
+ let record;
234
+ try {
235
+ record = await execute({ version: 1, databaseFile: deps.databaseFile, poolRoot: deps.pool?.root ?? null, tempRoots: [...deps.tempRoots], actor, at });
236
+ }
237
+ catch {
238
+ // Never save child stdout, argv or exception text: those may contain private process inputs.
239
+ record = sweepFailure(at, actor, deps.pool !== null, "failed");
240
+ }
241
+ return saveSweep(store, record);
242
+ }
243
+ export function sweepKinds(withPool) {
244
+ return ["test temp", "orphans", ...(withPool ? ["checkouts"] : []), "runtimes", "dependencies"];
245
+ }
246
+ /** Child side (also a focused unit-test seam): every scan and removal, without saving the parent's result. */
247
+ export async function executeSweep(store, deps, now) {
64
248
  const actor = deps.actor ?? "worker";
65
249
  const read = deps.census ?? (() => processCensus());
66
250
  const parts = [];
251
+ const spare = liveUse(store, deps, now);
67
252
  const part = async (kind, work) => {
253
+ await nextTurn();
68
254
  try {
69
255
  parts.push({ kind, ...await work() });
70
256
  }
@@ -79,12 +265,12 @@ export async function sweepStorage(store, deps, now) {
79
265
  alive = ownerRunning(read());
80
266
  }
81
267
  catch { /* owned roots stay */ }
82
- const done = removeStaleTestTemp(deps.tempRoots, now(), { ownerAlive: alive, max: TEMP_MAX });
268
+ const done = await removeStaleTestTemp(deps.tempRoots, now(), { ownerAlive: alive, max: TEMP_MAX, spare });
83
269
  return { count: done.removed.length, bytes: 0, failed: done.failed.length, ...(done.more ? { more: true } : {}), items: capped(done.removed) };
84
270
  });
85
271
  await part("orphans", async () => {
86
272
  const done = await sweepOrphans(store, now, {
87
- actor, pool: deps.pool, tempRoots: deps.tempRoots, census: read,
273
+ actor, pool: deps.pool, tempRoots: deps.tempRoots, census: read, spare,
88
274
  ...(deps.locate === undefined ? {} : { locate: deps.locate }), ...(deps.graceMs === undefined ? {} : { graceMs: deps.graceMs }),
89
275
  });
90
276
  return {
@@ -95,16 +281,16 @@ export async function sweepStorage(store, deps, now) {
95
281
  if (deps.pool !== null) {
96
282
  const pool = deps.pool;
97
283
  await part("checkouts", async () => {
98
- const done = await cleanCheckouts(store, pool, now, { manual: false, actor });
284
+ const done = await cleanCheckouts(store, pool, now, { manual: false, actor, spare });
99
285
  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
286
  });
101
287
  }
102
288
  const stateDir = dirname(deps.databaseFile);
103
289
  await part("runtimes", async () => {
104
- const inUse = deps.inUse ?? (() => runtimesInUse());
290
+ const inUse = deps.inUse ?? (() => runtimesInUse([], undefined, undefined, stateDir));
105
291
  const plan = stagePlan(stateDir, inUse(), now());
106
292
  const sizes = diskBytes(plan.go.map(one => one.path));
107
- const done = pruneStagedRuntimes(stateDir, now(), { inUse });
293
+ const done = pruneStagedRuntimes(stateDir, now(), { inUse, spare });
108
294
  return {
109
295
  count: done.removed.length, bytes: done.removed.reduce((sum, path) => sum + (sizes.get(path) ?? 0), 0), failed: done.failed.length,
110
296
  ...(done.refused === null ? {} : { problem: `kept every staged runtime: ${done.refused}` }), items: capped(done.removed),
@@ -115,10 +301,10 @@ export async function sweepStorage(store, deps, now) {
115
301
  const checkouts = () => store.listWorktrees().map(row => ({ path: row.path, repo: row.repo }));
116
302
  const candidates = sharedUse(root, checkouts()).filter(one => one.checkouts.length === 0 && now().getTime() - Date.parse(one.usedAt) > SHARED_UNUSED_MS);
117
303
  const sizes = diskBytes(candidates.map(one => one.dir));
118
- const done = pruneUnusedShared(root, checkouts, now());
304
+ const done = await pruneUnusedShared(root, checkouts, now(), SHARED_UNUSED_MS, { spare });
119
305
  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
306
  });
121
- return saveSweep(store, { at: now().toISOString(), source: "automatic", actor, parts });
307
+ return { at: now().toISOString(), source: "automatic", actor, parts };
122
308
  }
123
309
  /** Save what a sweep or a clean-up by hand did (the latest few are kept), with one ledger entry saying it. */
124
310
  export function saveSweep(store, record) {
@@ -1,6 +1,7 @@
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 { LandedSummary } from "./landed-cleanup.js";
4
5
  export declare const STORAGE_CSS: string;
5
6
  export type StorageView = {
6
7
  plan: CheckoutPlan;
@@ -9,6 +10,7 @@ export type StorageView = {
9
10
  last: SweepRecord | null;
10
11
  off: boolean;
11
12
  };
13
+ landed?: LandedSummary;
12
14
  };
13
15
  export declare function storageHtml(view: StorageView, notice: {
14
16
  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), 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";
@@ -29,8 +29,12 @@ export const STORAGE_CSS = `.storage{max-width:720px;min-width:0;overflow-wrap:a
29
29
  `.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
30
  `.storage .sweep{display:grid;gap:4px}.storage .sweep p{margin:0}.storage .sweep .problem{color:var(--destructive)}` +
31
31
  `.storage details.sweep-details>summary{cursor:pointer;min-height:2.25rem;display:inline-flex;align-items:center;font-size:.8125rem;color:var(--muted-foreground)}` +
32
+ `.storage details.landed>summary{cursor:pointer;min-height:2.25rem;display:inline-flex;align-items:center;font-size:.8125rem;color:var(--muted-foreground)}` +
33
+ `.storage details.landed .landed-body{display:grid;gap:6px;margin-top:4px;font-size:.8125rem}.storage details.landed p{margin:0}` +
34
+ `.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}` +
35
+ `.storage details.landed code{font-family:var(--font-mono, ui-monospace, monospace);font-size:.75rem;overflow-wrap:anywhere}` +
32
36
  `.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%}}`;
37
+ `@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
38
  /** Paths under the home folder as ~/…, as the checkout list shows them. */
35
39
  function homeAsTilde(line) {
36
40
  const home = homedir();
@@ -51,6 +55,14 @@ function sweepHtml(sweep) {
51
55
  const problem = last.parts.some(one => one.failed > 0 || one.problem !== undefined);
52
56
  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>`;
53
57
  }
58
+ /** Landed work, on demand: what its teardown did, what it waits on and why, and the one-time command for older work. */
59
+ function landedHtml(landed) {
60
+ const home = homedir();
61
+ const done = landed.completed + landed.checkouts + landed.branches === 0 ? "No landed work cleaned up yet."
62
+ : `${plural(landed.completed, "task")} marked complete; ${plural(landed.checkouts, "checkout")} and ${plural(landed.branches, "local branch", "local branches")} removed.`;
63
+ 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>`;
64
+ 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>`;
65
+ }
54
66
  function item(one) {
55
67
  const discard = one.why === "has changes"
56
68
  ? html `<details class="discard"><summary>Discard</summary>${postForm("/settings/storage/discard", html `<p class="meta">Throws away this checkout and its uncommitted changes. Its branch stays.</p><label>Your Toolroll password<input type="password" name="password" autocomplete="current-password" required></label><button type="submit" class="danger">Discard changes</button>`, { hidden: { path: one.path } })}</details>`
@@ -70,5 +82,5 @@ export function storageHtml(view, notice) {
70
82
  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
83
  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
84
  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>`;
85
+ 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
86
  }
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 (