@rightkit/release 0.6.6 → 0.6.7

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 (45) hide show
  1. package/README.md +122 -9
  2. package/admission-files.mjs +3 -2
  3. package/advisory-db.json +4 -0
  4. package/advisory-db.mjs +124 -0
  5. package/advisory-exceptions.mjs +13 -10
  6. package/app-check.mjs +257 -38
  7. package/bin/darwin/rightkit-swap +0 -0
  8. package/bin/rightkit-agent-hook.mjs +9 -0
  9. package/bin/rightkit-gate.mjs +495 -344
  10. package/build-release.mjs +13 -6
  11. package/cli/right-release.mjs +29 -6
  12. package/compile-inputs.mjs +66 -0
  13. package/consumer-contracts.json +1044 -0
  14. package/consumer-contracts.mjs +627 -0
  15. package/consumer-input-contracts.mjs +422 -0
  16. package/consumer-policy.mjs +588 -0
  17. package/consumer-source-contracts.mjs +469 -0
  18. package/deny-default.toml +42 -0
  19. package/deterministic-scans.mjs +240 -29
  20. package/disk-command.mjs +270 -30
  21. package/disk-policy.json +50 -10
  22. package/disk-policy.mjs +482 -35
  23. package/disk-report.mjs +129 -0
  24. package/door-policy.mjs +228 -0
  25. package/gate-executor.mjs +30 -0
  26. package/gate-stages.mjs +110 -0
  27. package/github-updater-release.mjs +48 -8
  28. package/init-command.mjs +41 -0
  29. package/legion-semantic-contracts.mjs +428 -0
  30. package/ownership-class-rules.mjs +277 -0
  31. package/ownership-key-authority.mjs +3 -1
  32. package/ownership-mandatory-set.mjs +3 -2
  33. package/package.json +10 -3
  34. package/publish-cargo.mjs +5 -2
  35. package/publish-update.mjs +14 -11
  36. package/release-config.mjs +51 -6
  37. package/rightkit-versions.json +172 -167
  38. package/shell-contract-bridge.json +251 -0
  39. package/shell-contract.mjs +1069 -0
  40. package/sign-updater.mjs +24 -4
  41. package/source-closure-rules.mjs +204 -0
  42. package/stage-sidecar-command.mjs +2 -1
  43. package/stage-sidecar.mjs +437 -114
  44. package/suite-api.generated.mjs +96 -6
  45. package/suite-trust-roots.mjs +198 -0
package/disk-policy.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { closeSync, cpSync, existsSync, lstatSync, mkdirSync, opendirSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, statfsSync, appendFileSync, writeFileSync } from "node:fs";
1
+ import { closeSync, cpSync, existsSync, fstatSync, lstatSync, mkdirSync, openSync, opendirSync, readFileSync, readSync, readdirSync, realpathSync, renameSync, rmSync, statSync, statfsSync, appendFileSync, writeFileSync } from "node:fs";
2
2
  import os from "node:os";
3
3
  import path from "node:path";
4
4
  import { fileURLToPath } from "node:url";
@@ -7,10 +7,36 @@ import { spawnSync } from "./background-process.mjs";
7
7
  export const DISK_EXIT_WARNING = 3;
8
8
  export const DEFAULT_POLICY_FILE = path.join(path.dirname(fileURLToPath(import.meta.url)), "disk-policy.json");
9
9
 
