@rightkit/release 0.6.5 → 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.
- package/README.md +196 -11
- package/admission-files.mjs +3 -2
- package/advisory-db.json +4 -0
- package/advisory-db.mjs +124 -0
- package/advisory-exceptions.mjs +13 -10
- package/android-play-upload.mjs +4 -4
- package/app-check.mjs +702 -26
- package/apple-profile-create.mjs +4 -3
- package/apple-profile-renew.mjs +5 -4
- package/bin/darwin/rightkit-swap +0 -0
- package/bin/rightkit-agent-hook.mjs +9 -0
- package/bin/rightkit-gate.mjs +671 -133
- package/binary-license-notices.mjs +2 -1
- package/build-release.mjs +20 -12
- package/cache-command.mjs +4 -3
- package/cargo-license-notices.mjs +2 -1
- package/cargo-manifest-text.mjs +25 -0
- package/cargo-profile-contract.mjs +26 -13
- package/cli/right-release.mjs +37 -7
- package/command-spec.mjs +4 -5
- package/compile-inputs.mjs +66 -0
- package/consumer-contracts.json +1044 -0
- package/consumer-contracts.mjs +627 -0
- package/consumer-input-contracts.mjs +422 -0
- package/consumer-policy.mjs +588 -0
- package/consumer-source-contracts.mjs +469 -0
- package/deny-default.toml +42 -0
- package/deps.mjs +5 -4
- package/deterministic-scans.mjs +513 -54
- package/development.mjs +5 -5
- package/disk-command.mjs +270 -30
- package/disk-policy.json +50 -10
- package/disk-policy.mjs +482 -35
- package/disk-report.mjs +129 -0
- package/door-policy.mjs +228 -0
- package/gate-executor.mjs +30 -0
- package/gate-stages.mjs +110 -0
- package/github-release.mjs +5 -3
- package/github-updater-release.mjs +48 -8
- package/github-upload-gate.mjs +6 -6
- package/init-command.mjs +175 -8
- package/ios-upload.mjs +4 -4
- package/legion-semantic-contracts.mjs +428 -0
- package/mirror-root-artifact.mjs +3 -4
- package/npm-license-notices.mjs +2 -1
- package/outdated-command.mjs +91 -0
- package/ownership-class-rules.mjs +277 -0
- package/ownership-key-authority.mjs +3 -1
- package/ownership-mandatory-set.mjs +3 -2
- package/package.json +46 -3
- package/pipeline-admission.mjs +12 -0
- package/publish-cargo.mjs +219 -4
- package/publish-update.mjs +19 -14
- package/release-config.mjs +87 -11
- package/release-dependencies-command.mjs +4 -3
- package/release-qa-audit.mjs +12 -10
- package/release-qa-command.mjs +42 -7
- package/release-qa-hardening.mjs +10 -3
- package/release-qa-policy.mjs +214 -47
- package/release.mjs +4 -4
- package/rightkit-registry-resolver.mjs +5 -2
- package/rightkit-versions.json +178 -170
- package/shell-contract-bridge.json +251 -0
- package/shell-contract.mjs +1069 -0
- package/sign-updater.mjs +24 -4
- package/source-closure-rules.mjs +204 -0
- package/stage-sidecar-command.mjs +7 -6
- package/stage-sidecar.mjs +437 -114
- package/suite-api.generated.mjs +96 -6
- package/suite-trust-roots.mjs +198 -0
- package/test-command.mjs +3 -2
- package/upload-release.mjs +3 -2
- package/web-build.mjs +2 -1
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
239
|
-
const floor =
|
|
362
|
+
const floors = checkFloors({ policy, platform, space });
|
|
363
|
+
const floor = floors[0];
|
|
240
364
|
const writers = [];
|
|
241
|
-
|
|
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
|
|
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
|
|
248
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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,
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
585
|
-
*
|
|
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
|
|
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 }); }
|