toolroll 0.9.31 → 0.9.32

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 (55) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/dist/assignment.d.ts +3 -0
  3. package/dist/assignment.js +8 -2
  4. package/dist/batch-checks.d.ts +15 -0
  5. package/dist/batch-checks.js +244 -0
  6. package/dist/batch-policy.d.ts +23 -0
  7. package/dist/batch-policy.js +25 -0
  8. package/dist/browser/workspace.js +13 -13
  9. package/dist/builder.js +14 -3
  10. package/dist/chat-actions.js +2 -2
  11. package/dist/check-levels-ui.d.ts +4 -0
  12. package/dist/check-levels-ui.js +21 -2
  13. package/dist/checkout-cleanup.d.ts +2 -1
  14. package/dist/checkout-cleanup.js +24 -8
  15. package/dist/desktop-update.d.ts +1 -1
  16. package/dist/exec.d.ts +2 -0
  17. package/dist/exec.js +11 -0
  18. package/dist/lead-capabilities.d.ts +0 -7
  19. package/dist/lead-capabilities.js +11 -1
  20. package/dist/mate-doors.js +2 -2
  21. package/dist/mate.d.ts +6 -2
  22. package/dist/mate.js +63 -38
  23. package/dist/never-index.d.ts +3 -0
  24. package/dist/never-index.js +21 -0
  25. package/dist/operate.js +86 -2
  26. package/dist/orphan-sweep.d.ts +63 -0
  27. package/dist/orphan-sweep.js +235 -0
  28. package/dist/result-follow-ups.d.ts +22 -2
  29. package/dist/result-follow-ups.js +44 -10
  30. package/dist/serve.js +26 -7
  31. package/dist/shared-deps.d.ts +18 -2
  32. package/dist/shared-deps.js +129 -15
  33. package/dist/staged-runtimes.d.ts +42 -0
  34. package/dist/staged-runtimes.js +191 -0
  35. package/dist/storage-sweep.d.ts +50 -0
  36. package/dist/storage-sweep.js +203 -0
  37. package/dist/storage-ui.d.ts +5 -0
  38. package/dist/storage-ui.js +30 -4
  39. package/dist/storage.d.ts +2 -0
  40. package/dist/storage.js +15 -3
  41. package/dist/store.d.ts +17 -2
  42. package/dist/store.js +37 -5
  43. package/dist/subscription-chat.d.ts +3 -1
  44. package/dist/subscription-chat.js +1 -0
  45. package/dist/surface.js +1 -0
  46. package/dist/task-status.d.ts +13 -0
  47. package/dist/task-status.js +37 -3
  48. package/dist/team-leads.js +6 -2
  49. package/dist/team-runtime.js +2 -1
  50. package/dist/test-temp.d.ts +20 -2
  51. package/dist/test-temp.js +53 -13
  52. package/dist/toolroll-update.js +2 -0
  53. package/dist/workspace-ui.js +3 -1
  54. package/dist/worktree.js +9 -0
  55. package/package.json +1 -1