10
+ /** Floors of one platform: `floors.<mac|win>` is a list; a single legacy object is a list of one. */
11
+ export function floorList(policy, key) {
12
+ return [].concat(policy.floors?.[key] ?? []);
13
+ }
14
+
15
+ /** Path of the floor whose volume holds the items that may be parked cross-volume (`holdingSource: true`), else the first
16
+ * floor. Holding must not depend on how many floors a platform has or in which order they are listed. */
17
+ export function holdingSourcePath(policy, key) {
18
+ const floors = floorList(policy, key);
19
+ return (floors.find((f) => f.holdingSource) ?? floors[0])?.path;
20
+ }
21
+
10
22
  export function readDiskPolicy(file = DEFAULT_POLICY_FILE) {
11
23
  const policy = JSON.parse(readFileSync(file, "utf8"));
12
24
  if (policy.schema !== 1 || !Array.isArray(policy.writers)) throw new Error("disk policy: unsupported schema");
25
+ for (const key of Object.keys(policy.floors ?? {})) {
26
+ const floors = floorList(policy, key);
27
+ for (const f of floors) if (!f.path) throw new Error(`disk policy: a ${key} floor needs a path`);
28
+ if (floors.filter((f) => f.holdingSource).length > 1) throw new Error(`disk policy: ${key} floors name more than one holdingSource`);
29
+ const ids = floors.map((f) => f.id).filter(Boolean);
30
+ if (new Set(ids).size !== ids.length) throw new Error(`disk policy: ${key} floor ids must be unique`);
31
+ }
32
+ for (const [key, roots] of Object.entries(policy.strayTargets ?? {})) {
33
+ for (const r of roots) if (typeof r.path !== "string" || !(Number.isInteger(r.depth ?? 4) && (r.depth ?? 4) >= 1 && (r.depth ?? 4) <= 8)) throw new Error(`disk policy: a ${key} strayTargets entry needs a path and a depth of 1 to 8`);
34
+ }
35
+ for (const [prefix, cap] of Object.entries(policy.scratchPrefixCaps ?? {})) {
36
+ if (!RUN_ROOT_NAME.test(prefix) || prefix.includes("-") || !(Number.isFinite(cap) && cap >= 0)) throw new Error(`disk policy: scratchPrefixCaps.${prefix} needs a name without "-" and a byte cap >= 0`);
37
+ }
13
38
  for (const w of policy.writers) {
39
+ if (w.budget !== undefined && !(Array.isArray(w.budget) && w.budget.length && w.budget.every((n) => n === "total" || BUDGET_PARTS.includes(n)))) throw new Error(`disk policy: ${w.id} budget must list parts of ${[...BUDGET_PARTS, "total"].join("|")}`);
14
40
  // `escalate` lets a report-only cache sweep its idle children once it is over cap by `factor` (see planSweep).
15
41
  if (!w.escalate) continue;
16
42
  const e = w.escalate;
@@ -32,6 +58,9 @@ export function expandPolicyPath(value, { env = process.env, home = os.homedir()
32
58
  return out;
33
59
  }
34
60
 
61
+ // One path segment of a policy pattern: `*` matches any run of characters inside that segment only.
62
+ const globSegment = (part) => new RegExp(`^${part.split("*").map((s) => s.replace(/[.+?^${}()|[\]\\]/g, "\\$&")).join(".*")}$`);
63
+
35
64
  // Expand a policy path whose segments may contain `*` wildcards into existing paths.
36
65
  export function expandGlob(pattern, sep = path.sep) {
37
66
  const parts = pattern.split(/[\\/]/);
@@ -45,7 +74,7 @@ export function expandGlob(pattern, sep = path.sep) {
45
74
  if (existsSync(candidate)) next.push(candidate);
46
75
  continue;
47
76
  }
48
- const rx = new RegExp(`^${part.split("*").map((s) => s.replace(/[.+?^${}()|[\]\\]/g, "\\$&")).join(".*")}$`);
77
+ const rx = globSegment(part);
49
78
  let names = [];
50
79
  try { names = readdirSync(base); } catch { continue; }
51
80
  for (const name of names) if (rx.test(name)) next.push(path.join(base, name));
@@ -94,9 +123,11 @@ export function floorThreshold(floor, totalBytes) {
94
123
  return Math.max(floor.warnFreeBytes ?? 0, pct);
95
124
  }
96
125
 
126
+ // `hardFreeBytes` is informational: the level below which RustRight refuses builds. It never makes a floor warn on its own.
97
127
  export function evaluateFloor(floor, space) {
98
128
  const thresholdBytes = floorThreshold(floor, space.totalBytes);
99
- return { label: floor.label, path: floor.path, ...space, thresholdBytes, warn: space.freeBytes < thresholdBytes };
129
+ const hard = floor.hardFreeBytes != null ? { hardFreeBytes: floor.hardFreeBytes, hardWarn: space.freeBytes < floor.hardFreeBytes } : {};
130
+ return { ...(floor.id ? { id: floor.id } : {}), label: floor.label, path: floor.path, ...space, thresholdBytes, warn: space.freeBytes < thresholdBytes, ...hard, ...(floor.agentVolume ? { agentVolume: true } : {}) };
100
131
  }
101
132
 
102
133
  // Literal directory prefix of a policy path: everything before the first wildcard segment
@@ -137,6 +168,27 @@ export function writerMatches(writer, ctx) {
137
168
  return expandGlob(pattern);
138
169
  }
139
170
 
171
+ // How a writer's path pattern relates to `target`, compared as text (nothing is read from disk, symlinks and redirected
172
+ // environment variables are not resolved): "covers" when the pattern is the target or one of its ancestors, "within" when
173
+ // the target is an ancestor of the pattern, null when they diverge. A pattern that needs an unset variable (`${TEMP}`)
174
+ // proves nothing and relates to nothing.
175
+ function writerPathRelation(writer, target, { platform = process.platform, home = "/home/x", env = {} } = {}) {
176
+ let pattern;
177
+ try { pattern = expandPolicyPath(writer.path, { env, home }); } catch { return null; }
178
+ const key = platformKey(platform);
179
+ const split = (value) => value.split(/[\\/]/).filter(Boolean).map((part) => fold(part, key));
180
+ const patternParts = split(pattern);
181
+ const targetParts = split(target);
182
+ for (let i = 0; i < Math.min(patternParts.length, targetParts.length); i += 1) if (!globSegment(patternParts[i]).test(targetParts[i])) return null;
183
+ return patternParts.length <= targetParts.length ? "covers" : "within";
184
+ }
185
+
186
+ /** True when the writer's path is `target` or an ancestor of it, so a write to `target` belongs to that writer. */
187
+ export const writerCoversPath = (writer, target, options) => writerPathRelation(writer, target, options) === "covers";
188
+
189
+ /** True when the writer's path is `target`, an ancestor of it, or lies below it: the entry is accounted for by some writer. */
190
+ export const writerTouchesPath = (writer, target, options) => writerPathRelation(writer, target, options) !== null;
191
+
140
192
 
141
193
  // ---- Workspace roots (allowlist) and scratch roots ------------------------------------------------------------------
142
194
 
@@ -233,28 +285,129 @@ export function scanWorkspaceRoots({ policy, platform = process.platform, env =
233
285
  return { unknown, skipped };
234
286
  }
235
287
 
236
- export function checkDisk({ policy, platform = process.platform, env = process.env, home = os.homedir(), size = (p) => pathSize(p, { platform }), space = freeSpace, now = Date.now(), scan = scanWorkspaceRoots } = {}) {
288
+ // Top-level names of an agent volume that are the operating system's, not data: never reported as uncovered.
289
+ export const DEFAULT_VOLUME_IGNORE = [".Trashes", ".fseventsd", ".Spotlight-V100", ".TemporaryItems", ".DocumentRevisions-V100", ".DS_Store", ".VolumeIcon.icns", "$RECYCLE.BIN", "System Volume Information", "lost+found"];
290
+
291
+ /** What an agent volume holds beyond its writers: used bytes minus the bytes of the outermost writer paths on that volume
292
+ * (a writer nested inside another, or two writers on one path, count once), and the top-level entries no writer names or
293
+ * contains. An estimate: statfs counts allocated blocks, snapshots and clones included. Null when the volume is not there. */
294
+ function unmanagedOnVolume(floor, { measured, policy, platform, env, home, size, readdir }) {
295
+ const key = platformKey(platform);
296
+ const dev = deviceOf(floor.path);
297
+ if (dev === null) return null;
298
+ const byPath = new Map();
299
+ for (const m of measured) if (deviceOf(m.path) === dev && !byPath.has(fold(m.path, key))) byPath.set(fold(m.path, key), m);
300
+ const onVolume = [...byPath.entries()];
301
+ const outer = onVolume.filter(([p]) => !onVolume.some(([o]) => o !== p && p.startsWith(o + path.sep)));
302
+ const writerBytes = outer.reduce((sum, [, m]) => sum + m.bytes, 0);
303
+ const usedBytes = floor.totalBytes - floor.freeBytes;
304
+ const ignore = new Set((policy.volumeIgnore ?? DEFAULT_VOLUME_IGNORE).map((n) => fold(n, key)));
305
+ const writersHere = policy.writers.filter((w) => w.platform === key);
306
+ const uncoveredTopLevel = [];
307
+ let names = [];
308
+ try { names = readdir(floor.path); } catch { /* unreadable volume root: nothing to list */ }
309
+ for (const name of [...names].sort()) {
310
+ if (ignore.has(fold(name, key))) continue;
311
+ const full = path.join(floor.path, name);
312
+ if (writersHere.some((w) => writerTouchesPath(w, full, { platform, home, env }))) continue;
313
+ let symlink = false;
314
+ try { symlink = lstatSync(full).isSymbolicLink(); } catch { continue; }
315
+ uncoveredTopLevel.push({ name, path: full, symlink, bytes: symlink ? 0 : size(full) });
316
+ }
317
+ // `writers` is what the writer total is made of (the report's categories), so used = sum(writers) + unmanaged holds by construction.
318
+ return { floor: floor.id ?? floor.label, usedBytes, writerBytes, unmanagedBytes: Math.max(0, usedBytes - writerBytes), writers: outer.map(([, m]) => ({ id: m.id, category: m.category, bytes: m.bytes })), uncoveredTopLevel };
319
+ }
320
+
321
+ /** Free space of every floor of a platform, statfs only (no directory is read). A volume that cannot be read (an unmounted
322
+ * /Volumes/D) is a warning on its own floor, never a crash that hides the rest. */
323
+ export function checkFloors({ policy, platform = process.platform, space = freeSpace } = {}) {
324
+ const key = platformKey(platform);
325
+ const floorPolicies = floorList(policy, key);
326
+ if (!floorPolicies.length) throw new Error(`disk policy: no floor for ${key}`);
327
+ return floorPolicies.map((f) => {
328
+ try { return evaluateFloor(f, space(f.path)); } catch (error) {
329
+ return { ...(f.id ? { id: f.id } : {}), label: f.label, path: f.path, absent: true, error: error.message, warn: true, ...(f.agentVolume ? { agentVolume: true } : {}) };
330
+ }
331
+ });
332
+ }
333
+
334
+ // The parts of RustRight's build budget (`rustright doctor --json` -> `buildBudget`) a writer's `budget` list may name; `total` is
335
+ // the whole budget.
336
+ export const BUDGET_PARTS = ["targets", "sccache", "cargoHome", "toolchains", "trash", "slack"];
337
+
338
+ /** RustRight's build budget (`buildBudget` of `rustright doctor --json --no-scan`, all parts in bytes), or null when RustRight is
339
+ * missing, fails, times out or answers with anything else. Nothing is cached and no shell is used. */
340
+ export function readRustrightBudget({ run = spawnSync } = {}) {
341
+ try {
342
+ const r = run("rustright", ["doctor", "--json", "--no-scan"], { encoding: "utf8", timeout: 20_000, shell: false, maxBuffer: 1 << 24 });
343
+ if (!r || r.error || r.status !== 0) return null;
344
+ const budget = JSON.parse(String(r.stdout)).buildBudget;
345
+ if (!budget || typeof budget !== "object") return null;
346
+ for (const part of [...BUDGET_PARTS, "totalBytes"]) if (!Number.isFinite(budget[part]) || budget[part] < 0) return null;
347
+ return budget;
348
+ } catch { return null; }
349
+ }
350
+
351
+ // A writer's cap: the sum of its `budget` parts when RustRight's budget is readable (`rustright`), else the `capBytes` of the
352
+ // policy (`policy`), so a host without RustRight keeps the committed number.
353
+ function writerCap(writer, budget) {
354
+ if (!Array.isArray(writer.budget) || !budget) return { capBytes: writer.capBytes, capSource: "policy" };
355
+ const parts = writer.budget.map((name) => (name === "total" ? budget.totalBytes : budget[name]));
356
+ if (!parts.every(Number.isFinite)) return { capBytes: writer.capBytes, capSource: "policy" };
357
+ return { capBytes: parts.reduce((a, b) => a + b, 0), capSource: "rustright" };
358
+ }
359
+
360
+ export function checkDisk({ policy, platform = process.platform, env = process.env, home = os.homedir(), size = (p) => pathSize(p, { platform }), space = freeSpace, now = Date.now(), scan = scanWorkspaceRoots, readdir = readdirSync, strays = findStrayTargets, readBudget = readRustrightBudget } = {}) {
237
361
  const key = platformKey(platform);
238
- const floorPolicy = policy.floors[key];
239
- const floor = evaluateFloor(floorPolicy, space(floorPolicy.path));
362
+ const floors = checkFloors({ policy, platform, space });
363
+ const floor = floors[0];
240
364
  const writers = [];
241
- for (const writer of policy.writers.filter((w) => w.platform === key)) {
365
+ const measured = [];
366
+ const platformWriters = policy.writers.filter((w) => w.platform === key);
367
+ // RustRight is asked only when a row follows its budget, and once.
368
+ const budget = platformWriters.some((w) => Array.isArray(w.budget)) ? readBudget() : null;
369
+ for (const writer of platformWriters) {
370
+ const category = writer.category ?? writer.id;
371
+ const { capBytes, capSource } = writerCap(writer, budget);
242
372
  let matches;
243
373
  try { matches = writerMatches(writer, { env, home }); } catch (error) {
244
- writers.push({ id: writer.id, action: writer.action, error: error.message, bytes: 0, capBytes: writer.capBytes, over: false, matches: 0 });
374
+ writers.push({ id: writer.id, category, action: writer.action, error: error.message, bytes: 0, capBytes, capSource, over: false, matches: 0 });
245
375
  continue;
246
376
  }
247
- const bytes = matches.reduce((sum, m) => sum + size(m), 0);
248
- writers.push({ id: writer.id, action: writer.action, path: writer.path, matches: matches.length, bytes, capBytes: writer.capBytes, over: bytes > writer.capBytes });
377
+ const sized = matches.map((m) => ({ path: m, bytes: size(m), id: writer.id, category }));
378
+ const bytes = sized.reduce((sum, m) => sum + m.bytes, 0);
379
+ measured.push(...sized);
380
+ writers.push({ id: writer.id, category, action: writer.action, path: writer.path, matches: matches.length, bytes, capBytes, capSource, over: bytes > capBytes });
249
381
  }
250
382
  const warnings = [];
251
- if (floor.warn) warnings.push(`${floor.label} free ${formatBytes(floor.freeBytes)} is below the ${formatBytes(floor.thresholdBytes)} floor`);
383
+ for (const f of floors) {
384
+ if (f.absent) warnings.push(`floor volume ${f.path} is not mounted or cannot be read (${f.error})`);
385
+ else if (f.warn) warnings.push(`${f.label} free ${formatBytes(f.freeBytes)} is below the ${formatBytes(f.thresholdBytes)} floor`);
386
+ }
252
387
  for (const w of writers) if (w.over) warnings.push(`${w.id} holds ${formatBytes(w.bytes)} over its ${formatBytes(w.capBytes)} cap`);
388
+ const unmanaged = [];
389
+ for (const f of floors.filter((x) => x.agentVolume && !x.absent)) {
390
+ const entry = unmanagedOnVolume(f, { measured, policy, platform, env, home, size, readdir });
391
+ if (entry) unmanaged.push(entry);
392
+ }
393
+ for (const u of unmanaged) for (const e of u.uncoveredTopLevel) warnings.push(`no writer covers ${e.path} (${e.symlink ? "symlink" : formatBytes(e.bytes)}): add one to disk-policy.json`);
253
394
  const scanned = scan({ policy, platform, env, home });
254
395
  const unknown = scanned.unknown.map((u) => ({ root: u.root, name: u.name, path: u.path, symlink: u.symlink, bytes: u.symlink ? 0 : size(u.path), ageDays: Math.max(0, Math.floor((now - u.mtimeMs) / 86400_000)) }));
255
396
  for (const u of unknown) warnings.push(`unknown top-level entry ${u.path} (${u.symlink ? "symlink" : formatBytes(u.bytes)}, ${u.ageDays}d old): track it or add it to workspaceAllowlist (sweep only moves it on a host that ran \`disk allow-workspace-sweep\`)`);
256
397
  for (const s of scanned.skipped) warnings.push(`workspace root ${s.path}: tracked paths cannot be read from git, so unknown entries are not evaluated or swept`);
257
- return { schema: 1, platform: key, floor, writers, unknown, warnings, warn: warnings.length > 0 };
398
+ // RustRight owns cargo targets, so a tagged target elsewhere is a stray that something built outside it.
399
+ const strayList = [];
400
+ for (const entry of policy.strayTargets?.[key] ?? []) {
401
+ let root;
402
+ try { root = expandPolicyPath(entry.path, { env, home }); } catch { continue; }
403
+ for (const p of strays(root, { depth: entry.depth ?? 4, protectedSegments: policy.protectedSegments })) {
404
+ let mtimeMs = now;
405
+ try { mtimeMs = lstatSync(p).mtimeMs; } catch { /* vanished */ }
406
+ strayList.push({ path: p, bytes: size(p), ageDays: Math.max(0, Math.floor((now - mtimeMs) / 86400_000)) });
407
+ }
408
+ }
409
+ for (const t of strayList) warnings.push(`stray cargo target ${t.path} (${formatBytes(t.bytes)}, ${t.ageDays}d old): RustRight owns cargo targets; sweep moves it once idle for ${policy.strayTargetDays ?? 1} day(s)`);
410
+ return { schema: 1, platform: key, floor, floors, writers, unmanaged, unknown, strays: strayList, warnings, warn: warnings.length > 0 };
258
411
  }
259
412
 
260
413
  export function formatBytes(n) {
@@ -281,6 +434,165 @@ export function hasFreshContent(root, cutoffMs) {
281
434
  return false;
282
435
  }
283
436
 
437
+ // A directory the agent wants to keep carries a `.keep` file; it protects the directory for `keepMarkerDays` (default 14) after
438
+ // its last touch. An expired marker is ignored, so the unit ages out by the normal rules; a symlink named .keep proves nothing.
439
+ // Directory units only: a marker beside a file would itself be swept as a scratch child.
440
+ export const KEEP_MARKER = ".keep";
441
+ export function keptByMarker(unit, now, days, lstat = lstatSync) {
442
+ try {
443
+ const st = lstat(path.join(unit, KEEP_MARKER));
444
+ return st.isFile() && !st.isSymbolicLink() && st.mtimeMs > now - days * 86400_000;
445
+ } catch { return false; }
446
+ }
447
+
448
+ // ---- Run-root leases ------------------------------------------------------------------------------------------------------
449
+ // A run root is a directory directly under a scratch root that an agent leased with `disk run-root <name>`: it holds
450
+ // `.rightkit-run.json` = {"schema":1,"name","createdAt","leaseUntil"} (ISO times). The sweep leaves a unit with a valid lease alone,
451
+ // and the write-time guard (build-control-policy) lets clones and downloads land only inside a run root. Keep the format: the guard
452
+ // reads exactly this file.
453
+
454
+ export const RUN_LEASE_FILE = ".rightkit-run.json";
455
+ export const DEFAULT_LEASE_DAYS = 3;
456
+ /** The longest lease `disk run-root --days` writes. The write-time guard (build-control-policy) carries the same number and treats a lease
457
+ * that claims more than this (plus one day) from its `createdAt`, or one created in the future, as no lease: a forged file cannot say 2999. */
458
+ export const MAX_LEASE_DAYS = 30;
459
+ const DAY_MS = 86_400_000;
460
+ const RUN_ROOT_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,79}$/;
461
+
462
+ /** Owner of a scratch run: the name before its first `-` (legion-..., rk-..., pulse-..., hr-...). */
463
+ export const scratchOwner = (name) => name.split("-")[0];
464
+
465
+ /** The lease of a run-root directory, or null when it has none or the file is not a regular, well-formed lease (a symlink or an
466
+ * oversized file proves nothing). A lease belongs to its own directory (`name` is the directory's name, so a copied lease file counts
467
+ * nowhere else) and spans at most MAX_LEASE_DAYS (plus one day) from its creation, as `createRunRoot` writes it. */
468
+ export function readRunLease(unit, { lstat = lstatSync, readFile = readFileSync } = {}) {
469
+ try {
470
+ const file = path.join(unit, RUN_LEASE_FILE);
471
+ const st = lstat(file);
472
+ if (!st.isFile() || st.isSymbolicLink() || st.size > 4096) return null;
473
+ const lease = JSON.parse(readFile(file, "utf8"));
474
+ if (lease?.schema !== 1 || typeof lease.name !== "string" || !Number.isFinite(Date.parse(lease.createdAt)) || !Number.isFinite(Date.parse(lease.leaseUntil))) return null;
475
+ if (lease.name !== path.basename(unit)) return null;
476
+ if (Date.parse(lease.leaseUntil) - Date.parse(lease.createdAt) > (MAX_LEASE_DAYS + 1) * DAY_MS) return null;
477
+ return lease;
478
+ } catch { return null; }
479
+ }
480
+
481
+ /** True while the unit's lease has not run out. A lease created more than a day after `now` is not one either. */
482
+ export function leasedByRunRoot(unit, now, options) {
483
+ const lease = readRunLease(unit, options);
484
+ return lease !== null && Date.parse(lease.createdAt) <= now + DAY_MS && Date.parse(lease.leaseUntil) > now;
485
+ }
486
+
487
+ function runRootsBase({ policy, platform, env, home }) {
488
+ const roots = scratchRoots(policy, { platform, env, home });
489
+ if (!roots.length) throw new Error(`disk run-root: the policy has no scratch root for ${platformKey(platform)}`);
490
+ return roots[0];
491
+ }
492
+
493
+ function checkRunRootName(name) {
494
+ if (typeof name !== "string" || !RUN_ROOT_NAME.test(name) || name.endsWith(".")) throw new Error(`disk run-root: name ${JSON.stringify(name)} must be 1 to 80 letters, digits, dots, dashes or underscores, starting with a letter or digit (the part before the first "-" is the owner)`);
495
+ }
496
+
497
+ /** Lease `<scratch root>/<name>` for `days`: creates the directory and writes its lease file (renewing keeps `createdAt`). Refused,
498
+ * with nothing written, while the scratch root as a whole is over the `scratch-total` writer's cap or the name's owner prefix is over
499
+ * its `scratchPrefixCaps` entry: the agent has to release or remove finished run roots first. */
500
+ export function createRunRoot({ policy, platform = process.platform, env = process.env, home = os.homedir(), name, days = DEFAULT_LEASE_DAYS, now = Date.now(), size = (p) => pathSize(p, { platform }) } = {}) {
501
+ checkRunRootName(name);
502
+ if (!(Number.isFinite(days) && days > 0 && days <= MAX_LEASE_DAYS)) throw new Error(`disk run-root: --days must be a positive number of at most ${MAX_LEASE_DAYS}`);
503
+ const base = runRootsBase({ policy, platform, env, home });
504
+ if (!ensureScratchRoots({ policy, platform, env, home }).includes(base)) throw new Error(`disk run-root: scratch root ${base} does not exist and cannot be created (is the volume mounted?)`);
505
+ if (lstatSync(base).isSymbolicLink()) throw new Error(`disk run-root: scratch root ${base} is a symlink`);
506
+ const key = platformKey(platform);
507
+ const total = policy.writers.find((w) => w.id === "scratch-total" && w.platform === key);
508
+ if (total) {
509
+ const bytes = size(base);
510
+ if (bytes > total.capBytes) throw new Error(`disk run-root: refused, scratch holds ${formatBytes(bytes)} over its ${formatBytes(total.capBytes)} total cap; release or remove finished run roots in ${base} first`);
511
+ }
512
+ const owner = scratchOwner(name);
513
+ const prefixCap = policy.scratchPrefixCaps?.[owner];
514
+ if (prefixCap !== undefined) {
515
+ let names = [];
516
+ try { names = readdirSync(base); } catch { /* unreadable: nothing counted */ }
517
+ const bytes = names.filter((n) => scratchOwner(n) === owner).reduce((sum, n) => sum + size(path.join(base, n)), 0);
518
+ if (bytes > prefixCap) throw new Error(`disk run-root: refused, "${owner}" run roots hold ${formatBytes(bytes)} over their ${formatBytes(prefixCap)} cap; release or remove finished ${owner}-* run roots in ${base} first`);
519
+ }
520
+ const dir = path.join(base, name);
521
+ mkdirSync(dir, { recursive: true });
522
+ if (lstatSync(dir).isSymbolicLink() || !lstatSync(dir).isDirectory()) throw new Error(`disk run-root: ${dir} is not a plain directory`);
523
+ const file = path.join(dir, RUN_LEASE_FILE);
524
+ try { if (!lstatSync(file).isFile()) throw new Error(`disk run-root: ${file} is not a regular file`); } catch (error) { if (error.code !== "ENOENT") throw error; }
525
+ const existing = readRunLease(dir);
526
+ const leaseUntil = now + days * DAY_MS;
527
+ // A renewal keeps the original createdAt only while the lease still fits MAX_LEASE_DAYS from it; a longer-running run root starts a new window.
528
+ const keepCreated = existing !== null && leaseUntil - Date.parse(existing.createdAt) <= MAX_LEASE_DAYS * DAY_MS && Date.parse(existing.createdAt) <= now;
529
+ const lease = { schema: 1, name, createdAt: keepCreated ? existing.createdAt : new Date(now).toISOString(), leaseUntil: new Date(leaseUntil).toISOString() };
530
+ const temp = `${file}.tmp-${process.pid}`;
531
+ writeFileSync(temp, `${JSON.stringify(lease)}\n`);
532
+ renameSync(temp, file);
533
+ return { path: dir, lease, renewed: existing !== null };
534
+ }
535
+
536
+ /** End a run root's lease by removing its lease file; the directory stays and ages out by the normal scratch rules. Says whether a
537
+ * lease was there; throws when the run root does not exist. */
538
+ export function releaseRunRoot({ policy, platform = process.platform, env = process.env, home = os.homedir(), name } = {}) {
539
+ checkRunRootName(name);
540
+ const dir = path.join(runRootsBase({ policy, platform, env, home }), name);
541
+ let st;
542
+ try { st = lstatSync(dir); } catch { throw new Error(`disk run-root: no run root ${dir}`); }
543
+ if (st.isSymbolicLink() || !st.isDirectory()) throw new Error(`disk run-root: ${dir} is not a plain directory`);
544
+ const file = path.join(dir, RUN_LEASE_FILE);
545
+ let had = false;
546
+ try { had = lstatSync(file).isFile(); if (had) rmSync(file); } catch { /* no lease file */ }
547
+ return { path: dir, released: had };
548
+ }
549
+
550
+ // ---- Stray cargo targets --------------------------------------------------------------------------------------------------
551
+ // RustRight owns cargo targets (under .rightkit-managed). A `target` or `.cache/cargo-target` directory that cargo tagged
552
+ // (CACHEDIR.TAG) anywhere else in a workspace or scratch root is a stray. The writer rows have no `**` and no content test,
553
+ // so strays are found by a bounded walk instead.
554
+
555
+ export const CACHEDIR_SIGNATURE = "Signature: 8a477f597d28d172789f06886806bc55";
556
+ // Never entered while looking for strays: repositories' own state, dependency trees and the package store (all huge, none cargo's).
557
+ const STRAY_WALK_SKIP = new Set([".git", "node_modules", ".pnpm-store"]);
558
+
559
+ function cargoTagged(dir) {
560
+ let fd;
561
+ try {
562
+ const tag = path.join(dir, "CACHEDIR.TAG");
563
+ if (!lstatSync(tag).isFile()) return false; // lstat: a symlinked tag proves nothing
564
+ fd = openSync(tag, "r");
565
+ const buf = Buffer.alloc(CACHEDIR_SIGNATURE.length);
566
+ return readSync(fd, buf, 0, buf.length, 0) === buf.length && buf.toString("utf8") === CACHEDIR_SIGNATURE;
567
+ } catch { return false; } finally { if (fd !== undefined) { try { closeSync(fd); } catch { /* ignore */ } } }
568
+ }
569
+
570
+ /** Cargo target directories under `root`, at most `depth` levels below it. Walks real directories only (a symlink is never
571
+ * followed), skips `.git`, `node_modules`, `.pnpm-store` and any directory named in `protectedSegments`, and never enters a
572
+ * target directory, tagged or not. A stray is a directory named `target`, or `cargo-target` inside a `.cache`, whose
573
+ * CACHEDIR.TAG starts with cargo's signature. */
574
+ export function findStrayTargets(root, { depth = 4, protectedSegments = [] } = {}) {
575
+ const protectedNames = new Set(protectedSegments);
576
+ if (root.split(/[\\/]/).some((seg) => protectedNames.has(seg))) return [];
577
+ const found = [];
578
+ const stack = [{ dir: root, level: 0 }];
579
+ while (stack.length) {
580
+ const { dir, level } = stack.pop();
581
+ let entries;
582
+ try { entries = readdirSync(dir, { withFileTypes: true }); } catch { continue; }
583
+ for (const entry of entries) {
584
+ if (!entry.isDirectory() || entry.isSymbolicLink() || STRAY_WALK_SKIP.has(entry.name) || protectedNames.has(entry.name)) continue;
585
+ const full = path.join(dir, entry.name);
586
+ if (entry.name === "target" || (entry.name === "cargo-target" && path.basename(dir) === ".cache")) {
587
+ if (cargoTagged(full)) found.push(full);
588
+ continue;
589
+ }
590
+ if (level + 1 < depth) stack.push({ dir: full, level: level + 1 });
591
+ }
592
+ }
593
+ return found.sort();
594
+ }
595
+
284
596
  export function isProtectedPath(candidate, policy) {
285
597
  const segments = candidate.split(/[\\/]/);
286
598
  return (policy.protectedSegments ?? []).some((p) => segments.includes(p));
@@ -318,15 +630,27 @@ export function gitTracksPath(target) {
318
630
  return String(listed.stdout).length > 0;
319
631
  }
320
632
 
321
- export function planSweep({ policy, platform = process.platform, env = process.env, home = os.homedir(), now = Date.now(), staleDays, openPaths, inUse, tracked = gitTracksPath, size = (p) => pathSize(p, { platform }), scan = scanWorkspaceRoots, workspaceSweep = workspaceSweepAllowed({ platform, env, home }) } = {}) {
633
+ // A `.keep` or a live run-root lease on any directory between `root` and a stray target (a scratch run, a checkout) keeps what is
634
+ // beneath it: "kept", "leased" or null. Asked again when the move is applied.
635
+ function heldAbove(unit, root, now, keepDays) {
636
+ let held = null;
637
+ for (let dir = path.dirname(unit); dir !== root && dir.startsWith(root); dir = path.dirname(dir)) held = held ?? (keptByMarker(dir, now, keepDays) ? "kept" : leasedByRunRoot(dir, now) ? "leased" : null);
638
+ return held;
639
+ }
640
+
641
+ export function planSweep({ policy, platform = process.platform, env = process.env, home = os.homedir(), now = Date.now(), staleDays, openPaths, inUse, tracked = gitTracksPath, size = (p) => pathSize(p, { platform }), scan = scanWorkspaceRoots, strays = findStrayTargets, workspaceSweep = workspaceSweepAllowed({ platform, env, home }), light = false } = {}) {
322
642
  const key = platformKey(platform);
323
643
  const effectiveDays = staleDays ?? policy.staleDays;
324
644
  const candidates = [];
325
645
  const skipped = [];
326
646
  const seen = new Set();
647
+ const keepDays = policy.keepMarkerDays ?? 14;
327
648
  const lockCheck = inUse ?? (key === "win" ? windowsLockProbe : (p) => inUseByOpenPaths(p, openPaths));
328
649
  // Sweep writers, plus report writers with `escalate` when they are over cap by its factor (pure caches: idle children go).
329
- for (const writer of policy.writers.filter((w) => w.platform === key && (w.action === "sweep" || w.escalate))) {
650
+ // The light (hourly) pass is stat-only: just the cheap temp-like writers marked `light`, no escalation (it sizes a whole
651
+ // root), no workspace scan and no stray walk.
652
+ const sweepable = (w) => (light ? w.action === "sweep" && w.light === true : w.action === "sweep" || w.escalate);
653
+ for (const writer of policy.writers.filter((w) => w.platform === key && sweepable(w))) {
330
654
  let roots; let literal;
331
655
  try { roots = writerMatches(writer, { env, home }); literal = literalRoot(expandPolicyPath(writer.path, { env, home })); } catch { continue; }
332
656
  // `--days N` overrides every writer; otherwise a writer may carry its own window (QA evidence: 3 days, caches: 2).
@@ -358,20 +682,20 @@ export function planSweep({ policy, platform = process.platform, env = process.e
358
682
  if (symlinkAncestor(unit, literal)) { skipped.push({ ...base, reason: "symlink-ancestor" }); continue; }
359
683
  if (isProtectedPath(unit, policy)) { skipped.push({ ...base, reason: "protected" }); continue; }
360
684
  if (writer.skipTracked && tracked(unit)) { skipped.push({ ...base, reason: "tracked" }); continue; }
685
+ if (keptByMarker(unit, now, keepDays)) { skipped.push({ ...base, reason: "kept" }); continue; }
686
+ if (leasedByRunRoot(unit, now)) { skipped.push({ ...base, reason: "leased" }); continue; }
361
687
  if (hasFreshContent(unit, cutoff)) { skipped.push({ ...base, reason: "fresh" }); continue; }
362
688
  if (lockCheck(unit)) { skipped.push({ ...base, reason: "in-use" }); continue; }
363
- candidates.push({ ...base, root, bytes: size(unit), mtime: new Date(st.mtimeMs).toISOString() });
689
+ candidates.push({ ...base, root, bytes: size(unit), mtime: new Date(st.mtimeMs).toISOString(), freshCutoffMs: cutoff });
364
690
  }
365
691
  }
366
- // Overlapping writers (a whole cache dir and one of its children): the outer unit wins, the nested one is not moved twice.
367
- const nested = candidates.filter((c) => candidates.some((o) => o !== c && c.path.startsWith(o.path + path.sep)));
368
- for (const c of nested) skipped.push({ writer: c.writer, path: c.path, reason: "nested" });
369
- if (nested.length) candidates.splice(0, candidates.length, ...candidates.filter((c) => !nested.includes(c)));
370
692
  // Allowlisted workspace roots: unknown top-level entries older than 3 days and idle.
371
- const scanned = scan({ policy, platform, env, home });
693
+ const scanned = light ? { unknown: [], skipped: [] } : scan({ policy, platform, env, home });
372
694
  skipped.push(...scanned.skipped);
373
695
  const unknownCutoff = now - (staleDays ?? policy.unknownStaleDays ?? 3) * 86400_000;
374
- const consider = (writerId, unit, root, cutoff) => {
696
+ // Scratch is run output the agent owns: it ages out on its own, shorter window, `--days N` still overriding both.
697
+ const scratchCutoff = now - (staleDays ?? policy.scratchStaleDays ?? policy.unknownStaleDays ?? 3) * 86400_000;
698
+ const consider = (writerId, unit, root, cutoff, { skipTracked = false } = {}) => {
375
699
  if (seen.has(unit)) return;
376
700
  seen.add(unit);
377
701
  const base = { writer: writerId, path: unit };
@@ -380,24 +704,43 @@ export function planSweep({ policy, platform = process.platform, env = process.e
380
704
  if (st.isSymbolicLink()) { skipped.push({ ...base, reason: "symlink" }); return; }
381
705
  if (!isInsideRoot(unit, root)) { skipped.push({ ...base, reason: "outside-root" }); return; }
382
706
  if (isProtectedPath(unit, policy)) { skipped.push({ ...base, reason: "protected" }); return; }
707
+ if (skipTracked && tracked(unit)) { skipped.push({ ...base, reason: "tracked" }); return; }
708
+ if (keptByMarker(unit, now, keepDays)) { skipped.push({ ...base, reason: "kept" }); return; }
709
+ if (leasedByRunRoot(unit, now)) { skipped.push({ ...base, reason: "leased" }); return; }
383
710
  if (hasFreshContent(unit, cutoff)) { skipped.push({ ...base, reason: "fresh" }); return; }
384
711
  if (lockCheck(unit)) { skipped.push({ ...base, reason: "in-use" }); return; }
385
- candidates.push({ ...base, root, bytes: size(unit), mtime: new Date(st.mtimeMs).toISOString() });
712
+ candidates.push({ ...base, root, bytes: size(unit), mtime: new Date(st.mtimeMs).toISOString(), freshCutoffMs: cutoff });
386
713
  };
387
714
  // Report-only unless this host opted in (`disk allow-workspace-sweep`): unknown entries are warned about, never moved.
388
715
  for (const u of scanned.unknown) {
389
716
  if (!workspaceSweep) { skipped.push({ writer: "workspace-unknown", path: u.path, reason: "report-only" }); continue; }
390
717
  consider("workspace-unknown", u.path, u.root, unknownCutoff);
391
718
  }
392
- // Scratch roots: every child older than 3 days and idle (the same window).
719
+ // Scratch roots: every child idle for `scratchStaleDays`.
393
720
  for (const root of scratchRoots(policy, { platform, env, home })) {
394
721
  let rst;
395
722
  try { rst = lstatSync(root); } catch { continue; }
396
723
  if (rst.isSymbolicLink()) { skipped.push({ writer: "scratch-root", path: root, reason: "symlink-root" }); continue; }
397
724
  let names = [];
398
725
  try { names = readdirSync(root); } catch { continue; }
399
- for (const n of names) consider("scratch-root", path.join(root, n), root, unknownCutoff);
726
+ for (const n of names) consider("scratch-root", path.join(root, n), root, scratchCutoff);
400
727
  }
728
+ // Stray cargo targets (tagged, outside RustRight's roots): idle `strayTargetDays`, never when git tracks anything in them.
729
+ const strayCutoff = now - (staleDays ?? policy.strayTargetDays ?? 1) * 86400_000;
730
+ for (const entry of light ? [] : policy.strayTargets?.[key] ?? []) {
731
+ let root;
732
+ try { root = expandPolicyPath(entry.path, { env, home }); } catch { continue; }
733
+ for (const unit of strays(root, { depth: entry.depth ?? 4, protectedSegments: policy.protectedSegments })) {
734
+ const held = heldAbove(unit, root, now, keepDays);
735
+ if (held) { skipped.push({ writer: "stray-cargo-target", path: unit, reason: held }); continue; }
736
+ consider("stray-cargo-target", unit, root, strayCutoff, { skipTracked: true });
737
+ }
738
+ }
739
+ // Overlapping units (a whole cache dir and one of its children, a scratch run and a target inside it): the outer unit wins,
740
+ // the nested one is not moved twice.
741
+ const nested = candidates.filter((c) => candidates.some((o) => o !== c && c.path.startsWith(o.path + path.sep)));
742
+ for (const c of nested) skipped.push({ writer: c.writer, path: c.path, reason: "nested" });
743
+ if (nested.length) candidates.splice(0, candidates.length, ...candidates.filter((c) => !nested.includes(c)));
401
744
  return { schema: 1, platform: key, staleDays: effectiveDays, candidates, skipped, totalBytes: candidates.reduce((s, c) => s + c.bytes, 0) };
402
745
  }
403
746
 
@@ -444,8 +787,8 @@ export function defaultMounted(mount, itemDev) {
444
787
  }
445
788
 
446
789
  // Candidate holding bases for an item, best first: policy per-volume rules (longest prefix), then the item's own volume root.
447
- // A rule with `mount` is a cross-volume base: it applies only to items on the policy floor's volume (the Mac internal drive),
448
- // and only while that mount is present (checked at pick time). Entries are `{ path, mount? }`.
790
+ // A rule with `mount` is a cross-volume base: it applies only to items on the volume of the floor flagged `holdingSource`
791
+ // (the Mac internal drive), and only while that mount is present (checked at pick time). Entries are `{ path, mount? }`.
449
792
  export function holdingBases(candidate, { policy, platform = process.platform, env = process.env, home = os.homedir() } = {}) {
450
793
  const key = platformKey(platform);
451
794
  const out = [];
@@ -458,7 +801,7 @@ export function holdingBases(candidate, { policy, platform = process.platform, e
458
801
  for (const r of rules) {
459
802
  if (r.mount) {
460
803
  if (floorDev === undefined) {
461
- const floorPath = policy.floors?.[key]?.path;
804
+ const floorPath = holdingSourcePath(policy, key);
462
805
  floorDev = floorPath ? deviceOf(expandPolicyPath(floorPath, { env, home })) : null;
463
806
  }
464
807
  if (floorDev === null || deviceOf(path.dirname(candidate)) !== floorDev) continue;
@@ -534,6 +877,63 @@ export function moveEntry(source, destination) {
534
877
  return "copy-verify-remove";
535
878
  }
536
879
 
880
+ // The tail of the log read by `readLogTail`, then more of it, until an event is found: a busy day appends thousands of sweep lines
881
+ // after the last full pass, so the newest full-pass line can lie further back than any one window. 4, 16, 64 and 256 MiB.
882
+ export const EVENT_SCAN_WINDOWS = [1 << 22, 1 << 24, 1 << 26, 1 << 28];
883
+
884
+ /** Parsed events of the log's last `maxBytes`, oldest first, and whether that reached the start of the file. A partial first line and
885
+ * any unparsable line are skipped. Throws what the filesystem throws (a missing log is ENOENT). */
886
+ function readLogTail(logFile, maxBytes) {
887
+ let fd;
888
+ try {
889
+ fd = openSync(logFile, "r");
890
+ const st = fstatSync(fd);
891
+ // A directory reports size 0 on Windows (and under /proc), so reading it would look like an empty log rather than fail.
892
+ if (!st.isFile()) throw Object.assign(new Error(`EISDIR: ${logFile} is not a regular file`), { code: "EISDIR" });
893
+ const { size } = st;
894
+ const start = Math.max(0, size - maxBytes);
895
+ const buf = Buffer.alloc(size - start);
896
+ readSync(fd, buf, 0, buf.length, start);
897
+ const lines = buf.toString("utf8").split("\n");
898
+ if (start > 0) lines.shift(); // starts mid-line
899
+ const events = [];
900
+ for (const line of lines) { try { const e = JSON.parse(line); if (e && typeof e === "object" && !Array.isArray(e)) events.push(e); } catch { /* blank or damaged line */ } }
901
+ return { events, whole: start === 0 };
902
+ } finally { if (fd !== undefined) { try { closeSync(fd); } catch { /* ignore */ } } }
903
+ }
904
+
905
+ /** Parsed events of the log's last `maxBytes`, oldest first. A partial first line and any unparsable line are skipped; a log
906
+ * that cannot be read is an empty list. */
907
+ export function readRecentEvents(logFile, { maxBytes = 1 << 22 } = {}) {
908
+ try { return readLogTail(logFile, maxBytes).events; } catch { return []; }
909
+ }
910
+
911
+ /** Newest event of the log whose `event` is one of `names` (and that `accept`s), or null. The log is read backwards in growing
912
+ * windows (EVENT_SCAN_WINDOWS) until a match turns up or the whole file has been read. A log that does not exist has no event (null:
913
+ * the job never ran); any other read error (EIO, ENOTDIR, a volume that went away) is thrown, because "nothing found" would then
914
+ * be a guess. `maxBytes` limits the scan to one window of that size. */
915
+ /** Windows reports ENOENT, not ENOTDIR, for a path that runs through a file; POSIX reports ENOTDIR. Rebuild that distinction: the nearest
916
+ * existing ancestor being anything but a directory means the log path is broken, not that the log is missing. */
917
+ export function throughFile(logFile, stat = statSync) {
918
+ for (let dir = path.dirname(path.resolve(logFile)); ; dir = path.dirname(dir)) {
919
+ let stats;
920
+ // Only a missing ancestor is skipped; any other error on the way up (EACCES, EIO, ...) is the read error itself, never "missing".
921
+ try { stats = stat(dir); } catch (error) { if (error?.code === "ENOENT" && path.dirname(dir) !== dir) continue; if (error?.code === "ENOENT") return false; throw error; }
922
+ if (stats.isDirectory()) return false;
923
+ throw Object.assign(new Error(`ENOTDIR: not a directory, open '${logFile}'`), { code: "ENOTDIR", path: logFile });
924
+ }
925
+ }
926
+
927
+ export function lastEvent(logFile, names, { maxBytes, accept = () => true, windows = EVENT_SCAN_WINDOWS } = {}) {
928
+ for (const window of maxBytes ? [maxBytes] : windows) {
929
+ let tail;
930
+ try { tail = readLogTail(logFile, window); } catch (error) { if (error?.code === "ENOENT" && !throughFile(logFile)) return null; throw error; }
931
+ for (let i = tail.events.length - 1; i >= 0; i -= 1) if (names.includes(tail.events[i].event) && accept(tail.events[i])) return tail.events[i];
932
+ if (tail.whole) return null;
933
+ }
934
+ return null;
935
+ }
936
+
537
937
  export function logEvent(logFile, event) {
538
938
  try {
539
939
  mkdirSync(path.dirname(logFile), { recursive: true });
@@ -541,14 +941,38 @@ export function logEvent(logFile, event) {
541
941
  } catch { /* logging must never fail the sweep */ }
542
942
  }
543
943
 
544
- export function applySweep({ plan, policy, platform = process.platform, deleteMode = false, date = new Date(), env = process.env, home = os.homedir(), move = moveEntry, pick = pickHolding, isMounted, remove = (p) => rmSync(p, { recursive: true, force: true }) }) {
944
+ // Why an item the plan listed must not be moved now, or null. The plan can be minutes old (a full pass sizes everything in between)
945
+ // and an agent may have kept, leased or touched the unit since: the checks of planSweep run again, per candidate, right before the move.
946
+ function recheckSweep(c, { policy, now }) {
947
+ const keepDays = policy.keepMarkerDays ?? 14;
948
+ if (keptByMarker(c.path, now, keepDays)) return "rechecked-keep";
949
+ if (leasedByRunRoot(c.path, now)) return "rechecked-leased";
950
+ const above = c.writer === "stray-cargo-target" && c.root ? heldAbove(c.path, c.root, now, keepDays) : null;
951
+ if (above) return above === "kept" ? "rechecked-keep" : "rechecked-leased";
952
+ if (Number.isFinite(c.freshCutoffMs) && hasFreshContent(c.path, c.freshCutoffMs)) return "rechecked-fresh";
953
+ return null;
954
+ }
955
+
956
+ // Where `source` goes below `holding/<writer>`: its own name, else `<name>.1`, `<name>.2`, ... The same name recurs within one
957
+ // sweep (a `target` in every checkout, one child name under several parents), so the first free suffix is looked up each time.
958
+ function freeDestination(holding, writer, source) {
959
+ const first = path.join(holding, writer, path.basename(source));
960
+ let destination = first;
961
+ for (let n = 1; pathExists(destination); n += 1) destination = `${first}.${n}`;
962
+ return destination;
963
+ }
964
+
965
+ export function applySweep({ plan, policy, platform = process.platform, deleteMode = false, date = new Date(), now = Date.now(), env = process.env, home = os.homedir(), move = moveEntry, pick = pickHolding, isMounted, remove = (p) => rmSync(p, { recursive: true, force: true }) }) {
545
966
  const logFile = path.join(policy.heavyVolume[platformKey(platform)], "disk-hygiene.jsonl");
546
967
  const results = [];
968
+ const skipped = [];
547
969
  for (const c of plan.candidates) {
548
970
  try {
549
971
  // Re-verify at apply time: still the planned unit, not a symlink, still inside its root.
550
972
  if (!c.root || lstatSync(c.path).isSymbolicLink() || !isInsideRoot(c.path, c.root)) throw new Error("refused: path is not a sweepable unit inside its target root");
551
973
  if (c.writer === "workspace-unknown" && PROJECT_MARKERS.some((m) => existsSync(path.join(c.path, m)))) throw new Error("refused: a nested git checkout or project directory is never swept");
974
+ const held = recheckSweep(c, { policy, now });
975
+ if (held) { skipped.push({ ...c, reason: held }); logEvent(logFile, { event: "disk.sweep.skip", reason: held, ...c }); continue; }
552
976
  if (deleteMode) { remove(c.path); results.push({ ...c, action: "delete", ok: true }); logEvent(logFile, { event: "disk.sweep.delete", ...c }); continue; }
553
977
  // An item stays on its own volume: moving it elsewhere frees nothing on the volume that is short of space.
554
978
  const holding = pick(c.path, { policy, platform, env, home, date, isMounted });
@@ -558,17 +982,16 @@ export function applySweep({ plan, policy, platform = process.platform, deleteMo
558
982
  logEvent(logFile, { event: "disk.sweep.delete", reason: "no holding dir possible on the item's volume", ...c });
559
983
  continue;
560
984
  }
561
- let destination = path.join(holding, c.writer, path.basename(c.path));
562
- if (existsSync(destination)) destination = `${destination}.${date.getTime()}`;
985
+ const destination = freeDestination(holding, c.writer, c.path);
563
986
  const how = move(c.path, destination);
564
987
  results.push({ ...c, action: "move", destination, how, ok: true });
565
- logEvent(logFile, { event: "disk.sweep.move", ...c, destination, how });
988
+ logEvent(logFile, { event: "disk.sweep.move", ...c, sourceParent: path.dirname(c.path), destination, how });
566
989
  } catch (error) {
567
990
  results.push({ ...c, action: deleteMode ? "delete" : "move", ok: false, error: error.message });
568
991
  logEvent(logFile, { event: "disk.sweep.error", ...c, error: error.message });
569
992
  }
570
993
  }
571
- return { logFile, results, movedBytes: results.filter((r) => r.ok).reduce((s, r) => s + r.bytes, 0) };
994
+ return { logFile, results, skipped, movedBytes: results.filter((r) => r.ok).reduce((s, r) => s + r.bytes, 0) };
572
995
  }
573
996
 
574
997
  // Holding bases that may exist: policy rule paths, each drive root (Windows) or /Volumes/* and the legacy heavy dir (Mac).
@@ -581,8 +1004,31 @@ export function knownHoldingBases({ policy, platform = process.platform, env = p
581
1004
  return [...bases];
582
1005
  }
583
1006
 
584
- /** Remove date-named holding dirs older than `days`. Only dirs this tool created: the base must carry the holding marker
585
- * (or, for the legacy heavy dir, the tool's own log), the child must be a real directory named YYYY-MM-DD, never a symlink. */
1007
+ /** The holding bases (every known one plus the legacy heavy dir) that live on the volume holding `volumePath`: the ones whose purge
1008
+ * frees that volume. An emergency on one volume must not empty another volume's undo window. None when that volume cannot be read. */
1009
+ export function holdingBasesOnVolume(volumePath, { policy, platform = process.platform, env = process.env, home = os.homedir(), deviceOf: deviceFor = deviceOf } = {}) {
1010
+ const key = platformKey(platform);
1011
+ const legacy = policy.heavyVolume?.[key];
1012
+ const device = deviceFor(volumePath);
1013
+ if (device === null || device === undefined) return [];
1014
+ return [...new Set([...knownHoldingBases({ policy, platform, env, home }), ...(legacy ? [legacy] : [])])].filter((base) => deviceFor(base) === device);
1015
+ }
1016
+
1017
+ // Newest modification time (ms) of a holding dir and of its direct children, symlinks not followed. A move into a dated
1018
+ // folder updates the mtime of the folder and of the writer dir inside it, though the moved item keeps its own age. Infinity
1019
+ // when the dir cannot be read: what cannot be inspected is not purged.
1020
+ function newestWriteMs(dir) {
1021
+ let newest;
1022
+ let names;
1023
+ try { newest = lstatSync(dir).mtimeMs; names = readdirSync(dir); } catch { return Infinity; }
1024
+ for (const name of names) { try { newest = Math.max(newest, lstatSync(path.join(dir, name)).mtimeMs); } catch { /* vanished */ } }
1025
+ return newest;
1026
+ }
1027
+
1028
+ /** Remove date-named holding dirs a full `days` after their date (`date 00:00Z + days <= now`), and only when nothing was
1029
+ * moved into or written in the dir within the last `days` either, so every item is held at least `days` even though the
1030
+ * folder name carries only a date. Only dirs this tool created: the base must carry the holding marker (or, for the legacy
1031
+ * heavy dir, the tool's own log), the child must be a real directory named YYYY-MM-DD, never a symlink. */
586
1032
  export function purgeHoldings({ policy, platform = process.platform, env = process.env, home = os.homedir(), now = Date.now(), days = policy.holdingDays ?? 3, dryRun = false, bases, remove = (p) => rmSync(p, { recursive: true, force: true }), size = walkSize } = {}) {
587
1033
  const key = platformKey(platform);
588
1034
  const legacy = policy.heavyVolume?.[key];
@@ -605,7 +1051,8 @@ export function purgeHoldings({ policy, platform = process.platform, env = proce
605
1051
  try { dst = lstatSync(dir); } catch { continue; }
606
1052
  if (!dst.isDirectory() || dst.isSymbolicLink()) continue;
607
1053
  const stamp = Date.parse(`${name}T00:00:00Z`);
608
- if (!Number.isFinite(stamp) || stamp + 86400_000 > cutoff) continue; // the whole day must be older than the window
1054
+ if (!Number.isFinite(stamp) || stamp > cutoff) continue; // the folder's date is a full window old
1055
+ if (newestWriteMs(dir) > cutoff) continue; // but something was moved in (or written) within the window
609
1056
  const bytes = size(dir);
610
1057
  try {
611
1058
  if (!dryRun) { remove(dir); if (logFile) logEvent(logFile, { event: "disk.purge", path: dir, bytes }); }