disk-space-saviour 0.15.2 → 0.17.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/cli.js CHANGED
@@ -15,9 +15,11 @@ import { startAudit, writeAudit } from './audit.js';
15
15
  import { clean } from './clean.js';
16
16
  import { emergency } from './emergency.js';
17
17
  import { setTrace } from './exec.js';
18
- import { resolveGoal } from './options.js';
18
+ import { resolveGoal, resolveOptions } from './options.js';
19
19
  import { formatAudit, formatReport } from './report.js';
20
20
  import { scan } from './scan.js';
21
+ import { cleanDuringScan } from './fast-clean.js';
22
+ import { defaultStateDirectory, loadScan } from './scan-state.js';
21
23
  import { formatBytes } from './units.js';
22
24
 
23
25
  export const USAGE = `Usage: dss <command> [paths...] [options]
@@ -37,6 +39,7 @@ Scope:
37
39
  --recursive docker: recurse into nested daemons
38
40
  --container ID|NAME limit Docker work to these containers (repeat)
39
41
  --max-depth N directory depth for project search (default 6)
42
+ --scan-budget AGE time budget per filesystem environment (2m)
40
43
  --only NAME only these ecosystems, rules or kinds (repeat)
41
44
  --exclude PATH|GLOB never touch matching paths (repeat)
42
45
  --scanner NAME projects, global, versions, agents, system
@@ -66,6 +69,9 @@ Consent:
66
69
  Output:
67
70
  --json print the report or audit log as JSON
68
71
  --report FILE clean: use a report saved by scan --json
72
+ --from FILE clean/emergency: reuse a scan or audit JSON
73
+ --fresh bypass the recent scan and persistent index
74
+ --cache-dir DIR scan state (default: XDG state directory)
69
75
  --audit-dir DIR audit logs ($DSS_AUDIT_DIR)
70
76
  --backup-dir DIR container log backups ($DSS_BACKUP_DIR)
71
77
  --verbose list everything and trace commands (DSS_DEBUG=1)
@@ -80,6 +86,7 @@ const OPTIONS = {
80
86
  recursive: { type: 'boolean' },
81
87
  container: { type: 'string', multiple: true },
82
88
  'max-depth': { type: 'string' },
89
+ 'scan-budget': { type: 'string' },
83
90
  only: { type: 'string', multiple: true },
84
91
  exclude: { type: 'string', multiple: true },
85
92
  scanner: { type: 'string', multiple: true },
@@ -104,6 +111,9 @@ const OPTIONS = {
104
111
  'allow-dirty-container': { type: 'string', multiple: true },
105
112
  'no-native': { type: 'boolean' },
106
113
  report: { type: 'string' },
114
+ from: { type: 'string' },
115
+ fresh: { type: 'boolean' },
116
+ 'cache-dir': { type: 'string' },
107
117
  'audit-dir': { type: 'string' },
108
118
  'backup-dir': { type: 'string' },
109
119
  help: { type: 'boolean', short: 'h' },
@@ -158,6 +168,7 @@ export function toOptions(values, paths = []) {
158
168
  dockerDepth: integer(values.depth, '--depth'),
159
169
  containers: values.container,
160
170
  maxDepth: integer(values['max-depth'], '--max-depth'),
171
+ scanBudget: values['scan-budget'],
161
172
  only: values.only,
162
173
  exclude: values.exclude,
163
174
  scanners: values.scanner,
@@ -179,6 +190,9 @@ export function toOptions(values, paths = []) {
179
190
  noNative: values['no-native'],
180
191
  auditDir: values['audit-dir'],
181
192
  backupDir: values['backup-dir'],
193
+ cacheDir: values.fresh
194
+ ? false
195
+ : (values['cache-dir'] ?? defaultStateDirectory()),
182
196
  };
183
197
  }
184
198
 
@@ -237,14 +251,7 @@ function dockerOnly(options, values) {
237
251
  export function scanAudit(report) {
238
252
  return startAudit('scan', {
239
253
  dryRun: true,
240
- report: {
241
- createdAt: report.createdAt,
242
- durationMs: report.durationMs,
243
- totals: report.totals,
244
- environments: report.environments.map((env) => env.id),
245
- errors: report.errors,
246
- items: report.items,
247
- },
254
+ report,
248
255
  entries: report.items.map((item) => ({
249
256
  id: item.id,
250
257
  env: item.env,
@@ -280,7 +287,7 @@ export function processIo() {
280
287
  };
281
288
  }
282
289
 
283
- const API = { scan, clean, emergency };
290
+ const API = { scan, clean, emergency, cleanDuringScan };
284
291
 
285
292
  class Cli {
286
293
  constructor(io, api) {
@@ -294,9 +301,18 @@ class Cli {
294
301
 
295
302
  async scan(options, values) {
296
303
  const report = await this.api.scan(options);
304
+ return this.printScan(report, options, values);
305
+ }
306
+
307
+ async printScan(report, options, values) {
297
308
  const audit = scanAudit(report);
298
309
  if (options.audit !== false) {
299
- await writeAudit(audit, options);
310
+ try {
311
+ await writeAudit(audit, options);
312
+ } catch (error) {
313
+ report.audit = { file: null, error: error.message, code: error.code };
314
+ this.io.stderr(`Audit log: not written (${error.message})`);
315
+ }
300
316
  }
301
317
  this.print(values, report, () =>
302
318
  [
@@ -305,14 +321,23 @@ class Cli {
305
321
  'Run `dss clean --tier safe` to see the plan, add --yes to delete.',
306
322
  ].join('\n')
307
323
  );
308
- return report.errors.length > 0 ? 1 : 0;
324
+ return report.aborted ? 130 : report.errors.length > 0 ? 1 : 0;
309
325
  }
310
326
 
311
327
  async loadReport(options, values) {
312
- if (!values.report) {
328
+ const file = values.from ?? values.report;
329
+ if (!file) {
330
+ const recent = await loadScan(resolveOptions(options));
331
+ if (recent) {
332
+ this.io.stderr(
333
+ `Reusing scan from ${recent.createdAt}; planned items will be checked before deletion.`
334
+ );
335
+ return recent;
336
+ }
313
337
  return this.api.scan(options);
314
338
  }
315
- return JSON.parse(await fsp.readFile(values.report, 'utf8'));
339
+ const saved = JSON.parse(await fsp.readFile(file, 'utf8'));
340
+ return saved.report?.schema ? saved.report : saved;
316
341
  }
317
342
 
318
343
  confirmItem() {
@@ -359,7 +384,37 @@ class Cli {
359
384
  }
360
385
 
361
386
  async clean(options, values) {
387
+ if (
388
+ this.api.cleanDuringScan &&
389
+ !values.report &&
390
+ !values.from &&
391
+ !this.io.interactive
392
+ ) {
393
+ const recent = await loadScan(resolveOptions(options));
394
+ if (!recent) {
395
+ const result = await this.consent(values, (extra) =>
396
+ this.api.cleanDuringScan({
397
+ ...options,
398
+ ...extra,
399
+ streaming: options.docker !== false,
400
+ })
401
+ );
402
+ if (result.report.aborted) {
403
+ return this.printScan(result.report, options, values);
404
+ }
405
+ this.print(values, result.audit, () =>
406
+ formatAudit(result.audit, { verbose: values.verbose })
407
+ );
408
+ return result.audit.aborted ||
409
+ result.audit.entries.some((entry) => entry.status === 'failed')
410
+ ? 1
411
+ : 0;
412
+ }
413
+ }
362
414
  const report = await this.loadReport(options, values);
415
+ if (report.aborted) {
416
+ return this.printScan(report, options, values);
417
+ }
363
418
  const audit = await this.consent(values, (extra) =>
364
419
  this.api.clean(report, { ...options, ...extra })
365
420
  );
@@ -378,7 +433,36 @@ class Cli {
378
433
  } catch (error) {
379
434
  throw new UsageError(error.message);
380
435
  }
436
+ if (
437
+ this.api === API &&
438
+ !values.report &&
439
+ !values.from &&
440
+ !this.io.interactive
441
+ ) {
442
+ const recent = await loadScan(resolveOptions(options));
443
+ const audit = await this.consent(values, (extra) =>
444
+ this.api.emergency({
445
+ ...options,
446
+ ...(recent ? { report: recent } : {}),
447
+ ...extra,
448
+ })
449
+ );
450
+ if (audit.scanReport?.aborted) {
451
+ return this.printScan(audit.scanReport, options, values);
452
+ }
453
+ this.print(values, audit, () =>
454
+ formatAudit(audit, { verbose: values.verbose })
455
+ );
456
+ return audit.entries.some((entry) => entry.status === 'failed')
457
+ ? 1
458
+ : audit.goalMet
459
+ ? 0
460
+ : 3;
461
+ }
381
462
  const report = await this.loadReport(options, values);
463
+ if (report.aborted) {
464
+ return this.printScan(report, options, values);
465
+ }
382
466
  const audit = await this.consent(values, (extra) =>
383
467
  this.api.emergency({ ...options, report, ...extra })
384
468
  );
@@ -441,6 +525,10 @@ export async function runCli(argv, deps = {}) {
441
525
  if (deps.signal) {
442
526
  options.signal = deps.signal;
443
527
  }
528
+ options.onScanProgress = (progress) =>
529
+ io.stderr(
530
+ `[scan] ${progress.env}: ${progress.scanner}, ${Math.round(progress.elapsedMs / 1000)}s, ${progress.candidates ?? progress.items} candidates, ${progress.filesSized ?? 0} files sized, ${progress.directoriesSized ?? 0} directories sized (${progress.status})`
531
+ );
444
532
  return await dispatch(
445
533
  new Cli(io, deps.api ?? API),
446
534
  command,
package/src/docker/cli.js CHANGED
@@ -306,12 +306,16 @@ export class DockerCli {
306
306
  const stream = this.executor.spawn(
307
307
  this.argv(['cp', `${id}:${source}`, '-'])
308
308
  );
309
- return pipeInto(stream, [
310
- 'tar',
311
- '-x',
312
- '-C',
313
- destination,
314
- ...excludes.map((pattern) => `--exclude=${pattern}`),
315
- ]);
309
+ return pipeInto(
310
+ stream,
311
+ [
312
+ 'tar',
313
+ '-x',
314
+ '-C',
315
+ destination,
316
+ ...excludes.map((pattern) => `--exclude=${pattern}`),
317
+ ],
318
+ { signal: this.executor.signal }
319
+ );
316
320
  }
317
321
  }
@@ -22,6 +22,7 @@
22
22
 
23
23
  import { backupDirectory } from '../audit.js';
24
24
  import { ShellEnv } from '../env/shell.js';
25
+ import { checkSignal, signalExecutor } from '../env/scope.js';
25
26
  import { containerExecutor, trace } from '../exec.js';
26
27
  import { block, makeItem } from '../items.js';
27
28
  import { DockerCli, parseDockerSize } from './cli.js';
@@ -380,6 +381,7 @@ class DockerScan {
380
381
  }
381
382
 
382
383
  async daemon(record, depth, inherited) {
384
+ checkSignal(this.options.signal);
383
385
  const docker = new DockerCli(record.executor);
384
386
  const info = await docker.info();
385
387
  if (!info) {
@@ -443,6 +445,7 @@ class DockerScan {
443
445
  })),
444
446
  };
445
447
  for (const ps of all) {
448
+ checkSignal(this.options.signal);
446
449
  ps.sizes = await this.bestEffort(
447
450
  record.env.id,
448
451
  () => docker.containerSize(ps.ID),
@@ -544,6 +547,7 @@ class DockerScan {
544
547
  }))
545
548
  );
546
549
  for (const ps of all) {
550
+ checkSignal(this.options.signal);
547
551
  const selected = selection.ids.has(ps.ID);
548
552
  if (!selected) {
549
553
  continue;
@@ -597,7 +601,10 @@ class DockerScan {
597
601
 
598
602
  async running(record, depth, ps, entry, inspected) {
599
603
  const label = `${record.env.label}/${entry.name}`;
600
- const executor = containerExecutor(record.executor, ps.ID, { label });
604
+ const executor = signalExecutor(
605
+ containerExecutor(record.executor, ps.ID, { label }),
606
+ this.options.signal
607
+ );
601
608
  const env = new ShellEnv(executor, {
602
609
  id: `${record.env.id}/${entry.name}`,
603
610
  label,
@@ -614,14 +621,20 @@ class DockerScan {
614
621
  entry.env = env.id;
615
622
  entry.scanned = true;
616
623
  const chain = [...record.chain, { containerId: ps.ID, name: entry.name }];
617
- this.result.environments.push({
624
+ const descriptor = {
618
625
  id: env.id,
619
626
  label,
620
627
  kind: 'container',
621
628
  depth,
622
629
  chain,
623
- });
630
+ };
631
+ this.result.environments.push(descriptor);
624
632
  this.result.items.push(...(await this.scanEnvironment(env)));
633
+ Object.assign(descriptor, {
634
+ scanStatus: env.scanStatus,
635
+ hint: env.probeHint ?? null,
636
+ layerSource: env.writableLayer?.source ?? null,
637
+ });
625
638
  if (await env.which('docker')) {
626
639
  try {
627
640
  await this.daemon({ env, executor, chain }, depth, true);
@@ -650,6 +663,14 @@ export async function scanDocker(hostRecord, options, scanEnvironment) {
650
663
  if (!(await hostRecord.env.which('docker'))) {
651
664
  return scan.result;
652
665
  }
653
- await scan.daemon(hostRecord, 0, false);
666
+ try {
667
+ await scan.daemon(hostRecord, 0, false);
668
+ } catch (error) {
669
+ scan.result.errors.push({
670
+ env: hostRecord.env.id,
671
+ scanner: 'docker',
672
+ message: error.message,
673
+ });
674
+ }
654
675
  return scan.result;
655
676
  }
@@ -0,0 +1,44 @@
1
+ import { batches } from '../env/batches.js';
2
+
3
+ /** Root overlay from the running container's mount namespace, read by its daemon. */
4
+ export function rootUpperdir(mountinfo) {
5
+ for (const line of mountinfo.split('\n')) {
6
+ const [mount, filesystem] = line.split(' - ');
7
+ if (mount.split(' ')[4] !== '/' || !filesystem?.startsWith('overlay ')) {
8
+ continue;
9
+ }
10
+ const options = filesystem.split(' ').slice(2).join(' ');
11
+ const upper = /(?:^|,)upperdir=([^,]+)/.exec(options)?.[1];
12
+ if (upper) {
13
+ return upper.replace(/\\([0-7]{3})/g, (_, code) =>
14
+ String.fromCharCode(Number.parseInt(code, 8))
15
+ );
16
+ }
17
+ }
18
+ return null;
19
+ }
20
+
21
+ /** One transport call per bounded batch; null means inaccessible, never guessed. */
22
+ export async function upperdirBytes(executor, paths) {
23
+ const measured = new Map();
24
+ const script = `for t do
25
+ printf '\\001%s\\n' "$t"
26
+ value=$(LC_ALL=C du -skx -- "$t" 2>&1)
27
+ code=$?
28
+ if [ "$code" = 0 ]; then printf '%s\\n' "$value" | cut -f1
29
+ else case "$value" in *'No such file or directory'*) echo 0 ;; *) echo unknown ;; esac; fi
30
+ done`;
31
+ for (const batch of batches(paths)) {
32
+ const result = await executor.run(['sh', '-c', script, 'dss', ...batch]);
33
+ for (const record of result.stdout.split('\u0001').slice(1)) {
34
+ const newline = record.indexOf('\n'),
35
+ target = record.slice(0, newline),
36
+ value = record.slice(newline + 1).trim();
37
+ measured.set(
38
+ target,
39
+ result.code === 0 && /^\d+$/.test(value) ? Number(value) * 1024 : null
40
+ );
41
+ }
42
+ }
43
+ return measured;
44
+ }
@@ -3,11 +3,13 @@ import { DockerCli } from './cli.js';
3
3
  import { isWithin } from '../paths.js';
4
4
  import { trace } from '../exec.js';
5
5
  import { resetDiffSnapshots } from './snapshots.js';
6
+ import { rootUpperdir, upperdirBytes } from './upperdir.js';
6
7
 
7
8
  let measurementQueue = Promise.resolve();
8
9
  const layers = new WeakMap();
9
10
 
10
11
  export function sharedWritableLayer(executor, id) {
12
+ executor = executor.unscoped ?? executor;
11
13
  if (!layers.has(executor)) {
12
14
  layers.set(executor, new Map());
13
15
  }
@@ -19,12 +21,14 @@ export function sharedWritableLayer(executor, id) {
19
21
  }
20
22
 
21
23
  export function resetWritableLayers(executor) {
24
+ executor = executor.unscoped ?? executor;
22
25
  layers.delete(executor);
23
26
  resetDiffSnapshots(executor);
24
27
  }
25
28
 
26
29
  /** Overlay without an authoritative upper layer must never count merged bytes. */
27
30
  export class UnknownWritableLayer {
31
+ source = 'unknown-overlay';
28
32
  measure(env, usages) {
29
33
  return Promise.resolve(
30
34
  new Map(
@@ -67,14 +71,14 @@ export class WritableLayer {
67
71
  if (!container) {
68
72
  return { diff: null, container };
69
73
  }
70
- const upperdir =
71
- container.GraphDriver?.Data?.UpperDir ?? this.upperdirCandidate;
74
+ const { upperdir, source } = await this.upperdir(container);
72
75
  if (upperdir?.startsWith('/')) {
73
76
  const result = await this.docker.executor
74
77
  .run(['stat', '-c', '%F', '--', upperdir])
75
78
  .catch(() => null);
76
79
  if (result?.code === 0 && result.stdout.trim() === 'directory') {
77
80
  trace('using authoritative upperdir', this.id);
81
+ this.source = source;
78
82
  return { container, upperdir, diff: null };
79
83
  }
80
84
  }
@@ -86,6 +90,7 @@ export class WritableLayer {
86
90
  return { diff: null, container };
87
91
  }
88
92
  const diff = await this.docker.diff(this.id);
93
+ this.source = diff === null ? 'unknown' : 'docker-diff';
89
94
  trace('finished measuring container', this.id);
90
95
  // Bound the retained change set; unknown is safer than a partial result.
91
96
  return {
@@ -102,6 +107,27 @@ export class WritableLayer {
102
107
  return this.snapshotPromise;
103
108
  }
104
109
 
110
+ async upperdir(container) {
111
+ if (container.GraphDriver?.Data?.UpperDir) {
112
+ return {
113
+ upperdir: container.GraphDriver.Data.UpperDir,
114
+ source: 'graphdriver-upperdir',
115
+ };
116
+ }
117
+ const pid = container.State?.Pid;
118
+ if (Number.isInteger(pid) && pid > 0) {
119
+ const info = await this.docker.executor.run([
120
+ 'cat',
121
+ `/proc/${pid}/mountinfo`,
122
+ ]);
123
+ const upperdir = info.code === 0 ? rootUpperdir(info.stdout) : null;
124
+ if (upperdir) {
125
+ return { upperdir, source: 'mountinfo-upperdir' };
126
+ }
127
+ }
128
+ return { upperdir: this.upperdirCandidate, source: 'overlay-upperdir' };
129
+ }
130
+
105
131
  forget(target, pathApi) {
106
132
  this.removed.add(target);
107
133
  for (const file of this.stats.keys()) {
@@ -111,11 +137,55 @@ export class WritableLayer {
111
137
  }
112
138
  }
113
139
 
140
+ /** Allocated upper blocks before/after deletion, independent of merged data. */
141
+ async allocatedBytes(paths, pathApi) {
142
+ const { container, upperdir } = await this.snapshot();
143
+ if (
144
+ !upperdir ||
145
+ (container.Mounts ?? []).some((mount) =>
146
+ paths.some(
147
+ (target) =>
148
+ isWithin(target, mount.Destination, pathApi) ||
149
+ isWithin(mount.Destination, target, pathApi)
150
+ )
151
+ )
152
+ ) {
153
+ return null;
154
+ }
155
+ const measured = await upperdirBytes(
156
+ this.docker.executor,
157
+ paths.map((target) => `${upperdir}/${target.replace(/^\/+/, '')}`)
158
+ );
159
+ const values = [...measured.values()];
160
+ return values.length === paths.length &&
161
+ values.every((value) => value !== null)
162
+ ? values.reduce((sum, value) => sum + value, 0)
163
+ : null;
164
+ }
165
+
114
166
  async measureUpperdir(env, usages, container, upperdir) {
115
167
  const result = new Map();
116
168
  const mounts = (container.Mounts ?? [])
117
169
  .map((mount) => mount.Destination)
118
170
  .filter(Boolean);
171
+ const upperPath = (target) => `${upperdir}/${target.replace(/^\/+/, '')}`;
172
+ const targets = [...usages.keys()].filter(
173
+ (target) => !mounts.some((mount) => isWithin(target, mount, env.path))
174
+ );
175
+ const measurements = await upperdirBytes(
176
+ this.docker.executor,
177
+ targets.map(upperPath)
178
+ );
179
+ const mountRoots = mounts.filter(
180
+ (mount) =>
181
+ targets.some((target) => isWithin(mount, target, env.path)) &&
182
+ !mounts.some(
183
+ (other) => other !== mount && isWithin(mount, other, env.path)
184
+ )
185
+ );
186
+ const mountedUsage = mountRoots.length
187
+ ? await env.rawUsageMany(mountRoots)
188
+ : new Map();
119
189
  for (const [target, usage] of usages) {
120
190
  if (mounts.some((mount) => isWithin(target, mount, env.path))) {
121
191
  result.set(target, {
@@ -125,17 +195,24 @@ export class WritableLayer {
125
195
  });
126
196
  continue;
127
197
  }
128
- const measured = await this.docker.executor.run([
129
- 'du',
130
- '-skx',
131
- '--',
132
- `${upperdir}/${target.replace(/^\/+/, '')}`,
133
- ]);
134
- const kib = Number(/^([0-9]+)\s/m.exec(measured.stdout)?.[1]);
135
- // Missing upperdir leaf means image-only. Other errors remain unknown.
136
- const absent = /no such file|not found/i.test(measured.stderr);
137
- const known = (measured.code === 0 && Number.isFinite(kib)) || absent;
138
- const bytes = known ? Math.min(usage.bytes, absent ? 0 : kib * 1024) : 0;
198
+ const measured = measurements.get(upperPath(target));
199
+ const contained = mountRoots.filter((mount) =>
200
+ isWithin(mount, target, env.path)
201
+ );
202
+ const known =
203
+ measured !== null &&
204
+ measured !== undefined &&
205
+ contained.every((mount) => mountedUsage.has(mount));
206
+ const bytes = known
207
+ ? Math.min(
208
+ usage.bytes,
209
+ measured +
210
+ contained.reduce(
211
+ (sum, mount) => sum + mountedUsage.get(mount).bytes,
212
+ 0
213
+ )
214
+ )
215
+ : 0;
139
216
  result.set(target, {
140
217
  ...usage,
141
218
  bytes,
package/src/emergency.js CHANGED
@@ -89,12 +89,40 @@ export async function emergency(input = {}) {
89
89
  });
90
90
  const report = input.report
91
91
  ? await revalidateReport(input.report, { ...input, env })
92
- : await scan({ ...input, env });
93
- audit.report = { createdAt: report.createdAt, totals: report.totals };
92
+ : goalMet(goal, diskBefore)
93
+ ? {
94
+ createdAt: new Date(options.now()).toISOString(),
95
+ items: [],
96
+ environments: [{ id: env.id, label: env.label }],
97
+ totals: {},
98
+ }
99
+ : await scan({
100
+ ...input,
101
+ env,
102
+ onScanPhase: async (phase) => {
103
+ audit.diskAfter = await escalate({
104
+ report: phase,
105
+ options: {
106
+ ...options,
107
+ tier: 'safe',
108
+ streaming: options.docker !== false,
109
+ },
110
+ env,
111
+ target,
112
+ goal,
113
+ audit,
114
+ });
115
+ return !goalMet(goal, audit.diskAfter);
116
+ },
117
+ });
118
+ audit.report = report;
94
119
  if (options.audit !== false) {
95
120
  await writeAudit(audit, { ...options, inProgress: true });
96
121
  }
97
- let disk = diskBefore;
122
+ let disk = audit.diskAfter ?? diskBefore;
123
+ if (report.aborted) {
124
+ audit.scanReport = report;
125
+ }
98
126
  try {
99
127
  if (!goalMet(goal, disk)) {
100
128
  disk = await escalate({ report, options, env, target, goal, audit });
@@ -121,9 +149,10 @@ async function escalate({ report, options, env, target, goal, audit }) {
121
149
  ...options,
122
150
  env,
123
151
  onProgress: persistProgress,
152
+ priorHealth: audit.health,
124
153
  });
125
154
  audit.health = cleaner.health.records;
126
- const done = new Set();
155
+ const done = new Set(audit.entries.map((entry) => entry.id));
127
156
  const targetDevice = await env.deviceId?.(target);
128
157
  const scoped = await Promise.all(
129
158
  wantedItems(report, options).map(async (item) => {
@@ -141,8 +170,10 @@ async function escalate({ report, options, env, target, goal, audit }) {
141
170
  })
142
171
  );
143
172
  const wanted = scoped.filter(Boolean);
144
- let disk = audit.diskBefore;
145
- let freed = 0;
173
+ let disk = audit.diskAfter ?? audit.diskBefore;
174
+ let freed = audit.entries
175
+ .filter((entry) => entry.status === 'planned')
176
+ .reduce((sum, entry) => sum + entry.plannedBytes, 0);
146
177
  for (const tier of TIERS.slice(0, tierRank(options.tier) + 1)) {
147
178
  audit.reachedTier = tier;
148
179
  const items = selectByTier(wanted, tier)
@@ -0,0 +1,20 @@
1
+ /** Bound argument count and encoded size below common exec limits. */
2
+ export function batches(items, size = 200, maxBytes = 48000) {
3
+ const result = [];
4
+ let batch = [],
5
+ bytes = 0;
6
+ for (const item of items) {
7
+ const length = Buffer.byteLength(String(item)) + 1;
8
+ if (batch.length && (batch.length >= size || bytes + length > maxBytes)) {
9
+ result.push(batch);
10
+ batch = [];
11
+ bytes = 0;
12
+ }
13
+ batch.push(item);
14
+ bytes += length;
15
+ }
16
+ if (batch.length) {
17
+ result.push(batch);
18
+ }
19
+ return result;
20
+ }