package/dist/serve.js CHANGED
@@ -3,6 +3,7 @@ import { aboutYouOf, checkAboutYou, saveAboutYou, ABOUT_YOU_LINE_MAX, ABOUT_YOU_
3
3
  import { withActor } from "./actor.js";
4
4
  import { repositoryContext, repositoryContextRead } from './repository-context.js';
5
5
  import { ADD_TESTS_ACTION, followUpChecksOf, requestFollowUpChecks, fileAddTestsTask, resultCheckLevel } from './result-follow-ups.js';
6
+ import { projectBatchChecks, setProjectBatchChecks } from './batch-policy.js';
6
7
  import { CHECK_LEVEL_WORDS, isCheckLevel, liveQuickCommand, projectCheckLevel, quickVerifyKey, setProjectCheckLevel, suggestQuickCommand } from './check-levels.js';
7
8
  import { repositoryContextHtml } from './repository-context-ui.js';
8
9
  import { browserAssetsAvailable, browserWorkspaceDocument, serveBrowserAsset } from './browser-shell.js';
@@ -235,6 +236,7 @@ import { prometheusMetrics } from "./metrics.js";
235
236
  import { SPEND_CSS, spendCsv, spendHtml } from "./spend-ui.js";
236
237
  import { RETENTION_CSS, retentionHtml } from "./retention-ui.js";
237
238
  import { STORAGE_CSS, storageHtml } from "./storage-ui.js";
239
+ import { lastSweep, saveSweep, storageSweepOff } from "./storage-sweep.js";
238
240
  import { bytesWords, parseCleanup } from "./storage.js";
239
241
  import { checkoutPlan, cleanCheckouts, discardCheckout, previewDigest } from "./checkout-cleanup.js";
240
242
  import { UPDATES_CSS, newerThan, updateStepsHtml, updatesHtml, updatesScript } from "./toolroll-update-ui.js";
@@ -4028,7 +4030,8 @@ export function createDecisionServer(options) {
4028
4030
  if (who.via !== "cookie" || !store.isInstanceOperator(who.name) || databaseFile === null)
4029
4031
  return refuse(response, who, 403, "An instance operator looks after storage.", "/settings");
4030
4032
  const plan = await checkoutPlan(store, storagePool(databaseFile), now, { manual: true });
4031
- return sendScreen(response, 200, screen("Storage", `<p><a href="/settings">Settings</a></p><h1>Storage</h1>${storageHtml({ plan, csrf: who.session.csrf }, { said: url.searchParams.get("said"), problem: url.searchParams.get("problem") })}`, { chrome: chromeFor(project, "settings") }));
4033
+ const sweep = { last: lastSweep(store), off: storageSweepOff() };
4034
+ return sendScreen(response, 200, screen("Storage", `<p><a href="/settings">Settings</a></p><h1>Storage</h1>${storageHtml({ plan, csrf: who.session.csrf, sweep }, { said: url.searchParams.get("said"), problem: url.searchParams.get("problem") })}`, { chrome: chromeFor(project, "settings") }));
4032
4035
  }
4033
4036
  // Settings → Projects → Pull requests: one project's setup. Off, the checks run on open, so the page says
4034
4037
  // exactly what turning on would do, or what to fix first.
@@ -4053,7 +4056,7 @@ export function createDecisionServer(options) {
4053
4056
  const quick = liveQuickCommand(store, repo);
4054
4057
  const html = checkSettingsHtml({ repo, name: projectName(repo), csrf: who.session.csrf, canChange: who.role === "approver" && !store.isDemo(),
4055
4058
  level: projectCheckLevel(store, repo).level, full: store.liveVerifyCommand(repo), quick, suggestion: quick === null ? suggestQuickCommand(repo) : null,
4056
- review: (({ on, source }) => ({ on, source }))(store.reviewSwitch(repo, clock())),
4059
+ review: (({ on, source }) => ({ on, source }))(store.reviewSwitch(repo, clock())), batch: { on: projectBatchChecks(store, repo).on },
4057
4060
  said: url.searchParams.get("said"), problem: url.searchParams.get("problem") });
4058
4061
  return sendScreen(response, 200, screen("Checks", `<p><a href="/projects">Projects</a></p><h1>Checks</h1>${html}`, { chrome: chromeFor(project, "projects") }));
4059
4062
  }
@@ -7541,6 +7544,7 @@ export function createDecisionServer(options) {
7541
7544
  if (body.get("preview") !== previewDigest(plan))
7542
7545
  return back("problem", "What a clean-up would remove changed since you looked. Check the list again.");
7543
7546
  const done = await cleanCheckouts(store, pool, clock, { manual: true, actor: who.name, only: new Set(plan.go.map(one => one.path)) });
7547
+ saveSweep(store, { at: clock().toISOString(), source: "manual", actor: who.name, parts: [{ kind: "checkouts", count: done.removed.length, bytes: done.freed, failed: done.kept.filter(one => one.why === "git refused").length, items: done.removed.slice(0, 20).map(one => one.path) }] });
7544
7548
  return back("said", done.removed.length === 0 ? "Nothing was removed: those checkouts are no longer ready to go." : `Removed ${done.removed.length} checkout${done.removed.length === 1 ? "" : "s"}, about ${bytesWords(done.freed)}. Their branches stay.`);
7545
7549
  }
7546
7550
  const done = await discardCheckout(store, pool, body.get("path") ?? "", now, who.name);
@@ -7602,6 +7606,12 @@ export function createDecisionServer(options) {
7602
7606
  store.setReviewSwitch(repo, on, who.name, now);
7603
7607
  return back("said", on ? "Automatic review is on." : "Automatic review is off.");
7604
7608
  }
7609
+ // Batch checks: off by default; the ledger keeps before → after.
7610
+ if (act === "batch") {
7611
+ const on = body.get("on") === "1";
7612
+ const changed = setProjectBatchChecks(store, repo, on, who.name, now);
7613
+ return back("said", !changed.changed ? "Saved." : on ? "Batch checks are on." : "Batch checks are off.");
7614
+ }
7605
7615
  if (act === "level") {
7606
7616
  const level = body.get("level");
7607
7617
  if (!isCheckLevel(level))
@@ -9672,7 +9682,8 @@ export function createDecisionServer(options) {
9672
9682
  const onProgress = beginLiveTurn(opened.thread.id);
9673
9683
  void runMateTurn({ store, who: principal, session: mateSession, thread: opened.thread, config: enabled.config, key: enabled.key, message, onProgress, channel: "console", ...(requestId === null ? {} : { requestId }), ...(focusTask === null && chatProject !== null ? { context: `Current project: ${projectName(chatProject)} (${chatProject}). Keep this conversation about that project unless the operator explicitly asks to broaden it; use it as the repo for project tools.` } : {}), ...(focusTask === null ? {} : { context: `Current task: ${focusTask.id}. Read it with get_task before answering or proposing changes. Read its currentExecution next and bind new actions to that exact execution. Never replace the target of a prior proposal with a newer revision. Keep this turn about that task unless the operator explicitly asks to broaden it.${resultContext}${modeContext}` }), fetcher: chatFetcher, ...(options.subscriptionChatRunner === undefined ? {} : { subscriptionRunner: options.subscriptionChatRunner }), clock, evidenceRoot })
9674
9684
  .then(outcome => {
9675
- if (!outcome.ok)
9685
+ // A turn that saved its outcome in the thread (one stopped at its deadline) is said there, once.
9686
+ if (!outcome.ok && !("saved" in outcome && outcome.saved === true))
9676
9687
  noteMate(who.session.csrf, "turn" in outcome ? outcome.turn : null, outcome.message);
9677
9688
  endLiveTurn(opened.thread.id, outcome.ok);
9678
9689
  })
@@ -18282,7 +18293,8 @@ function mateBrowserMessages(rows, csrf, back, target = { task: null, project: n
18282
18293
  html: message.role === 'operator' ? `<p>${escape(message.text)}</p>` : renderChatText(message.text, askedBy.get(message.id)) + asked(message.turn),
18283
18294
  activity: message.activity, createdAt: message.createdAt,
18284
18295
  ...(() => {
18285
- const parts = message.turn === null ? [] : rows.proposals.filter(one => one.turn === message.turn)
18296
+ // Under the lead's message only, as the server-rendered thread does: the person's message shares the turn.
18297
+ const parts = message.turn === null || message.role === 'operator' ? [] : rows.proposals.filter(one => one.turn === message.turn)
18286
18298
  .map(one => mateProposalCardParts(one, csrf, rows.pending !== null, rows.decisions.get(typeof one.payload['decision'] === 'number' ? one.payload['decision'] : -1) ?? null, back));
18287
18299
  return { cardsHtml: parts.map(one => one.html).join(''), cards: parts.map(one => one.card) };
18288
18300
  })(),
@@ -23024,9 +23036,16 @@ function followUpsFor(store, evidenceRoot, run, now) {
23024
23036
  const FOLLOW_UP_WORDS = { waiting: "waiting for a worker", running: "running", passed: "passed", failed: "failed", "not-run": "didn't run" };
23025
23037
  /** Run checks and Add tests, under the result's Checks: what ran since, then the two acts. */
23026
23038
  function followUpsHtml(followUps, runId, o) {
23027
- const rows = followUps.checks.map(one => `<li data-follow-up-check="${one.state}">${escape(CHECK_LEVEL_WORDS[one.level])} checks ${escape(FOLLOW_UP_WORDS[one.state])}` +
23028
- `${one.exitCode === null || one.state === "passed" ? "" : ` (exit ${one.exitCode})`} on <span class="mono">${escape(one.head.slice(0, 7))}</span>` +
23029
- `<span class="meta"> · ${one.why === "pull-request" ? "for the pull request" : escape(one.actor)}${one.note === null ? "" : ` · ${escape(one.note)}`}</span>` +
23039
+ // A batch check names every result it ran with and the exact commit it tested (batch-checks.ts).
23040
+ const batchHow = (one) => {
23041
+ const peers = one.batch?.members.filter(member => member.run !== runId).map(member => member.task) ?? [];
23042
+ return one.batch?.mode === "together" && peers.length > 0 ? ` together with ${peers.map(escape).join(", ")}`
23043
+ : one.batch?.mode === "split" ? " on its own after its batch failed" : one.batch?.mode === "conflict" ? " on its own (it conflicted with another result)" : "";
23044
+ };
23045
+ const rows = followUps.checks.map(one => `<li data-follow-up-check="${one.state}"${one.why === "batch" ? ` data-batch="${escape(one.batch?.mode ?? "waiting")}"` : ""}>${escape(CHECK_LEVEL_WORDS[one.level])} checks ` +
23046
+ `${escape(one.why === "batch" && one.state === "waiting" ? "waiting to check with other results" : FOLLOW_UP_WORDS[one.state])}${batchHow(one)}` +
23047
+ `${one.exitCode === null || one.state === "passed" ? "" : ` (exit ${one.exitCode})`} on <span class="mono">${escape((one.tested ?? one.head).slice(0, 7))}</span>` +
23048
+ `<span class="meta"> · ${one.why === "pull-request" ? "for the pull request" : one.why === "batch" ? "batch check" : escape(one.actor)}${one.note === null ? "" : ` · ${escape(one.note)}`}</span>` +
23030
23049
  `${one.logArtifactId === null ? "" : ` <a href="/r/${runId}/evidence/${one.logArtifactId}">Log</a>`}</li>`).join("");
23031
23050
  const hidden = `<input type="hidden" name="csrf" value="${escape(o.csrf)}"><input type="hidden" name="return" value="${escape(o.returnTo)}">`;
23032
23051
  const canCheck = followUps.quick || followUps.full;
@@ -1,12 +1,14 @@
1
1
  export declare const DEPS_FOLDER = "deps";
2
2
  /** A copy linked or made this recently is never removed by a clean-up: a lease may be about to link it. */
3
3
  export declare const SHARED_GRACE_MS: number;
4
+ /** The daily storage sweep removes a copy no checkout uses and none has linked for this long. */
5
+ export declare const SHARED_UNUSED_MS: number;
4
6
  export type SharedCopy = {
5
7
  key: string;
6
8
  /** The copy's folder, holding node_modules and ready.json. */
7
9
  dir: string;
8
10
  repo: string;
9
- /** sha256 of the lockfile and package.json it was installed from. */
11
+ /** sha256 of the lockfile and package.json, excluding the project's own version (raw bytes in old copies). */
10
12
  lock: string;
11
13
  node: string;
12
14
  createdAt: string;
@@ -19,7 +21,8 @@ export declare function sharedDepsRoot(databaseFile: string): string;
19
21
  /** A setup whose whole effect is node_modules: `npm ci` or `npm install`, with plain flags only. */
20
22
  export declare function installsOnly(command: string): boolean;
21
23
  /**
22
- * The checkout's dependency identity: its npm lockfile and package.json, hashed together. null when there is no
24
+ * The checkout's dependency identity: its npm lockfile and package.json, hashed together without the root
25
+ * package's own version, as in release-check.mjs versionOnly(). null when either JSON file is invalid, there is no
23
26
  * lockfile, or the lockfile links local folders (workspaces, `file:`), whose links would point into the shared
24
27
  * folder instead of the checkout.
25
28
  */
@@ -35,6 +38,7 @@ export declare function sharingFor(args: {
35
38
  command: string;
36
39
  digest: string;
37
40
  };
41
+ root?: string;
38
42
  }): {
39
43
  key: string;
40
44
  lock: string;
@@ -92,3 +96,15 @@ export declare function unusedShared(root: string, checkouts: readonly {
92
96
  };
93
97
  /** Remove one copy or debris folder (read-only on disk, so made writable first). */
94
98
  export declare function removeShared(path: string): boolean;
99
+ /**
100
+ * The daily sweep: remove each copy no checkout uses and none has linked for `unusedMs` (a week), and old debris.
101
+ * Right before a copy goes, who uses it and when it was last linked are read again; a copy a checkout picked up in
102
+ * between stays.
103
+ */
104
+ export declare function pruneUnusedShared(root: string, checkouts: () => readonly {
105
+ path: string;
106
+ repo: string;
107
+ }[], now: Date, unusedMs?: number): {
108
+ removed: SharedUse[];
109
+ failed: string[];
110
+ };
@@ -14,12 +14,13 @@
14
14
  * The link is read-only to the build. A checkout whose package.json or lockfile no longer matches the copy it links
15
15
  * gets its own install (the link goes, the approved setup runs there) and the shared copy is never touched; a copy
16
16
  * whose installed tree changed anyway is retired, never linked again. `toolroll storage clean` removes a copy no
17
- * checkout links or matches.
17
+ * checkout links or matches; the plane's daily storage sweep removes one no checkout has used for a week.
18
18
  */
19
19
  import { createHash, randomBytes } from "node:crypto";
20
20
  import { spawnSync } from "node:child_process";
21
21
  import { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, symlinkSync, utimesSync, writeFileSync } from "node:fs";
22
22
  import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
23
+ import { markNeverIndex } from "./never-index.js";
23
24
  export const DEPS_FOLDER = "deps";
24
25
  const READY = "ready.json";
25
26
  const RETIRED = "retired";
@@ -28,6 +29,8 @@ const LINK_NOTE = ".toolroll-shared";
28
29
  const NODE_MODULES = "node_modules";
29
30
  /** A copy linked or made this recently is never removed by a clean-up: a lease may be about to link it. */
30
31
  export const SHARED_GRACE_MS = 60 * 60_000;
32
+ /** The daily storage sweep removes a copy no checkout uses and none has linked for this long. */
33
+ export const SHARED_UNUSED_MS = 7 * 24 * 60 * 60_000;
31
34
  /** A half-made copy (a crash mid-promotion) is debris after this long. */
32
35
  const PARTIAL_STALE_MS = 24 * 60 * 60_000;
33
36
  export function sharedDepsRoot(databaseFile) {
@@ -38,15 +41,20 @@ export function installsOnly(command) {
38
41
  return /^\s*npm\s+(?:ci|install|i)(?:\s+--?[A-Za-z][\w-]*(?:=[\w.@/:+-]+)?)*\s*$/.test(command);
39
42
  }
40
43
  /**
41
- * The checkout's dependency identity: its npm lockfile and package.json, hashed together. null when there is no
44
+ * The checkout's dependency identity: its npm lockfile and package.json, hashed together without the root
45
+ * package's own version, as in release-check.mjs versionOnly(). null when either JSON file is invalid, there is no
42
46
  * lockfile, or the lockfile links local folders (workspaces, `file:`), whose links would point into the shared
43
47
  * folder instead of the checkout.
44
48
  */
45
49
  export function lockDigest(checkout) {
50
+ return dependencyInputs(checkout)?.digest ?? null;
51
+ }
52
+ const digestPair = (lock, manifest) => createHash("sha256").update(lock).update("\u0000").update(manifest).digest("hex");
53
+ function dependencyInputs(checkout) {
46
54
  let lock = null;
47
55
  for (const name of ["npm-shrinkwrap.json", "package-lock.json"]) {
48
56
  try {
49
- lock = readFileSync(join(checkout, name));
57
+ lock = readFileSync(join(checkout, name), "utf8");
50
58
  break;
51
59
  }
52
60
  catch {
@@ -55,16 +63,75 @@ export function lockDigest(checkout) {
55
63
  }
56
64
  if (lock === null)
57
65
  return null;
58
- let manifest;
59
66
  try {
60
- manifest = readFileSync(join(checkout, "package.json"));
67
+ const manifest = readFileSync(join(checkout, "package.json"), "utf8");
68
+ const normalized = (text, isLock) => {
69
+ const json = JSON.parse(text);
70
+ if (json === null || typeof json !== "object" || Array.isArray(json))
71
+ throw Error("not an object");
72
+ delete json.version;
73
+ if (isLock && json.packages?.[""] !== undefined)
74
+ delete json.packages[""].version;
75
+ return JSON.stringify(json);
76
+ };
77
+ if (/"link"\s*:\s*true/.test(lock))
78
+ return null;
79
+ return { lock, manifest, digest: digestPair(normalized(lock, true), normalized(manifest, false)) };
61
80
  }
62
81
  catch {
63
82
  return null;
64
83
  }
65
- if (/"link"\s*:\s*true/.test(lock.toString("utf8")))
66
- return null;
67
- return createHash("sha256").update(lock).update("\u0000").update(manifest).digest("hex");
84
+ }
85
+ /** Replace only root version values, preserving every other byte for comparison with a pre-normalization hash.
86
+ * The caller already parsed the JSON. Token offsets let compact and indented files keep their original layout. */
87
+ function withRootVersion(text, version, isLock) {
88
+ const tokens = [...text.matchAll(/"(?:[^"\\]|\\.)*"|[{}[\]:,]|[^\s{}[\]:,]+/g)];
89
+ let cursor = 0;
90
+ const edits = [];
91
+ const value = (path) => {
92
+ const start = tokens[cursor++];
93
+ if (start[0] === "{") {
94
+ while (tokens[cursor][0] !== "}") {
95
+ const key = JSON.parse(tokens[cursor++][0]);
96
+ cursor++; // colon
97
+ value([...path, key]);
98
+ if (tokens[cursor][0] === ",")
99
+ cursor++;
100
+ }
101
+ cursor++;
102
+ }
103
+ else if (start[0] === "[") {
104
+ let index = 0;
105
+ while (tokens[cursor][0] !== "]") {
106
+ value([...path, String(index++)]);
107
+ if (tokens[cursor][0] === ",")
108
+ cursor++;
109
+ }
110
+ cursor++;
111
+ }
112
+ if ((path.length === 1 && path[0] === "version") ||
113
+ (isLock && path.length === 3 && path[0] === "packages" && path[1] === "" && path[2] === "version")) {
114
+ const end = tokens[cursor - 1];
115
+ edits.push({ start: start.index, end: end.index + end[0].length });
116
+ }
117
+ };
118
+ value([]);
119
+ for (const edit of edits.sort((a, b) => b.start - a.start))
120
+ text = text.slice(0, edit.start) + JSON.stringify(version) + text.slice(edit.end);
121
+ return text;
122
+ }
123
+ /** Old copies did not save the manifests. npm's hidden lock records their root version; reconstruct only those
124
+ * version fields and require the exact old raw digest. Never infer equality from the installed packages alone. */
125
+ function matchesInputs(dir, ready, inputs) {
126
+ if (ready.lock === inputs.digest || ready.lock === digestPair(inputs.lock, inputs.manifest))
127
+ return true;
128
+ try {
129
+ const version = JSON.parse(readFileSync(join(dir, NODE_MODULES, ".package-lock.json"), "utf8")).version;
130
+ return typeof version === "string" && ready.lock === digestPair(withRootVersion(inputs.lock, version, true), withRootVersion(inputs.manifest, version, false));
131
+ }
132
+ catch {
133
+ return false;
134
+ }
68
135
  }
69
136
  const nodeSeen = new Map();
70
137
  /** The node a setup on this PATH would install with: version, platform and architecture (native modules differ). */
@@ -85,13 +152,32 @@ export function depsKey(repo, setupDigest, lock, node) {
85
152
  export function sharingFor(args) {
86
153
  if (!installsOnly(args.setup.command))
87
154
  return null;
88
- const lock = lockDigest(args.worktree);
89
- if (lock === null)
155
+ const inputs = dependencyInputs(args.worktree);
156
+ if (inputs === null)
90
157
  return null;
158
+ const lock = inputs.digest;
91
159
  const node = nodeIdentity();
92
160
  if (node === null)
93
161
  return null;
94
- return { key: depsKey(args.repo, args.setup.digest, lock, node), lock, node };
162
+ const key = depsKey(args.repo, args.setup.digest, lock, node);
163
+ if (args.root !== undefined) {
164
+ let names = [];
165
+ try {
166
+ names = readdirSync(args.root).filter(name => /^[0-9a-f]{24}$/.test(name));
167
+ }
168
+ catch { /* no copies yet */ }
169
+ // Keep a currently linked copy first. Reusing its old directory preserves every existing checkout's links.
170
+ for (const name of new Set([linkedKey(args.worktree, args.root), key, ...names])) {
171
+ if (name === null)
172
+ continue;
173
+ const dir = join(args.root, name);
174
+ const ready = readReady(dir);
175
+ if (ready?.repo === args.repo && ready.setup === args.setup.digest && ready.node === node &&
176
+ readyCopy(args.root, name) !== null && matchesInputs(dir, ready, inputs))
177
+ return { key: name, lock, node };
178
+ }
179
+ }
180
+ return { key, lock, node };
95
181
  }
96
182
  function readReady(dir) {
97
183
  try {
@@ -179,6 +265,7 @@ export function linkInto(worktree, shared) {
179
265
  symlinkSync(join(shared, entry.name), join(at, entry.name), entry.isDirectory() ? (process.platform === "win32" ? "junction" : "dir") : "file");
180
266
  }
181
267
  writeFileSync(join(at, LINK_NOTE), `${shared}\n`, "utf8");
268
+ markNeverIndex(at);
182
269
  touch(dirname(shared));
183
270
  return "link";
184
271
  }
@@ -234,6 +321,8 @@ export function promoteInstall(args) {
234
321
  const partial = join(args.root, `${args.key}.partial-${process.pid}-${randomBytes(4).toString("hex")}`);
235
322
  try {
236
323
  mkdirSync(partial, { recursive: true });
324
+ // Spotlight leaves shared installs alone (never-index.ts).
325
+ markNeverIndex(args.root);
237
326
  renameSync(own, join(partial, NODE_MODULES));
238
327
  }
239
328
  catch {
@@ -317,12 +406,14 @@ export function sharedUse(root, checkouts) {
317
406
  }
318
407
  if (!existsSync(checkout.path))
319
408
  continue;
320
- const lock = lockDigest(checkout.path);
321
- if (lock === null)
409
+ const inputs = dependencyInputs(checkout.path);
410
+ if (inputs === null)
322
411
  continue;
323
- for (const copy of copies)
324
- if (!copy.retired && copy.repo === checkout.repo && copy.lock === lock)
412
+ for (const copy of copies) {
413
+ const ready = readReady(copy.dir);
414
+ if (!copy.retired && copy.repo === checkout.repo && ready !== null && matchesInputs(copy.dir, ready, inputs))
325
415
  users.get(copy.key).add(checkout.path);
416
+ }
326
417
  }
327
418
  return copies.map(one => ({ ...one, checkouts: [...users.get(one.key)].sort() }));
328
419
  }
@@ -363,3 +454,26 @@ export function removeShared(path) {
363
454
  return false;
364
455
  }
365
456
  }
457
+ /**
458
+ * The daily sweep: remove each copy no checkout uses and none has linked for `unusedMs` (a week), and old debris.
459
+ * Right before a copy goes, who uses it and when it was last linked are read again; a copy a checkout picked up in
460
+ * between stays.
461
+ */
462
+ export function pruneUnusedShared(root, checkouts, now, unusedMs = SHARED_UNUSED_MS) {
463
+ const stale = (one) => one.checkouts.length === 0 && now.getTime() - Date.parse(one.usedAt) > unusedMs;
464
+ const removed = [];
465
+ const failed = [];
466
+ for (const one of sharedUse(root, checkouts()).filter(stale)) {
467
+ const fresh = sharedUse(root, checkouts()).find(other => other.key === one.key);
468
+ if (fresh === undefined || !stale(fresh))
469
+ continue;
470
+ if (removeShared(fresh.dir))
471
+ removed.push(fresh);
472
+ else
473
+ failed.push(fresh.dir);
474
+ }
475
+ for (const debris of sharedDebris(root, now))
476
+ if (!removeShared(debris))
477
+ failed.push(debris);
478
+ return { removed, failed };
479
+ }
@@ -0,0 +1,42 @@
1
+ /** A deploy or update that never finished keeps its stage this long (it may be resumed). */
2
+ export declare const UNFINISHED_KEEP_MS: number;
3
+ export declare const STAGED_FOLDER = "staged-upgrades";
4
+ export type Stage = {
5
+ path: string;
6
+ name: string;
7
+ at: number;
8
+ finished: boolean;
9
+ prior: string | null;
10
+ keptAside: boolean;
11
+ };
12
+ export type StageKeep = "current" | "previous" | "newest" | "under way" | "kept-aside database";
13
+ export type StagePlan = {
14
+ keep: {
15
+ path: string;
16
+ why: StageKeep;
17
+ }[];
18
+ go: Stage[];
19
+ refused: string | null;
20
+ };
21
+ /** The `browser-*` and `release-*` stages: real folders directly in staged-upgrades, newest first. */
22
+ export declare function stagedRuntimes(stateDir: string): Stage[];
23
+ /**
24
+ * Paths a runtime may be running from: the launchd services' definitions, every `toolroll` (or older
25
+ * `standing-orders`) command on PATH or under nvm, this process, and `extra`.
26
+ */
27
+ export declare function runtimesInUse(extra?: readonly string[], home?: string, path?: string): string[];
28
+ /** What stays and what goes now, and why. */
29
+ export declare function stagePlan(stateDir: string, inUse: readonly string[], now: Date): StagePlan;
30
+ export type StagePrune = {
31
+ removed: string[];
32
+ failed: string[];
33
+ refused: string | null;
34
+ };
35
+ /**
36
+ * Remove the stages that go. Right before each goes, the plan is made again (what's in use now) and the stage must
37
+ * still be a real folder directly in staged-upgrades.
38
+ */
39
+ export declare function pruneStagedRuntimes(stateDir: string, now: Date, options?: {
40
+ inUse?: () => string[];
41
+ remove?: (path: string) => void;
42
+ }): StagePrune;
@@ -0,0 +1,191 @@
1
+ /**
2
+ * Staged runtimes (`staged-upgrades/`): each deploy (scripts/deploy-browser.mjs, `browser-*`) and each `toolroll
3
+ * update` (`release-*`) stages a whole runtime, a few hundred MB with its database backup. Oct 4: 9 of them, 2.2 GB.
4
+ *
5
+ * Kept: the current runtime (one the service, a `toolroll` command or this process runs from), the one before it
6
+ * (its journal's way back), the newest stage, a deploy or update still under way (no finished journal) for a week,
7
+ * and a stage holding a database a failed update kept aside. Everything else of those two kinds goes — after a deploy
8
+ * and in the plane's daily storage sweep. `rollback-*` records and anything else in the folder are never touched.
9
+ * With no current runtime found, nothing goes: which one is safe to remove is then unknown.
10
+ */
11
+ import { lstatSync, readFileSync, readdirSync, realpathSync, rmSync, statSync } from "node:fs";
12
+ import { homedir } from "node:os";
13
+ import { basename, dirname, join, resolve, sep } from "node:path";
14
+ const DAY_MS = 86_400_000;
15
+ /** A deploy or update that never finished keeps its stage this long (it may be resumed). */
16
+ export const UNFINISHED_KEEP_MS = 7 * DAY_MS;
17
+ export const STAGED_FOLDER = "staged-upgrades";
18
+ /** The canonical path, even of something that doesn't exist (yet): its nearest existing folder, resolved, plus the rest. */
19
+ function real(path) {
20
+ let at = resolve(path);
21
+ const rest = [];
22
+ for (let hops = 0; hops < 256; hops++) {
23
+ try {
24
+ return join(realpathSync(at), ...rest.reverse());
25
+ }
26
+ catch { /* not there */ }
27
+ const up = dirname(at);
28
+ if (up === at)
29
+ break;
30
+ rest.push(basename(at));
31
+ at = up;
32
+ }
33
+ return resolve(path);
34
+ }
35
+ const readJson = (file) => {
36
+ try {
37
+ const parsed = JSON.parse(readFileSync(file, "utf8"));
38
+ return parsed !== null && typeof parsed === "object" ? parsed : null;
39
+ }
40
+ catch {
41
+ return null;
42
+ }
43
+ };
44
+ /** The `browser-*` and `release-*` stages: real folders directly in staged-upgrades, newest first. */
45
+ export function stagedRuntimes(stateDir) {
46
+ const root = real(join(stateDir, STAGED_FOLDER));
47
+ let names = [];
48
+ try {
49
+ names = readdirSync(root);
50
+ }
51
+ catch {
52
+ return [];
53
+ }
54
+ const stages = [];
55
+ for (const name of names) {
56
+ if (!/^(browser|release)-/.test(name))
57
+ continue;
58
+ const path = join(root, name);
59
+ try {
60
+ if (!lstatSync(path).isDirectory())
61
+ continue;
62
+ }
63
+ catch {
64
+ continue;
65
+ }
66
+ const deploy = name.startsWith("browser-") ? readJson(join(path, "deployment.json")) : null;
67
+ const update = name.startsWith("release-") ? readJson(join(path, "update.json")) : null;
68
+ const journal = deploy ?? update;
69
+ const stamp = String(journal?.["deployedAt"] ?? journal?.["startedAt"] ?? journal?.["createdAt"] ?? "");
70
+ let at = Date.parse(stamp);
71
+ if (!Number.isFinite(at)) {
72
+ try {
73
+ at = statSync(path).mtimeMs;
74
+ }
75
+ catch {
76
+ at = 0;
77
+ }
78
+ }
79
+ const finished = deploy !== null ? deploy["phase"] === "deployed" : update !== null ? typeof update["finishedAt"] === "string" : false;
80
+ const from = update?.["from"];
81
+ const prior = typeof deploy?.["priorRuntime"] === "string" ? deploy["priorRuntime"] : typeof from?.dist === "string" ? from.dist : null;
82
+ let keptAside = false;
83
+ try {
84
+ keptAside = readdirSync(path).some(one => /^orders\.(?:kept|unreadable)\./.test(one));
85
+ }
86
+ catch {
87
+ keptAside = true;
88
+ }
89
+ stages.push({ path, name, at, finished, prior, keptAside });
90
+ }
91
+ return stages.sort((a, b) => b.at - a.at);
92
+ }
93
+ /**
94
+ * Paths a runtime may be running from: the launchd services' definitions, every `toolroll` (or older
95
+ * `standing-orders`) command on PATH or under nvm, this process, and `extra`.
96
+ */
97
+ export function runtimesInUse(extra = [], home = homedir(), path = process.env["PATH"] ?? "") {
98
+ const found = [...extra];
99
+ const agents = join(home, "Library", "LaunchAgents");
100
+ try {
101
+ for (const name of readdirSync(agents).filter(one => one.startsWith("com.toolroll.") || one.startsWith("com.standing-orders."))) {
102
+ for (const match of readFileSync(join(agents, name), "utf8").matchAll(/<string>([^<]*staged-upgrades[^<]*)<\/string>/g))
103
+ found.push(match[1]);
104
+ }
105
+ }
106
+ catch { /* no launch agents here */ }
107
+ const commands = path.split(":").filter(Boolean).flatMap(dir => ["toolroll", "standing-orders"].map(bin => join(dir, bin)));
108
+ try {
109
+ for (const version of readdirSync(join(home, ".nvm", "versions", "node")))
110
+ for (const bin of ["toolroll", "standing-orders"])
111
+ commands.push(join(home, ".nvm", "versions", "node", version, "bin", bin));
112
+ }
113
+ catch { /* no nvm */ }
114
+ for (const command of commands) {
115
+ try {
116
+ lstatSync(command);
117
+ found.push(command);
118
+ }
119
+ catch { /* not there */ }
120
+ }
121
+ if (process.argv[1] !== undefined)
122
+ found.push(process.argv[1]);
123
+ return found;
124
+ }
125
+ /** The stage `path` lies in, or null. */
126
+ function stageOf(stages, path) {
127
+ if (path === null || path === undefined || path === "")
128
+ return null;
129
+ for (const at of new Set([resolve(path), real(path)])) {
130
+ for (const stage of stages)
131
+ for (const base of new Set([stage.path, real(stage.path)])) {
132
+ if (at === base || at.startsWith(base + sep))
133
+ return stage;
134
+ }
135
+ }
136
+ return null;
137
+ }
138
+ /** What stays and what goes now, and why. */
139
+ export function stagePlan(stateDir, inUse, now) {
140
+ const stages = stagedRuntimes(stateDir);
141
+ const keep = new Map();
142
+ const current = inUse.map(path => stageOf(stages, path)).filter((one) => one !== null);
143
+ if (stages.length > 0 && current.length === 0)
144
+ return { keep: [], go: [], refused: "no staged runtime is in use, so which one runs now is unknown" };
145
+ for (const one of current)
146
+ keep.set(one.path, "current");
147
+ for (const one of current) {
148
+ const prior = stageOf(stages, one.prior) ?? stages.find(other => other.finished && other.path !== one.path && other.at < one.at) ?? null;
149
+ if (prior !== null && !keep.has(prior.path))
150
+ keep.set(prior.path, "previous");
151
+ }
152
+ if (stages[0] !== undefined && !keep.has(stages[0].path))
153
+ keep.set(stages[0].path, "newest");
154
+ for (const one of stages) {
155
+ if (keep.has(one.path))
156
+ continue;
157
+ if (one.keptAside)
158
+ keep.set(one.path, "kept-aside database");
159
+ else if (!one.finished && now.getTime() - one.at < UNFINISHED_KEEP_MS)
160
+ keep.set(one.path, "under way");
161
+ }
162
+ return { keep: [...keep].map(([path, why]) => ({ path, why })), go: stages.filter(one => !keep.has(one.path)), refused: null };
163
+ }
164
+ /**
165
+ * Remove the stages that go. Right before each goes, the plan is made again (what's in use now) and the stage must
166
+ * still be a real folder directly in staged-upgrades.
167
+ */
168
+ export function pruneStagedRuntimes(stateDir, now, options = {}) {
169
+ const inUse = options.inUse ?? (() => runtimesInUse());
170
+ const first = stagePlan(stateDir, inUse(), now);
171
+ if (first.refused !== null)
172
+ return { removed: [], failed: [], refused: first.refused };
173
+ const root = real(join(stateDir, STAGED_FOLDER));
174
+ const removed = [];
175
+ const failed = [];
176
+ for (const one of first.go) {
177
+ const again = stagePlan(stateDir, inUse(), now);
178
+ if (again.refused !== null || !again.go.some(other => other.path === one.path))
179
+ continue;
180
+ try {
181
+ if (lstatSync(one.path).isSymbolicLink() || dirname(one.path) !== root)
182
+ continue;
183
+ (options.remove ?? (path => rmSync(path, { recursive: true, force: true, maxRetries: 3 })))(one.path);
184
+ removed.push(one.path);
185
+ }
186
+ catch {
187
+ failed.push(one.path);
188
+ }
189
+ }
190
+ return { removed, failed, refused: null };
191
+ }
@@ -0,0 +1,50 @@
1
+ import { type Located } from "./orphan-sweep.js";
2
+ import { type ProcessRow } from "./run-closeout.js";
3
+ import type { Store } from "./store.js";
4
+ import type { WorktreePool } from "./worktree.js";
5
+ export declare const SWEEP_EVERY_MS = 86400000;
6
+ /** A sweep that failed somewhere, or left work for later, runs again this soon. */
7
+ export declare const RETRY_MS: number;
8
+ export type SweepKind = "test temp" | "orphans" | "checkouts" | "runtimes" | "dependencies";
9
+ export type SweepPart = {
10
+ kind: SweepKind;
11
+ count: number;
12
+ bytes: number;
13
+ failed: number;
14
+ more?: boolean;
15
+ problem?: string;
16
+ items: string[];
17
+ };
18
+ export type SweepRecord = {
19
+ at: string;
20
+ source: "automatic" | "manual";
21
+ actor: string;
22
+ parts: SweepPart[];
23
+ };
24
+ export type SweepDeps = {
25
+ databaseFile: string;
26
+ pool: WorktreePool | null;
27
+ tempRoots: readonly string[];
28
+ actor?: string;
29
+ census?: () => ProcessRow[];
30
+ locate?: (pids: readonly number[]) => Map<number, Located>;
31
+ inUse?: () => string[];
32
+ graceMs?: number;
33
+ };
34
+ export declare function storageSweepOff(env?: NodeJS.ProcessEnv): boolean;
35
+ export declare function storageSweepDue(store: Store, now: Date): boolean;
36
+ /** The daily sweep, when it's due and on: one worker takes it. Null when it wasn't this pass's to run. */
37
+ export declare function dailyStorageSweep(store: Store, deps: SweepDeps, now: () => Date): Promise<SweepRecord | null>;
38
+ /** One sweep, now, every part; saved and in the ledger. Each part's failure is its own: the others still run. */
39
+ export declare function sweepStorage(store: Store, deps: SweepDeps, now: () => Date): Promise<SweepRecord>;
40
+ /** Save what a sweep or a clean-up by hand did (the latest few are kept), with one ledger entry saying it. */
41
+ export declare function saveSweep(store: Store, record: SweepRecord): SweepRecord;
42
+ /** The latest sweep (of `source`, or either), or null when there has been none. */
43
+ export declare function lastSweep(store: Store, source?: SweepRecord["source"]): SweepRecord | null;
44
+ /** When the next automatic sweep is due, or null when it is off. */
45
+ export declare function nextSweepAt(store: Store): string | null;
46
+ export declare function partWords(one: Pick<SweepPart, "kind" | "count">): string;
47
+ /** "Removed 120 test temp folders and 2 finished checkouts, about 1.2 GB. Stopped 1 leftover process. 3 couldn't be removed." */
48
+ export declare function sweepWords(parts: readonly SweepPart[]): string;
49
+ /** The details a disclosure shows: each part's paths, problems and what's left for later, one line each. */
50
+ export declare function sweepDetails(parts: readonly SweepPart[]): string[];