disk-space-saviour 0.0.0-stage → 0.12.0

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/src/scan.js ADDED
@@ -0,0 +1,384 @@
1
+ /**
2
+ * `scan(options) → Report`: finds reclaimable space on the host and, through
3
+ * Docker, inside running containers and nested daemons. Never deletes.
4
+ */
5
+
6
+ import { scanDocker } from './docker/scan.js';
7
+ import { LocalEnv } from './env/local.js';
8
+ import { block, tierTotals } from './items.js';
9
+ import { hostExecutor, trace } from './exec.js';
10
+ import { GitInspector } from './git.js';
11
+ import { LivenessProbe } from './liveness.js';
12
+ import { resolveOptions } from './options.js';
13
+ import { existingPaths, isWithin, matchesGlob } from './paths.js';
14
+ import { scanAgents } from './scanners/agents.js';
15
+ import { scanGlobal } from './scanners/global.js';
16
+ import { scanProjects } from './scanners/projects.js';
17
+ import { scanSystem } from './scanners/system.js';
18
+ import { scanVersions } from './scanners/versions.js';
19
+
20
+ export const SCANNERS = {
21
+ projects: scanProjects,
22
+ global: scanGlobal,
23
+ versions: scanVersions,
24
+ agents: scanAgents,
25
+ system: scanSystem,
26
+ };
27
+
28
+ export const REPORT_SCHEMA = 1;
29
+ const trustedReports = new WeakMap();
30
+ const RESCAN_OPTIONS = new Set([
31
+ 'roots',
32
+ 'scanners',
33
+ 'containers',
34
+ 'host',
35
+ 'docker',
36
+ 'dockerDepth',
37
+ 'maxDepth',
38
+ 'staleAge',
39
+ 'olderThan',
40
+ 'inactive',
41
+ 'minSize',
42
+ 'only',
43
+ 'exclude',
44
+ 'includeVolumes',
45
+ 'removeStoppedContainers',
46
+ 'removeContainers',
47
+ 'removeImages',
48
+ 'allowDirtyRepos',
49
+ 'allowDirtyContainers',
50
+ 'noNative',
51
+ 'journalKeep',
52
+ 'env',
53
+ 'now',
54
+ ]);
55
+
56
+ function safetySnapshot(report) {
57
+ return JSON.stringify(report);
58
+ }
59
+
60
+ function sealReport(report) {
61
+ trustedReports.set(report, safetySnapshot(report));
62
+ return report;
63
+ }
64
+
65
+ /** Rebuild scanner-derived actions when a report came from JSON or changed. */
66
+ export async function revalidateReport(saved, input = {}) {
67
+ if (trustedReports.get(saved) === safetySnapshot(saved)) {
68
+ return saved;
69
+ }
70
+ const scope = {
71
+ roots: saved.options?.roots,
72
+ scanners: saved.options?.scanners,
73
+ containers: saved.options?.containers,
74
+ host: saved.options?.host,
75
+ docker: saved.options?.docker,
76
+ dockerDepth: saved.options?.dockerDepth,
77
+ maxDepth: saved.options?.maxDepth,
78
+ };
79
+ for (const [key, value] of Object.entries(input)) {
80
+ if (RESCAN_OPTIONS.has(key) && value !== undefined && value !== null) {
81
+ scope[key] = value;
82
+ }
83
+ }
84
+ const current = await scan(scope);
85
+ const selected = new Set((saved.items ?? []).map((item) => item.id));
86
+ current.items = current.items.filter((item) => selected.has(item.id));
87
+ current.totals = tierTotals(current.items);
88
+ return sealReport(current);
89
+ }
90
+
91
+ /** Common project locations inside containers, scanned when present. */
92
+ export const CONTAINER_ROOTS = [
93
+ '/workspace',
94
+ '/workspaces',
95
+ '/app',
96
+ '/src',
97
+ '/code',
98
+ '/project',
99
+ '/usr/src/app',
100
+ ];
101
+
102
+ async function defaultRoots(env, homes, tmpDirs) {
103
+ const roots = [...homes, ...tmpDirs];
104
+ if (env.kind === 'container') {
105
+ const present = await existingPaths(env, CONTAINER_ROOTS);
106
+ roots.push(...CONTAINER_ROOTS.filter((root) => present.has(root)));
107
+ }
108
+ return [...new Set(roots)];
109
+ }
110
+
111
+ /**
112
+ * Builds the scanner context of one environment.
113
+ * @param {object} env environment adapter
114
+ * @param {object} options resolved options
115
+ * @param {{roots?: string[]|null}} [overrides]
116
+ */
117
+ export async function environmentContext(env, options, overrides = {}) {
118
+ const liveness = new LivenessProbe(env, {
119
+ staleAgeMs: options.staleAgeMs,
120
+ now: options.now,
121
+ });
122
+ await liveness.refresh(true);
123
+ const homes = await env.homeDirs();
124
+ const tmpDirs = await env.tmpDirs();
125
+ const roots = overrides.roots ?? (await defaultRoots(env, homes, tmpDirs));
126
+ return {
127
+ env,
128
+ git: new GitInspector(env),
129
+ liveness,
130
+ now: options.now(),
131
+ options: { ...options, homes, tmpDirs, roots },
132
+ };
133
+ }
134
+
135
+ /**
136
+ * Runs every enabled scanner in one environment.
137
+ * @param {object} env
138
+ * @param {object} options resolved options
139
+ * @param {{roots?: string[]|null, errors?: object[]}} [extra]
140
+ * @returns {Promise<object[]>} items
141
+ */
142
+ export async function scanEnvironment(env, options, extra = {}) {
143
+ const errors = extra.errors ?? [];
144
+ let context;
145
+ try {
146
+ context = await environmentContext(env, options, extra);
147
+ } catch (error) {
148
+ errors.push({ env: env.id, scanner: 'context', message: error.message });
149
+ return [];
150
+ }
151
+ const items = [];
152
+ for (const name of options.scanners) {
153
+ const scanner = SCANNERS[name];
154
+ if (!scanner) {
155
+ throw new Error(
156
+ `Unknown scanner: ${name} (expected ${Object.keys(SCANNERS).join(', ')})`
157
+ );
158
+ }
159
+ const started = Date.now();
160
+ try {
161
+ items.push(...(await scanner(context)));
162
+ } catch (error) {
163
+ errors.push({ env: env.id, scanner: name, message: error.message });
164
+ }
165
+ trace('scanner', name, 'in', env.id, `${Date.now() - started}ms`);
166
+ }
167
+ return items;
168
+ }
169
+
170
+ /**
171
+ * Container ids of the container dss itself runs in (from mount sources
172
+ * such as `/var/lib/docker/containers/<id>/hostname`).
173
+ * @returns {Promise<string[]>}
174
+ */
175
+ export async function selfContainerIds(env) {
176
+ if (env.platform !== 'linux') {
177
+ return [];
178
+ }
179
+ const ids = new Set();
180
+ const pattern = /(?:containers\/|docker[-/])([0-9a-f]{64})/g;
181
+ const cgroup =
182
+ (await env.readText('/proc/self/cgroup').catch(() => null)) ?? '';
183
+ for (const match of cgroup.matchAll(pattern)) {
184
+ ids.add(match[1]);
185
+ }
186
+ const mounts =
187
+ (await env.readText('/proc/self/mountinfo').catch(() => null)) ?? '';
188
+ for (const line of mounts.split('\n')) {
189
+ const mountpoint = line.split(' ')[4];
190
+ if (
191
+ !['/etc/hostname', '/etc/hosts', '/etc/resolv.conf'].includes(mountpoint)
192
+ ) {
193
+ continue;
194
+ }
195
+ for (const match of line.matchAll(pattern)) {
196
+ ids.add(match[1]);
197
+ }
198
+ }
199
+ return [...ids];
200
+ }
201
+
202
+ function matchesOnly(item, only) {
203
+ return (
204
+ only.length === 0 ||
205
+ only.some(
206
+ (entry) =>
207
+ entry === item.ecosystem || entry === item.rule || entry === item.kind
208
+ )
209
+ );
210
+ }
211
+
212
+ function excluded(item, exclude, pathApi) {
213
+ return item.paths.some((target) =>
214
+ exclude.some(
215
+ (pattern) =>
216
+ isWithin(target, pattern, pathApi) ||
217
+ isWithin(pattern, target, pathApi) ||
218
+ matchesGlob(pathApi.basename(target), pattern)
219
+ )
220
+ );
221
+ }
222
+
223
+ function underDaemonRoot(item, daemons, pathApi) {
224
+ return daemons.some(
225
+ (daemon) =>
226
+ daemon.env === item.env &&
227
+ daemon.rootDir &&
228
+ item.action?.type === 'remove' &&
229
+ item.paths.some((target) => isWithin(target, daemon.rootDir, pathApi))
230
+ );
231
+ }
232
+
233
+ /**
234
+ * Applies `only`, `exclude` and `minSize`, and drops paths owned by a
235
+ * Docker daemon (those are reclaimed through Docker only).
236
+ */
237
+ export function filterItems(items, options, daemons = [], pathApi) {
238
+ return items.filter(
239
+ (item) =>
240
+ item.bytes >= options.minSizeBytes &&
241
+ matchesOnly(item, options.only) &&
242
+ !excluded(item, options.exclude, pathApi) &&
243
+ !underDaemonRoot(item, daemons, pathApi)
244
+ );
245
+ }
246
+
247
+ function envDescriptor(env, depth, chain) {
248
+ return { id: env.id, label: env.label, kind: env.kind, depth, chain };
249
+ }
250
+
251
+ async function withDisk(env, descriptor) {
252
+ const target = env.platform === 'win32' ? env.currentHome : '/';
253
+ const disk = await env.diskUsage(target).catch(() => null);
254
+ return { ...descriptor, disk: disk ? { path: target, ...disk } : null };
255
+ }
256
+
257
+ async function dockerSection(env, options, errors) {
258
+ if (options.docker === false) {
259
+ return null;
260
+ }
261
+ const hostRecord = {
262
+ env,
263
+ executor: env.executor ?? hostExecutor(),
264
+ chain: [],
265
+ };
266
+ try {
267
+ const result = await scanDocker(
268
+ hostRecord,
269
+ { ...options, selfContainerIds: await selfContainerIds(env) },
270
+ (inner) => scanEnvironment(inner, options, { errors })
271
+ );
272
+ if (options.docker === true && result.daemons.length === 0) {
273
+ errors.push({
274
+ env: env.id,
275
+ scanner: 'docker',
276
+ message: 'no reachable Docker daemon',
277
+ });
278
+ }
279
+ return result;
280
+ } catch (error) {
281
+ errors.push({ env: env.id, scanner: 'docker', message: error.message });
282
+ return null;
283
+ }
284
+ }
285
+
286
+ /**
287
+ * Scans and returns a report. Nothing is modified.
288
+ * @param {object} [input] options, see ./options.js; `host: false` skips the
289
+ * host file system (Docker only); `env` injects the host environment
290
+ * adapter (tests).
291
+ * @returns {Promise<object>} report
292
+ */
293
+ function blockMountedHostItems(items, mounts, pathApi) {
294
+ for (const item of items) {
295
+ if (item.action?.type !== 'remove') {
296
+ continue;
297
+ }
298
+ for (const mount of mounts) {
299
+ if (
300
+ item.paths.some(
301
+ (target) =>
302
+ isWithin(target, mount.source, pathApi) ||
303
+ isWithin(mount.source, target, pathApi)
304
+ )
305
+ ) {
306
+ block(item, `bind-mounted by running container ${mount.containerId}`);
307
+ }
308
+ }
309
+ }
310
+ }
311
+
312
+ export async function scan(input = {}) {
313
+ const options = resolveOptions(input);
314
+ const env = input.env ?? new LocalEnv();
315
+ const startedAt = options.now();
316
+ const errors = [];
317
+ const hostItems =
318
+ options.host === false
319
+ ? []
320
+ : await scanEnvironment(env, options, { roots: options.roots, errors });
321
+ const docker = await dockerSection(env, options, errors);
322
+ blockMountedHostItems(hostItems, docker?.bindMounts ?? [], env.path);
323
+ const items = filterItems(
324
+ [...hostItems, ...(docker?.items ?? [])],
325
+ options,
326
+ docker?.daemons ?? [],
327
+ env.path
328
+ );
329
+ const environments = [envDescriptor(env, 0, [])];
330
+ for (const inner of docker?.environments ?? []) {
331
+ environments.push(inner);
332
+ }
333
+ environments[0] = await withDisk(env, environments[0]);
334
+ return buildReport({
335
+ options,
336
+ env,
337
+ items,
338
+ environments,
339
+ docker,
340
+ errors,
341
+ startedAt,
342
+ });
343
+ }
344
+
345
+ function reportOptions(options) {
346
+ const serializable = { ...options };
347
+ delete serializable.now;
348
+ delete serializable.env;
349
+ return serializable;
350
+ }
351
+
352
+ /**
353
+ * Assembles the JSON-serializable report.
354
+ */
355
+ export function buildReport({
356
+ options,
357
+ env,
358
+ items,
359
+ environments,
360
+ docker,
361
+ errors,
362
+ startedAt,
363
+ }) {
364
+ return sealReport({
365
+ schema: REPORT_SCHEMA,
366
+ tool: 'disk-space-saviour',
367
+ createdAt: new Date(options.now()).toISOString(),
368
+ durationMs: options.now() - startedAt,
369
+ host: { env: env.id, label: env.label, platform: env.platform },
370
+ options: reportOptions(options),
371
+ environments,
372
+ items,
373
+ totals: tierTotals(items),
374
+ docker: docker
375
+ ? {
376
+ daemons: docker.daemons,
377
+ containers: docker.containers,
378
+ hints: docker.hints,
379
+ volumes: docker.volumes,
380
+ }
381
+ : null,
382
+ errors,
383
+ });
384
+ }
@@ -0,0 +1,166 @@
1
+ /**
2
+ * Snapshot stores of AI coding agents built on the opencode storage layout
3
+ * (`@link-assistant/agent`, opencode).
4
+ *
5
+ * `<data>/snapshot/<projectId>/` is a bare Git object store with a full copy
6
+ * of the project's worktree, keyed by the project's root commit.
7
+ * `<data>/storage/project/<projectId>.json` records the project's
8
+ * `worktree`. Tasks that run the agent in throwaway checkouts leave one
9
+ * store per checkout behind (link-assistant/hive-mind#2186).
10
+ *
11
+ * - worktree recorded and gone: retained because the snapshot may be the only
12
+ * remaining copy of uncommitted work;
13
+ * - no project record and idle for the activity window: `moderate`;
14
+ * - worktree still present: kept;
15
+ * - the newest store is always kept.
16
+ *
17
+ * Running agents do not block: a store is only written by the agent working
18
+ * in its worktree, so recent writes and open files are the liveness signal
19
+ * (the same rule the agent's own `Project.prune()` uses).
20
+ */
21
+
22
+ import { block, makeItem } from '../items.js';
23
+ import { listMany, olderThan, scanTimeBusy } from './common.js';
24
+
25
+ export const AGENT_APPS = [
26
+ { id: 'link-assistant-agent', name: '@link-assistant/agent' },
27
+ { id: 'opencode', name: 'opencode' },
28
+ ];
29
+
30
+ function dataDirs(env, home) {
31
+ const dirs = [env.path.join(home, '.local', 'share')];
32
+ const xdg = env.vars?.XDG_DATA_HOME;
33
+ if (home === env.currentHome && xdg && !dirs.includes(xdg)) {
34
+ dirs.push(xdg);
35
+ }
36
+ return dirs;
37
+ }
38
+
39
+ /**
40
+ * Reads the recorded worktree from a project record.
41
+ * @returns {string|null}
42
+ */
43
+ export function recordedWorktree(text) {
44
+ try {
45
+ const worktree = JSON.parse(text ?? '').worktree;
46
+ return typeof worktree === 'string' && worktree ? worktree : null;
47
+ } catch {
48
+ return null;
49
+ }
50
+ }
51
+
52
+ async function snapshotStates(env, root) {
53
+ const snapshotDir = env.path.join(root, 'snapshot');
54
+ const projectDir = env.path.join(root, 'storage', 'project');
55
+ const listings = await listMany(env, [snapshotDir, projectDir]);
56
+ const stores = (listings.get(snapshotDir) ?? []).filter(
57
+ (entry) => entry.type === 'dir'
58
+ );
59
+ if (stores.length === 0) {
60
+ return [];
61
+ }
62
+ const records = new Set(
63
+ (listings.get(projectDir) ?? []).map((entry) => entry.name)
64
+ );
65
+ const recordPath = (id) => env.path.join(projectDir, `${id}.json`);
66
+ const heads = await env.readHeads(
67
+ stores
68
+ .filter((entry) => records.has(`${entry.name}.json`))
69
+ .map((entry) => recordPath(entry.name)),
70
+ 65536
71
+ );
72
+ const states = [];
73
+ for (const entry of stores) {
74
+ const record = heads.get(recordPath(entry.name));
75
+ const worktree = record === undefined ? null : recordedWorktree(record);
76
+ states.push({
77
+ entry,
78
+ hasRecord: record !== undefined,
79
+ worktree,
80
+ worktreeExists: worktree ? await env.exists(worktree) : false,
81
+ });
82
+ }
83
+ return states;
84
+ }
85
+
86
+ function classify(context, state, usage) {
87
+ if (state.worktreeExists) {
88
+ return null;
89
+ }
90
+ if (state.hasRecord && state.worktree) {
91
+ return null;
92
+ }
93
+ if (olderThan(context, usage.newestMtimeMs, context.options.staleAgeMs)) {
94
+ return {
95
+ tier: 'moderate',
96
+ reason: 'no project record points at this store and it is idle',
97
+ };
98
+ }
99
+ return null;
100
+ }
101
+
102
+ async function appItems(context, app, root) {
103
+ const { env } = context;
104
+ const states = await snapshotStates(env, root);
105
+ if (states.length === 0) {
106
+ return [];
107
+ }
108
+ const usages = await env.usageMany(states.map((s) => s.entry.path));
109
+ const newest = states
110
+ .map((state) => ({
111
+ state,
112
+ mtimeMs: usages.get(state.entry.path)?.newestMtimeMs ?? 0,
113
+ }))
114
+ .sort((a, b) => b.mtimeMs - a.mtimeMs)[0].state;
115
+ const items = [];
116
+ for (const state of states) {
117
+ const usage = usages.get(state.entry.path);
118
+ const verdict =
119
+ state === newest || !usage ? null : classify(context, state, usage);
120
+ if (!verdict) {
121
+ continue;
122
+ }
123
+ const item = makeItem(env, {
124
+ rule: 'agent-snapshot',
125
+ kind: 'agent-state',
126
+ ecosystem: 'agents',
127
+ description: `${app.name} snapshot store ${state.entry.name}`,
128
+ path: state.entry.path,
129
+ bytes: usage.bytes,
130
+ newestMtimeMs: usage.newestMtimeMs,
131
+ worktree: state.worktree,
132
+ ...verdict,
133
+ checks: { busy: [], cwd: null, mtime: true },
134
+ });
135
+ const busy = scanTimeBusy(context, item);
136
+ if (busy) {
137
+ block(item, `busy: ${busy}`);
138
+ }
139
+ items.push(item);
140
+ }
141
+ return items;
142
+ }
143
+
144
+ /**
145
+ * Scans agent snapshot stores of every home.
146
+ * @returns {Promise<object[]>} report items
147
+ */
148
+ export async function scanAgents(context, { apps = AGENT_APPS } = {}) {
149
+ const { env, options } = context;
150
+ const homes = options.homes ?? (await env.homeDirs());
151
+ const items = [];
152
+ const seen = new Set();
153
+ for (const home of homes) {
154
+ for (const dataDir of dataDirs(env, home)) {
155
+ for (const app of apps) {
156
+ const root = env.path.join(dataDir, app.id);
157
+ if (seen.has(root)) {
158
+ continue;
159
+ }
160
+ seen.add(root);
161
+ items.push(...(await appItems(context, app, root)));
162
+ }
163
+ }
164
+ }
165
+ return items;
166
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Helpers shared by scanners.
3
+ *
4
+ * Every scanner receives a context:
5
+ * `{env, git, liveness, options, now}` where `options` holds the resolved
6
+ * scan options (`staleAgeMs`, `inactiveMs`, `maxDepth`, `roots`, ...).
7
+ */
8
+
9
+ /**
10
+ * Lists many directories, batched when the environment supports it.
11
+ * @returns {Promise<Map<string, object[]>>}
12
+ */
13
+ export async function listMany(env, dirs) {
14
+ if (typeof env.listMany === 'function') {
15
+ return env.listMany(dirs);
16
+ }
17
+ const listings = new Map();
18
+ for (const dir of dirs) {
19
+ listings.set(dir, await env.list(dir));
20
+ }
21
+ return listings;
22
+ }
23
+
24
+ /**
25
+ * Busy reason at scan time (recent writes, running tool, open files).
26
+ * @returns {string|null}
27
+ */
28
+ export function scanTimeBusy(context, item) {
29
+ return context.liveness ? context.liveness.busyReason(item) : null;
30
+ }
31
+
32
+ /**
33
+ * Whether `now - timestamp` exceeds `windowMs` (unknown timestamps count as
34
+ * recent, so they are never treated as abandoned).
35
+ */
36
+ export function olderThan(context, timestamp, windowMs) {
37
+ return Boolean(timestamp) && context.now - timestamp >= windowMs;
38
+ }