disk-space-saviour 0.15.1 → 0.16.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.
@@ -0,0 +1,24 @@
1
+ /** Read-only probes shared by local identity retries and Docker adapters. */
2
+ export const OPEN_PATHS_SCRIPT = `failed=0
3
+ [ "$#" -gt 0 ] || set -- /proc/[0-9]*
4
+ for p do
5
+ [ -d "$p" ] || continue
6
+ state=$(sed 's/.*) //' "$p/stat" 2>/dev/null | cut -d' ' -f1)
7
+ [ "$state" = Z ] && continue
8
+ unreadable=0
9
+ ls "$p/fd" >/dev/null 2>&1 || unreadable=1
10
+ for l in "$p/cwd" "$p/exe" "$p"/fd/*; do
11
+ [ -L "$l" ] || continue
12
+ readlink "$l" 2>/dev/null || { [ -L "$l" ] && unreadable=1; }
13
+ done
14
+ if [ "$unreadable" = 1 ] && [ -d "$p" ]; then
15
+ printf 'unreadable pid %s (%s)\\n' "\${p#/proc/}" "$(cat "$p/comm" 2>/dev/null)" >&2
16
+ uid=$(awk '/^Uid:/{print $2}' "$p/status" 2>/dev/null)
17
+ gid=$(awk '/^Gid:/{print $2}' "$p/status" 2>/dev/null)
18
+ printf 'DSS_GID|%s|%s\\n' "\${p#/proc/}" "$gid" >&2
19
+ command=$(tr '\\000' ' ' < "$p/cmdline" 2>/dev/null)
20
+ printf 'DSS_UNREADABLE|%s|%s|%s|%s\\n' "\${p#/proc/}" "$uid" "$(cat "$p/comm" 2>/dev/null)" "$command" >&2
21
+ failed=1
22
+ fi
23
+ done
24
+ exit "$failed"`;
@@ -0,0 +1,95 @@
1
+ /** Scan cancellation and deadlines shared by local and remote adapters. */
2
+ export function checkSignal(signal) {
3
+ if (signal?.aborted) {
4
+ const error = new Error(signal.reason?.message ?? 'scan aborted');
5
+ error.name = 'AbortError';
6
+ throw error;
7
+ }
8
+ }
9
+
10
+ export function signalExecutor(executor, signal, deadline = Infinity) {
11
+ const base = executor.unscoped ?? executor;
12
+ return {
13
+ ...executor,
14
+ unscoped: executor.unscoped ?? executor,
15
+ signal,
16
+ spawn: (argv, options = {}) => {
17
+ checkSignal(options.signal ?? signal);
18
+ return base.spawn(argv, {
19
+ ...options,
20
+ signal: options.signal ?? signal,
21
+ timeoutMs: Math.max(
22
+ 1,
23
+ Math.min(options.timeoutMs ?? 600000, deadline - Date.now())
24
+ ),
25
+ });
26
+ },
27
+ run: async (argv, options = {}) => {
28
+ const active = options.signal ?? signal;
29
+ checkSignal(active);
30
+ const result = await base.run(argv, {
31
+ ...options,
32
+ signal: active,
33
+ timeoutMs: Math.max(
34
+ 1,
35
+ Math.min(options.timeoutMs ?? 600000, deadline - Date.now())
36
+ ),
37
+ });
38
+ checkSignal(active);
39
+ return result;
40
+ },
41
+ };
42
+ }
43
+
44
+ /** Restore adapter state so later clean/re-scan calls never inherit an old abort. */
45
+ export async function environmentScope(
46
+ env,
47
+ signal,
48
+ operation,
49
+ deadline = Infinity
50
+ ) {
51
+ const original = {
52
+ executor: env.executor,
53
+ signal: env.signal,
54
+ deadline: env.deadline,
55
+ };
56
+ const probe = env.probeExecutor;
57
+ env.signal = signal;
58
+ env.deadline = deadline;
59
+ env.executor = signalExecutor(env.executor, signal, deadline);
60
+ if (probe) {
61
+ env.probeExecutor = signalExecutor(probe, signal, deadline);
62
+ }
63
+ const layerExecutor = env.writableLayer?.docker?.executor;
64
+ if (layerExecutor) {
65
+ env.writableLayer.docker.executor = signalExecutor(
66
+ layerExecutor,
67
+ signal,
68
+ deadline
69
+ );
70
+ }
71
+ try {
72
+ checkSignal(signal);
73
+ return await (env.processInspector
74
+ ? environmentScope(env.processInspector, signal, operation, deadline)
75
+ : operation());
76
+ } finally {
77
+ Object.assign(env, original);
78
+ env.probeExecutor = env.probeExecutor?.unscoped ?? probe;
79
+ if (layerExecutor) {
80
+ env.writableLayer.docker.executor = layerExecutor;
81
+ }
82
+ }
83
+ }
84
+
85
+ /** Scanner arrays emit completed findings immediately, retaining them on abort. */
86
+ export function scanItems(context) {
87
+ const items = [];
88
+ items.push = (...entries) => {
89
+ for (const entry of entries) {
90
+ context.emit?.(entry);
91
+ }
92
+ return Array.prototype.push.apply(items, entries);
93
+ };
94
+ return items;
95
+ }
package/src/env/shell.js CHANGED
@@ -1,3 +1,10 @@
1
+ import { OPEN_PATHS_SCRIPT } from './process-scripts.js';
2
+ import {
3
+ parseProcessProbe,
4
+ retryProcessOwners,
5
+ inspectionHint,
6
+ } from './inspection.js';
7
+ export { OPEN_PATHS_SCRIPT } from './process-scripts.js';
1
8
  /**
2
9
  * Environment adapter for a filesystem reached through an executor, usually
3
10
  * a running container via `docker exec`.
@@ -12,9 +19,10 @@ import { sharedWritableLayer } from '../docker/writable.js';
12
19
 
13
20
  import { containerExecutor, shellQuote, trace } from '../exec.js';
14
21
  import { DEFAULT_SKIP_NAMES, parseDf, processAliases } from './local.js';
22
+ import { batches } from './batches.js';
23
+ import { signalExecutor } from './scope.js';
15
24
 
16
25
  const STAT_FORMAT = '%F|%s|%b|%B|%Y|%n';
17
- const ARG_BATCH = 200;
18
26
  const RECORD = '\u0001';
19
27
 
20
28
  const LIST_SCRIPT = `for d in "$@"; do
@@ -50,27 +58,6 @@ for p in /proc/[0-9]*; do
50
58
  printf '%s|%s|%s|%s|%s|%s|%s\\n' "\${p#/proc/}" "$t" "$c" "$e" "$a" "$w" "$q"
51
59
  done`;
52
60
 
53
- export const OPEN_PATHS_SCRIPT = `failed=0
54
- for p in /proc/[0-9]*; do
55
- [ -d "$p" ] || continue
56
- state=$(sed 's/.*) //' "$p/stat" 2>/dev/null | cut -d' ' -f1)
57
- [ "$state" = Z ] && continue
58
- unreadable=0
59
- ls "$p/fd" >/dev/null 2>&1 || unreadable=1
60
- for l in "$p/cwd" "$p/exe" "$p"/fd/*; do
61
- [ -L "$l" ] || continue
62
- readlink "$l" 2>/dev/null || { [ -L "$l" ] && unreadable=1; }
63
- done
64
- if [ "$unreadable" = 1 ] && [ -d "$p" ]; then
65
- printf 'unreadable pid %s (%s)\\n' "\${p#/proc/}" "$(cat "$p/comm" 2>/dev/null)" >&2
66
- uid=$(awk '/^Uid:/{print $2}' "$p/status" 2>/dev/null)
67
- command=$(tr '\\000' ' ' < "$p/cmdline" 2>/dev/null)
68
- printf 'DSS_UNREADABLE|%s|%s|%s|%s\\n' "\${p#/proc/}" "$uid" "$(cat "$p/comm" 2>/dev/null)" "$command" >&2
69
- failed=1
70
- fi
71
- done
72
- exit "$failed"`;
73
-
74
61
  const REMOVE_SCRIPT = `rm -rf -- "$@" 2>/dev/null && exit 0
75
62
  chmod -R u+w -- "$@" 2>/dev/null
76
63
  rm -rf -- "$@"`;
@@ -146,14 +133,6 @@ function parseUsage(body) {
146
133
  };
147
134
  }
148
135
 
149
- function batches(items, size = ARG_BATCH) {
150
- const result = [];
151
- for (let index = 0; index < items.length; index += size) {
152
- result.push(items.slice(index, index + size));
153
- }
154
- return result;
155
- }
156
-
157
136
  function findNameExpression(names) {
158
137
  return names.flatMap((name, index) =>
159
138
  index === 0 ? ['-name', name] : ['-o', '-name', name]
@@ -201,6 +180,21 @@ export class ShellEnv {
201
180
  return Promise.resolve(this.options.tmpDirs ?? ['/tmp']);
202
181
  }
203
182
 
183
+ async variables(names) {
184
+ const result = await this.sh(
185
+ 'for n do printf "%s\\0" "$n"; printenv "$n" | tr -d "\\n"; printf "\\0"; done',
186
+ names
187
+ );
188
+ const fields = result.stdout.split('\0');
189
+ const vars = {};
190
+ for (let index = 0; index + 1 < fields.length; index += 2) {
191
+ if (fields[index + 1]) {
192
+ vars[fields[index]] = fields[index + 1];
193
+ }
194
+ }
195
+ return vars;
196
+ }
197
+
204
198
  async exists(target) {
205
199
  const result = await this.sh('[ -e "$1" ] || [ -L "$1" ]', [target]);
206
200
  return result.code === 0;
@@ -223,26 +217,7 @@ export class ShellEnv {
223
217
  }
224
218
 
225
219
  async stat(target) {
226
- const result = await this.executor.run([
227
- 'stat',
228
- '-c',
229
- STAT_FORMAT,
230
- '--',
231
- target,
232
- ]);
233
- if (result.code !== 0) {
234
- return null;
235
- }
236
- const parsed = parseStatLine(result.stdout.trim());
237
- if (!parsed) {
238
- return null;
239
- }
240
- return {
241
- type: parsed.type,
242
- size: parsed.size,
243
- bytes: parsed.bytes,
244
- mtimeMs: parsed.mtimeMs,
245
- };
220
+ return (await this.statMany([target])).get(target) ?? null;
246
221
  }
247
222
 
248
223
  async readText(target, maxBytes = 1024 * 1024) {
@@ -317,7 +292,7 @@ export class ShellEnv {
317
292
  const result = await this.executor.run([
318
293
  'stat',
319
294
  '-c',
320
- `${STAT_FORMAT}|%d:%i:%u`,
295
+ `${STAT_FORMAT}|%d:%i:%u:%g:%f`,
321
296
  '--',
322
297
  ...batch,
323
298
  ]);
@@ -330,6 +305,11 @@ export class ShellEnv {
330
305
  ...stat,
331
306
  inode: identity.slice(0, 2).join(':'),
332
307
  uid: identity.length > 2 ? Number(identity[2]) : undefined,
308
+ gid: identity.length > 3 ? Number(identity[3]) : undefined,
309
+ mode:
310
+ identity.length > 4
311
+ ? Number.parseInt(identity[4], 16)
312
+ : undefined,
333
313
  });
334
314
  }
335
315
  }
@@ -342,31 +322,24 @@ export class ShellEnv {
342
322
  const policy = this.scanPolicy;
343
323
  if (policy) {
344
324
  targets = targets.filter((target) => !policy.removalReason(target));
345
- for (const target of targets) {
346
- const expression = this.#boundaryPatterns().flatMap(
347
- (pattern, index) => [
348
- ...(index ? ['-o'] : []),
349
- /[/\\]/.test(pattern) ? '-path' : '-name',
350
- pattern.replaceAll('\\', '/'),
351
- ]
352
- );
353
- const found = await this.executor.run([
354
- 'find',
355
- target,
356
- '-xdev',
357
- '-mindepth',
358
- '1',
359
- '(',
360
- ...expression,
361
- ')',
362
- '-print',
363
- '-quit',
364
- ]);
325
+ const expression = this.#boundaryPatterns().flatMap((pattern, index) => [
326
+ ...(index ? ['-o'] : []),
327
+ /[/\\]/.test(pattern) ? '-path' : '-name',
328
+ pattern.replaceAll('\\', '/'),
329
+ ]);
330
+ const script = `for t in "$@"; do
331
+ [ -e "$t" ] || [ -L "$t" ] || continue
332
+ find "$t" -xdev -mindepth 1 '(' ${expression.map(shellQuote).join(' ')} ')' -print -quit || printf '%s\\n' "$t"
333
+ done`;
334
+ for (const batch of batches(targets)) {
335
+ const found = await this.sh(script, batch);
365
336
  for (const line of found.stdout.split('\n').filter(Boolean)) {
366
337
  policy.excludedPaths.add(line);
367
338
  }
368
339
  if (found.code !== 0) {
369
- policy.excludedPaths.add(target);
340
+ for (const target of batch) {
341
+ policy.excludedPaths.add(target);
342
+ }
370
343
  }
371
344
  }
372
345
  // du cannot prune same-device mounts or excluded children. Skip their
@@ -388,8 +361,21 @@ export class ShellEnv {
388
361
  }
389
362
 
390
363
  async realPath(target) {
391
- const result = await this.executor.run(['readlink', '-f', '--', target]);
392
- return result.code === 0 ? result.stdout.trim() || null : null;
364
+ return (await this.realPathMany([target])).get(target) ?? null;
365
+ }
366
+
367
+ async realPathMany(targets) {
368
+ const resolved = new Map();
369
+ for (const batch of batches(targets)) {
370
+ const result = await this.sh(
371
+ `for t do printf '${RECORD}%s\\n' "$t"; readlink -f -- "$t"; done`,
372
+ batch
373
+ );
374
+ for (const [target, body] of splitRecords(result.stdout)) {
375
+ resolved.set(target, body.trim() || null);
376
+ }
377
+ }
378
+ return resolved;
393
379
  }
394
380
 
395
381
  async isRoot() {
@@ -544,6 +530,29 @@ export class ShellEnv {
544
530
  OPEN_PATHS_SCRIPT,
545
531
  ]);
546
532
  this.probeDiagnostic = null;
533
+ const probe = await this.#privilegedProbe(result);
534
+ result = probe.result;
535
+ let evidence = probe.evidence;
536
+ if (
537
+ evidence.unreadable.length &&
538
+ (this.probeExecutor || (await this.isRoot()))
539
+ ) {
540
+ evidence = await retryProcessOwners(
541
+ this.probeExecutor ?? this.executor,
542
+ evidence
543
+ );
544
+ }
545
+ this.unreadableProcesses = evidence.unreadable;
546
+ this.probeHint = inspectionHint(this.unreadableProcesses);
547
+ if (result.code !== 0) {
548
+ this.probeDiagnostic ??= result.stderr.trim();
549
+ }
550
+ return probe.coverageKnown ? evidence.paths : null;
551
+ }
552
+
553
+ async #privilegedProbe(result) {
554
+ const evidence = parseProcessProbe(result);
555
+ let coverageKnown = result.code === 0 || evidence.unreadable.length > 0;
547
556
  if (
548
557
  result.code !== 0 &&
549
558
  this.executor.parent &&
@@ -555,44 +564,33 @@ export class ShellEnv {
555
564
  this.id,
556
565
  diagnostic
557
566
  );
558
- const inspector = containerExecutor(
559
- this.executor.parent,
560
- this.executor.containerId,
561
- { user: '0', privileged: true }
567
+ const inspector = signalExecutor(
568
+ containerExecutor(
569
+ this.executor.parent.unscoped ?? this.executor.parent,
570
+ this.executor.containerId,
571
+ { user: '0', privileged: true }
572
+ ),
573
+ this.signal,
574
+ this.deadline
562
575
  );
563
- result = await inspector.run(['sh', '-c', OPEN_PATHS_SCRIPT]);
564
- if (result.code !== 0) {
565
- this.probeDiagnostic = `${diagnostic}; privileged inspection failed: ${result.stderr.trim()}`;
566
- } else {
576
+ const retried = await inspector.run(['sh', '-c', OPEN_PATHS_SCRIPT]);
577
+ const next = parseProcessProbe(retried);
578
+ for (const target of next.paths) {
579
+ evidence.paths.add(target);
580
+ }
581
+ if (retried.code === 0 || next.unreadable.length) {
582
+ coverageKnown = true;
583
+ evidence.unreadable = next.unreadable;
584
+ result = retried;
567
585
  this.probeExecutor = inspector;
568
586
  }
569
- }
570
- this.unreadableProcesses = result.stderr
571
- .split('\n')
572
- .filter((line) => line.startsWith('DSS_UNREADABLE|'))
573
- .map((line) => {
574
- const [, pid, uid, name, ...command] = line.split('|');
575
- return {
576
- pid: Number(pid),
577
- uid: uid ? Number(uid) : null,
578
- name,
579
- command: command.join('|'),
580
- };
581
- });
582
- if (result.code !== 0) {
583
- this.probeDiagnostic ??= result.stderr.trim();
584
- if (!this.unreadableProcesses.length) {
585
- return null;
587
+ if (retried.code !== 0) {
588
+ this.probeDiagnostic = `${diagnostic}; privileged inspection failed: ${retried.stderr.trim()}`;
589
+ } else {
590
+ this.probeExecutor = inspector;
586
591
  }
587
- this.probeHint =
588
- 'some processes could not be inspected; run as root for full verification';
589
592
  }
590
- return new Set(
591
- result.stdout
592
- .split('\n')
593
- .filter((line) => line.startsWith('/'))
594
- .map((line) => line.replace(/ \(deleted\)$/, ''))
595
- );
593
+ return { result, evidence, coverageKnown };
596
594
  }
597
595
 
598
596
  run(argv, options) {
package/src/exec.js CHANGED
@@ -41,7 +41,15 @@ export function trace(...parts) {
41
41
  * @returns {Promise<{code: number, stdout: string, stderr: string, error?: Error}>}
42
42
  */
43
43
  export function runProcess(argv, options = {}) {
44
- const { input, timeoutMs = DEFAULT_TIMEOUT_MS, cwd, env } = options;
44
+ const { input, timeoutMs = DEFAULT_TIMEOUT_MS, cwd, env, signal } = options;
45
+ if (signal?.aborted) {
46
+ return Promise.resolve({
47
+ code: 130,
48
+ stdout: '',
49
+ stderr: 'aborted',
50
+ aborted: true,
51
+ });
52
+ }
45
53
  trace('exec', JSON.stringify(argv));
46
54
  return new Promise((resolve) => {
47
55
  let child;
@@ -51,6 +59,7 @@ export function runProcess(argv, options = {}) {
51
59
  env: env ? { ...process.env, ...env } : process.env,
52
60
  stdio: ['pipe', 'pipe', 'pipe'],
53
61
  windowsHide: true,
62
+ detached: process.platform !== 'win32',
54
63
  });
55
64
  } catch (error) {
56
65
  resolve({ code: 127, stdout: '', stderr: String(error), error });
@@ -66,12 +75,33 @@ export function runProcess(argv, options = {}) {
66
75
  if (!settled) {
67
76
  settled = true;
68
77
  clearTimeout(timer);
78
+ signal?.removeEventListener('abort', abort);
69
79
  trace(`exit ${result.code} after ${Date.now() - startedAt}ms`);
70
80
  resolve(result);
71
81
  }
72
82
  };
83
+ const kill = () => {
84
+ try {
85
+ if (process.platform !== 'win32') {
86
+ process.kill(-child.pid, 'SIGKILL');
87
+ } else {
88
+ child.kill('SIGKILL');
89
+ }
90
+ } catch {
91
+ child.kill('SIGKILL');
92
+ }
93
+ };
94
+ const abort = () => {
95
+ kill();
96
+ finish({
97
+ code: 130,
98
+ stdout: Buffer.concat(stdout).toString('utf8'),
99
+ stderr: 'aborted',
100
+ aborted: true,
101
+ });
102
+ };
73
103
  const timer = setTimeout(() => {
74
- child.kill('SIGKILL');
104
+ kill();
75
105
  finish({
76
106
  code: 124,
77
107
  stdout: Buffer.concat(stdout).toString('utf8'),
@@ -79,6 +109,10 @@ export function runProcess(argv, options = {}) {
79
109
  error: new Error(`timed out after ${timeoutMs}ms`),
80
110
  });
81
111
  }, timeoutMs);
112
+ signal?.addEventListener('abort', abort, { once: true });
113
+ if (signal?.aborted) {
114
+ abort();
115
+ }
82
116
  const collect = (target) => (chunk) => {
83
117
  captured += chunk.length;
84
118
  if (captured <= captureLimit) {
@@ -117,6 +151,11 @@ export function runProcess(argv, options = {}) {
117
151
  * @param {{timeoutMs?: number, cwd?: string}} [options]
118
152
  */
119
153
  export async function pipeInto(source, argv, options = {}) {
154
+ const abortSource = () => source.kill?.('SIGKILL');
155
+ options.signal?.addEventListener('abort', abortSource, { once: true });
156
+ if (options.signal?.aborted) {
157
+ abortSource();
158
+ }
120
159
  const sourceErr = [];
121
160
  source.stderr?.on('data', (chunk) => sourceErr.push(chunk));
122
161
  const sourceDone = new Promise((resolve) => {
@@ -124,7 +163,11 @@ export async function pipeInto(source, argv, options = {}) {
124
163
  source.on('close', (code) => resolve({ code: code ?? 1 }));
125
164
  });
126
165
  const result = await runProcess(argv, { ...options, stdin: source.stdout });
166
+ if (result.code !== 0) {
167
+ abortSource();
168
+ }
127
169
  const upstream = await sourceDone;
170
+ options.signal?.removeEventListener('abort', abortSource);
128
171
  if (upstream.code !== 0) {
129
172
  return {
130
173
  ...result,
@@ -143,11 +186,7 @@ export function hostExecutor() {
143
186
  label: 'host',
144
187
  depth: 0,
145
188
  run: (argv, options) => runProcess(argv, options),
146
- spawn: (argv) =>
147
- spawn(argv[0], argv.slice(1), {
148
- stdio: ['ignore', 'pipe', 'pipe'],
149
- windowsHide: true,
150
- }),
189
+ spawn: spawnStream,
151
190
  };
152
191
  }
153
192
 
@@ -175,10 +214,42 @@ export function containerExecutor(parent, containerId, options = {}) {
175
214
  parent,
176
215
  run: (argv, runOptions = {}) =>
177
216
  parent.run(wrap(argv, runOptions.input !== undefined), runOptions),
178
- spawn: (argv) => parent.spawn(wrap(argv, false)),
217
+ spawn: (argv, spawnOptions) =>
218
+ parent.spawn(wrap(argv, false), spawnOptions),
179
219
  };
180
220
  }
181
221
 
222
+ /** Stream commands (Docker logs/cp) obey the same cancellation and deadline. */
223
+ function spawnStream(argv, options = {}) {
224
+ const child = spawn(argv[0], argv.slice(1), {
225
+ stdio: ['ignore', 'pipe', 'pipe'],
226
+ windowsHide: true,
227
+ detached: process.platform !== 'win32',
228
+ });
229
+ const stop = () => {
230
+ try {
231
+ if (process.platform !== 'win32') {
232
+ process.kill(-child.pid, 'SIGKILL');
233
+ } else {
234
+ child.kill('SIGKILL');
235
+ }
236
+ } catch {
237
+ child.kill('SIGKILL');
238
+ }
239
+ };
240
+ const timer = setTimeout(stop, options.timeoutMs ?? 600000);
241
+ timer.unref?.();
242
+ options.signal?.addEventListener('abort', stop, { once: true });
243
+ if (options.signal?.aborted) {
244
+ stop();
245
+ }
246
+ child.once('close', () => {
247
+ clearTimeout(timer);
248
+ options.signal?.removeEventListener('abort', stop);
249
+ });
250
+ return child;
251
+ }
252
+
182
253
  /**
183
254
  * Quotes a value for inclusion in a POSIX shell script.
184
255
  * @param {string} value
package/src/health.js CHANGED
@@ -52,8 +52,14 @@ function identity(proc) {
52
52
  * Processes worth watching: PID 1 and every agent or build tool.
53
53
  * @param {object[]} processes output of `env.processes()`
54
54
  */
55
- export function watchedProcesses(processes) {
55
+ export function watchedProcesses(processes, mountNamespace = null) {
56
56
  return processes
57
+ .filter(
58
+ (proc) =>
59
+ !mountNamespace ||
60
+ !proc.mountNamespace ||
61
+ proc.mountNamespace === mountNamespace
62
+ )
57
63
  .filter((proc) => proc.pid === 1 || matchingName(proc, WATCHED_NAMES))
58
64
  .map(identity);
59
65
  }
@@ -118,7 +124,12 @@ export class HealthWatch {
118
124
  env: item.env,
119
125
  envLabel: item.envLabel,
120
126
  checks: 0,
121
- watched: processes ? watchedProcesses(processes) : [],
127
+ watched: processes
128
+ ? watchedProcesses(
129
+ processes,
130
+ env.kind === 'host' ? env.mountNamespace : null
131
+ )
132
+ : [],
122
133
  available: Boolean(processes),
123
134
  lost: [],
124
135
  stopped: null,
package/src/index.d.ts CHANGED
@@ -11,6 +11,16 @@ export interface ScanOptions {
11
11
  roots?: string[] | string | null;
12
12
  /** Directory depth for project search. */
13
13
  maxDepth?: number;
14
+ /** Time budget per filesystem environment (`2m`); incomplete findings are kept. */
15
+ scanBudget?: Duration;
16
+ signal?: AbortSignal;
17
+ onScanProgress?: (progress: {
18
+ env: string;
19
+ scanner: string;
20
+ elapsedMs: number;
21
+ items: number;
22
+ status: string;
23
+ }) => void;
14
24
  /** Activity window: newer files are kept (`1h`). */
15
25
  staleAge?: Duration;
16
26
  /** Alias of `staleAge`. */
@@ -65,7 +75,6 @@ export interface CleanOptions extends ScanOptions {
65
75
  confirm?: (item: Item) => boolean | Promise<boolean>;
66
76
  /** `false` skips writing the audit log file. */
67
77
  audit?: boolean;
68
- signal?: AbortSignal;
69
78
  onEntry?: (entry: AuditEntry, item: Item) => void;
70
79
  }
71
80
 
@@ -145,11 +154,22 @@ export interface EnvironmentDescriptor {
145
154
  chain: Array<{ containerId: string; name: string }>;
146
155
  hint?: string | null;
147
156
  disk?: Disk | null;
157
+ scanStatus?:
158
+ | 'scanning'
159
+ | 'complete'
160
+ | 'aborted'
161
+ | 'budget-exceeded'
162
+ | 'error'
163
+ | null;
164
+ layerSource?: string | null;
148
165
  }
149
166
 
150
167
  export interface TierTotal {
151
168
  items: number;
152
169
  bytes: number;
170
+ /** Visible blocked bytes, including data whose writable size is unknown. */
171
+ totalBytes?: number;
172
+ unknownBytes?: number;
153
173
  }
154
174
 
155
175
  export interface Report {
@@ -157,6 +177,8 @@ export interface Report {
157
177
  tool: 'disk-space-saviour';
158
178
  createdAt: string;
159
179
  durationMs: number;
180
+ aborted?: boolean;
181
+ audit?: { file: string | null; error: string; code?: string };
160
182
  host: { env: string; label: string; platform: string };
161
183
  options: Record<string, unknown>;
162
184
  environments: EnvironmentDescriptor[];
@@ -188,6 +210,8 @@ export interface AuditEntry {
188
210
  reason: string | null;
189
211
  plannedBytes: number;
190
212
  freedBytes: number;
213
+ /** True when authoritative before/after allocation could not be measured. */
214
+ freedEstimated?: boolean;
191
215
  durationMs: number;
192
216
  backup?: string;
193
217
  [key: string]: unknown;
package/src/items.js CHANGED
@@ -100,11 +100,17 @@ export function tierTotals(items) {
100
100
  Boolean(item.requiresConfirmation)
101
101
  )
102
102
  );
103
+ const blocked = dropNested(items.filter((item) => item.blockers.length > 0));
103
104
  totals.blocked = {
104
105
  items: items.filter((item) => item.blockers.length > 0).length,
105
- bytes: items
106
- .filter((item) => item.blockers.length > 0)
107
- .reduce((sum, item) => sum + item.bytes, 0),
106
+ bytes: blocked.reduce((sum, item) => sum + item.bytes, 0),
107
+ totalBytes: blocked.reduce(
108
+ (sum, item) => sum + (item.totalBytes ?? item.bytes),
109
+ 0
110
+ ),
111
+ unknownBytes: blocked
112
+ .filter((item) => item.sizeUnknown)
113
+ .reduce((sum, item) => sum + (item.totalBytes ?? item.bytes), 0),
108
114
  };
109
115
  return totals;
110
116
  }