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.
@@ -0,0 +1,239 @@
1
+ /** Advisory discovery/size index and authenticated, short-lived scan plans. */
2
+ import {
3
+ createHash,
4
+ createHmac,
5
+ randomBytes,
6
+ timingSafeEqual,
7
+ } from 'node:crypto';
8
+ import { promises as fs } from 'node:fs';
9
+ import os from 'node:os';
10
+ import path from 'node:path';
11
+ import { trace } from './exec.js';
12
+
13
+ export const REPORT_MAX_AGE = 30 * 60 * 1000;
14
+ export const SIZE_MAX_AGE = 60 * 60 * 1000;
15
+ const LIMIT = 20000;
16
+
17
+ export function defaultStateDirectory(vars = process.env) {
18
+ return path.join(
19
+ vars.XDG_STATE_HOME || path.join(os.homedir(), '.local', 'state'),
20
+ 'disk-space-saviour'
21
+ );
22
+ }
23
+
24
+ function payload(report) {
25
+ const { authentication, ...fields } = report;
26
+ void authentication;
27
+ return JSON.stringify(fields);
28
+ }
29
+
30
+ async function secret(directory) {
31
+ await fs.mkdir(directory, { recursive: true, mode: 0o700 });
32
+ const file = path.join(directory, 'scan-key');
33
+ try {
34
+ await fs.writeFile(file, randomBytes(32), { flag: 'wx', mode: 0o600 });
35
+ } catch (error) {
36
+ if (error.code !== 'EEXIST') {
37
+ throw error;
38
+ }
39
+ }
40
+ return fs.readFile(file);
41
+ }
42
+
43
+ export async function authenticateReport(report, directory) {
44
+ if (!directory) {
45
+ return report;
46
+ }
47
+ try {
48
+ report.authentication = {
49
+ hostname: os.hostname(),
50
+ uid: process.getuid?.() ?? os.userInfo().username,
51
+ digest: createHmac('sha256', await secret(directory))
52
+ .update(payload(report))
53
+ .digest('hex'),
54
+ };
55
+ } catch (error) {
56
+ trace('scan state unavailable', error.message);
57
+ }
58
+ return report;
59
+ }
60
+
61
+ function recentLocalReport(report, now) {
62
+ const age = now - Date.parse(report.createdAt);
63
+ const auth = report.authentication;
64
+ if (
65
+ !Number.isFinite(age) ||
66
+ age < 0 ||
67
+ age > REPORT_MAX_AGE ||
68
+ report.aborted ||
69
+ auth?.hostname !== os.hostname() ||
70
+ auth?.uid !== (process.getuid?.() ?? os.userInfo().username)
71
+ ) {
72
+ return false;
73
+ }
74
+ return true;
75
+ }
76
+
77
+ export async function authenticatedReport(report, directory, now = Date.now()) {
78
+ if (
79
+ !directory ||
80
+ report?.schema !== 1 ||
81
+ report.tool !== 'disk-space-saviour' ||
82
+ !recentLocalReport(report, now)
83
+ ) {
84
+ return false;
85
+ }
86
+ try {
87
+ const key = await fs.readFile(path.join(directory, 'scan-key'));
88
+ const expected = createHmac('sha256', key).update(payload(report)).digest();
89
+ const actual = Buffer.from(report.authentication.digest, 'hex');
90
+ return (
91
+ expected.length === actual.length && timingSafeEqual(expected, actual)
92
+ );
93
+ } catch {
94
+ return false;
95
+ }
96
+ }
97
+
98
+ export function scopeKey(options) {
99
+ const names = [
100
+ 'roots',
101
+ 'scanners',
102
+ 'host',
103
+ 'docker',
104
+ 'dockerDepth',
105
+ 'maxDepth',
106
+ 'staleAgeMs',
107
+ 'inactiveMs',
108
+ 'minSizeBytes',
109
+ 'only',
110
+ 'exclude',
111
+ 'includeVolumes',
112
+ 'noNative',
113
+ 'journalKeepBytes',
114
+ 'containerFilter',
115
+ 'removeStoppedContainers',
116
+ 'investigationMaxAgeMs',
117
+ 'removeContainers',
118
+ 'removeImages',
119
+ 'allowDirtyRepos',
120
+ 'allowDirtyContainers',
121
+ ];
122
+ return createHash('sha256')
123
+ .update(JSON.stringify(names.map((name) => [name, options[name]])))
124
+ .digest('hex');
125
+ }
126
+
127
+ export async function saveScan(report, directory) {
128
+ if (
129
+ !directory ||
130
+ !report.authentication ||
131
+ report.aborted ||
132
+ report.environments.some((env) => env.scanStatus === 'goal-reached')
133
+ ) {
134
+ return;
135
+ }
136
+ try {
137
+ const dir = path.join(directory, 'scans');
138
+ await fs.mkdir(dir, { recursive: true, mode: 0o700 });
139
+ const file = path.join(dir, `${scopeKey(report.options)}.json`);
140
+ const temporary = `${file}.${process.pid}.${randomBytes(4).toString('hex')}`;
141
+ await fs.writeFile(temporary, JSON.stringify(report), { mode: 0o600 });
142
+ await fs.rename(temporary, file);
143
+ } catch (error) {
144
+ trace('cannot save scan', error.message);
145
+ }
146
+ }
147
+
148
+ export async function loadScan(options) {
149
+ if (!options.cacheDir) {
150
+ return null;
151
+ }
152
+ try {
153
+ const report = JSON.parse(
154
+ await fs.readFile(
155
+ path.join(options.cacheDir, 'scans', `${scopeKey(options)}.json`),
156
+ 'utf8'
157
+ )
158
+ );
159
+ return (await authenticatedReport(report, options.cacheDir, options.now()))
160
+ ? report
161
+ : null;
162
+ } catch {
163
+ return null;
164
+ }
165
+ }
166
+
167
+ /** Cached listings are validated by each directory's own metadata, never its parent alone. */
168
+ export class ScanIndex {
169
+ constructor(directory) {
170
+ this.directory = directory;
171
+ this.entries = new Map();
172
+ this.dirty = false;
173
+ }
174
+
175
+ async load() {
176
+ if (!this.directory) {
177
+ return;
178
+ }
179
+ try {
180
+ const data = JSON.parse(
181
+ await fs.readFile(
182
+ path.join(this.directory, 'index', 'directories.json'),
183
+ 'utf8'
184
+ )
185
+ );
186
+ if (data.schema === 1 && Array.isArray(data.entries)) {
187
+ this.entries = new Map(data.entries.slice(-LIMIT));
188
+ }
189
+ } catch {
190
+ // Missing/corrupt state only loses an optimization.
191
+ }
192
+ }
193
+
194
+ get(key, fingerprint, maxAge = Infinity) {
195
+ const entry = this.entries.get(key);
196
+ return entry &&
197
+ entry.fingerprint === fingerprint &&
198
+ Date.now() - entry.time < maxAge
199
+ ? entry.value
200
+ : null;
201
+ }
202
+
203
+ set(key, fingerprint, value) {
204
+ this.entries.delete(key);
205
+ this.entries.set(key, { fingerprint, value, time: Date.now() });
206
+ while (this.entries.size > LIMIT) {
207
+ this.entries.delete(this.entries.keys().next().value);
208
+ }
209
+ this.dirty = true;
210
+ }
211
+
212
+ async save() {
213
+ if (!this.directory || !this.dirty) {
214
+ return;
215
+ }
216
+ const dir = path.join(this.directory, 'index');
217
+ const temporary = path.join(
218
+ dir,
219
+ `directories.${process.pid}.${randomBytes(4).toString('hex')}.json`
220
+ );
221
+ try {
222
+ await fs.mkdir(dir, { recursive: true, mode: 0o700 });
223
+ await fs.writeFile(
224
+ temporary,
225
+ JSON.stringify({ schema: 1, entries: [...this.entries] }),
226
+ { mode: 0o600 }
227
+ );
228
+ await fs.rename(temporary, path.join(dir, 'directories.json'));
229
+ this.dirty = false;
230
+ } catch (error) {
231
+ trace('cannot save discovery index', error.message);
232
+ } finally {
233
+ await fs.rm(temporary, { force: true }).catch(() => {});
234
+ }
235
+ }
236
+ }
237
+
238
+ export const directoryFingerprint = (stats) =>
239
+ `${stats.device}:${stats.inode}:${stats.mtimeMs}:${stats.ctimeMs}`;
package/src/scan.js CHANGED
@@ -7,9 +7,12 @@ import {
7
7
  configureContainerHost,
8
8
  resetWritableLayers,
9
9
  } from './docker/writable.js';
10
+ import { setInterval, clearInterval } from 'node:timers';
10
11
  import { scanDocker } from './docker/scan.js';
11
12
  import { LocalEnv } from './env/local.js';
12
13
  import { configureScanPolicy } from './env/policy.js';
14
+ import { configureCacheRoots } from './env/cache-roots.js';
15
+ import { checkSignal, environmentScope } from './env/scope.js';
13
16
  import { block, tierTotals } from './items.js';
14
17
  import { hostExecutor, trace } from './exec.js';
15
18
  import { GitInspector } from './git.js';
@@ -21,6 +24,14 @@ import { scanGlobal } from './scanners/global.js';
21
24
  import { scanProjects } from './scanners/projects.js';
22
25
  import { scanSystem } from './scanners/system.js';
23
26
  import { scanVersions } from './scanners/versions.js';
27
+ import { readSwap } from './swap.js';
28
+ import {
29
+ authenticateReport,
30
+ authenticatedReport,
31
+ saveScan,
32
+ ScanIndex,
33
+ scopeKey,
34
+ } from './scan-state.js';
24
35
 
25
36
  export const SCANNERS = {
26
37
  projects: scanProjects,
@@ -59,6 +70,9 @@ const RESCAN_OPTIONS = new Set([
59
70
  'journalKeep',
60
71
  'env',
61
72
  'now',
73
+ 'signal',
74
+ 'scanBudget',
75
+ 'onScanProgress',
62
76
  ]);
63
77
 
64
78
  function safetySnapshot(report) {
@@ -70,20 +84,47 @@ function sealReport(report) {
70
84
  return report;
71
85
  }
72
86
 
87
+ function unchangedDiscoveryScope(report, input) {
88
+ const original = resolveOptions(report.options);
89
+ const overrides = Object.fromEntries(
90
+ Object.entries(input).filter(
91
+ ([name, value]) =>
92
+ RESCAN_OPTIONS.has(name) && value !== undefined && value !== null
93
+ )
94
+ );
95
+ return (
96
+ scopeKey(original) ===
97
+ scopeKey(resolveOptions({ ...original, ...overrides }))
98
+ );
99
+ }
100
+
73
101
  /** Rebuild scanner-derived actions when a report came from JSON or changed. */
74
102
  export async function revalidateReport(saved, input = {}) {
75
103
  if (trustedReports.get(saved) === safetySnapshot(saved)) {
76
104
  return saved;
77
105
  }
78
- const scope = {
79
- roots: saved.options?.roots,
80
- scanners: saved.options?.scanners,
81
- containers: saved.options?.containers,
82
- host: saved.options?.host,
83
- docker: saved.options?.docker,
84
- dockerDepth: saved.options?.dockerDepth,
85
- maxDepth: saved.options?.maxDepth,
86
- };
106
+ saved = saved.report?.schema === REPORT_SCHEMA ? saved.report : saved;
107
+ if (
108
+ unchangedDiscoveryScope(saved, input) &&
109
+ (await authenticatedReport(
110
+ saved,
111
+ input.cacheDir,
112
+ input.now?.() ?? Date.now()
113
+ ))
114
+ ) {
115
+ return sealReport(saved);
116
+ }
117
+ const scope = Object.fromEntries(
118
+ [
119
+ 'roots',
120
+ 'scanners',
121
+ 'containers',
122
+ 'host',
123
+ 'docker',
124
+ 'dockerDepth',
125
+ 'maxDepth',
126
+ ].map((name) => [name, saved.options?.[name]])
127
+ );
87
128
  for (const [key, value] of Object.entries(input)) {
88
129
  if (RESCAN_OPTIONS.has(key) && value !== undefined && value !== null) {
89
130
  scope[key] = value;
@@ -133,6 +174,7 @@ export async function environmentContext(env, options, overrides = {}) {
133
174
  });
134
175
  await liveness.refresh(true);
135
176
  const homes = await env.homeDirs();
177
+ await configureCacheRoots(env, homes);
136
178
  const tmpDirs = await env.tmpDirs();
137
179
  const roots = overrides.roots ?? (await defaultRoots(env, homes, tmpDirs));
138
180
  return {
@@ -152,16 +194,127 @@ export async function environmentContext(env, options, overrides = {}) {
152
194
  * @returns {Promise<object[]>} items
153
195
  */
154
196
  export async function scanEnvironment(env, options, extra = {}) {
155
- const errors = extra.errors ?? [];
156
- let context;
197
+ const controller = new AbortController();
198
+ const abort = () => controller.abort(options.signal.reason);
199
+ options.signal?.addEventListener('abort', abort, { once: true });
200
+ if (options.signal?.aborted) {
201
+ abort();
202
+ }
203
+ const started = Date.now(),
204
+ deadline = started + options.scanBudgetMs;
205
+ const timer = setTimeout(
206
+ () => controller.abort(new Error('scan time budget exceeded')),
207
+ options.scanBudgetMs
208
+ );
209
+ const found = new Map();
210
+ const completed = new Set();
211
+ let scanner = 'context';
212
+ const progress = () =>
213
+ options.onScanProgress?.({
214
+ env: env.id,
215
+ scanner,
216
+ elapsedMs: Date.now() - started,
217
+ items: found.size,
218
+ candidates: found.size,
219
+ filesSized: env.scanMetrics?.filesSized ?? 0,
220
+ directoriesSized: env.scanMetrics?.directoriesSized ?? 0,
221
+ indexHits: env.scanMetrics?.indexHits ?? 0,
222
+ sizeCacheHits: env.scanMetrics?.sizeCacheHits ?? 0,
223
+ status: env.scanStatus,
224
+ });
225
+ env.scanStatus = 'scanning';
226
+ const interval = setInterval(progress, 5000);
157
227
  try {
158
- context = await environmentContext(env, options, extra);
228
+ return await environmentScope(
229
+ env,
230
+ controller.signal,
231
+ () => {
232
+ progress();
233
+ return scanEnvironmentScoped(
234
+ env,
235
+ options,
236
+ extra,
237
+ found,
238
+ completed,
239
+ (name) => {
240
+ scanner = name;
241
+ progress();
242
+ }
243
+ );
244
+ },
245
+ deadline
246
+ );
159
247
  } catch (error) {
160
- errors.push({ env: env.id, scanner: 'context', message: error.message });
161
- return [];
248
+ env.scanStatus = options.signal?.aborted
249
+ ? 'aborted'
250
+ : controller.signal.aborted
251
+ ? 'budget-exceeded'
252
+ : 'error';
253
+ (extra.errors ?? []).push({ env: env.id, scanner, message: error.message });
254
+ const items = [...found.values()];
255
+ for (const item of items) {
256
+ if (options.signal?.aborted || !completed.has(item.id)) {
257
+ block(item, `scan incomplete: ${error.message}`);
258
+ }
259
+ }
260
+ return items;
261
+ } finally {
262
+ clearTimeout(timer);
263
+ clearInterval(interval);
264
+ options.signal?.removeEventListener('abort', abort);
265
+ progress();
162
266
  }
163
- const items = [];
267
+ }
268
+
269
+ async function scanEnvironmentScoped(
270
+ env,
271
+ options,
272
+ extra,
273
+ found,
274
+ completed,
275
+ stage
276
+ ) {
277
+ const errors = extra.errors ?? [];
278
+ const context = await environmentContext(env, options, extra);
279
+ context.emit = (item) => found.set(item.id, item);
280
+ let stopped = false;
281
+ const ready = async (items) => {
282
+ await attributeStorage(env, items);
283
+ for (const item of items) {
284
+ for (const target of item.paths) {
285
+ block(item, env.scanPolicy.removalReason(target));
286
+ }
287
+ completed.add(item.id);
288
+ }
289
+ if (
290
+ env.kind === 'host' &&
291
+ context.scanner === 'global' &&
292
+ options.onScanPhase
293
+ ) {
294
+ const result = await options.onScanPhase(
295
+ sealReport({
296
+ schema: REPORT_SCHEMA,
297
+ tool: 'disk-space-saviour',
298
+ createdAt: new Date(options.now()).toISOString(),
299
+ host: { env: env.id, label: env.label, platform: env.platform },
300
+ options: reportOptions(options),
301
+ environments: [envDescriptor(env, 0, [])],
302
+ items: filterItems(items, options, [], env.path),
303
+ totals: tierTotals(items),
304
+ errors: [],
305
+ docker: null,
306
+ })
307
+ );
308
+ stopped ||= result === false;
309
+ }
310
+ return !stopped;
311
+ };
312
+ context.ready = ready;
313
+ let incomplete = false;
164
314
  for (const name of options.scanners) {
315
+ checkSignal(env.signal);
316
+ context.scanner = name;
317
+ stage(name);
165
318
  const scanner = SCANNERS[name];
166
319
  if (!scanner) {
167
320
  throw new Error(
@@ -169,14 +322,31 @@ export async function scanEnvironment(env, options, extra = {}) {
169
322
  );
170
323
  }
171
324
  const started = Date.now();
325
+ const previous = new Set(found.keys());
172
326
  try {
173
- items.push(...(await scanner(context)));
327
+ const entries = await scanner(context);
328
+ for (const item of entries) {
329
+ context.emit(item);
330
+ }
331
+ await ready(entries.filter((item) => !completed.has(item.id)));
174
332
  } catch (error) {
333
+ checkSignal(env.signal);
334
+ incomplete = true;
335
+ for (const [id, item] of found) {
336
+ if (![previous, completed].some((set) => set.has(id))) {
337
+ block(item, `scan incomplete: ${error.message}`);
338
+ }
339
+ }
175
340
  errors.push({ env: env.id, scanner: name, message: error.message });
176
341
  }
177
342
  trace('scanner', name, 'in', env.id, `${Date.now() - started}ms`);
343
+ if (stopped) {
344
+ env.scanStatus = 'goal-reached';
345
+ return [...found.values()];
346
+ }
178
347
  }
179
- await attributeStorage(env, items);
348
+ stage('storage');
349
+ const items = [...found.values()];
180
350
  for (const item of items) {
181
351
  for (const target of item.paths) {
182
352
  block(item, env.scanPolicy.removalReason(target));
@@ -185,6 +355,7 @@ export async function scanEnvironment(env, options, extra = {}) {
185
355
  for (const message of env.accessErrors?.values() ?? []) {
186
356
  errors.push({ env: env.id, scanner: 'filesystem', message });
187
357
  }
358
+ env.scanStatus = incomplete ? 'error' : 'complete';
188
359
  return items;
189
360
  }
190
361
 
@@ -307,13 +478,19 @@ function envDescriptor(env, depth, chain) {
307
478
  depth,
308
479
  chain,
309
480
  hint: env.probeHint ?? null,
481
+ scanStatus: env.scanStatus ?? null,
482
+ layerSource: env.writableLayer?.source ?? null,
310
483
  };
311
484
  }
312
485
 
313
486
  async function withDisk(env, descriptor) {
314
487
  const target = env.platform === 'win32' ? env.currentHome : '/';
315
488
  const disk = await env.diskUsage(target).catch(() => null);
316
- return { ...descriptor, disk: disk ? { path: target, ...disk } : null };
489
+ return {
490
+ ...descriptor,
491
+ disk: disk ? { path: target, ...disk } : null,
492
+ swap: await readSwap(env),
493
+ };
317
494
  }
318
495
 
319
496
  async function dockerSection(env, options, errors) {
@@ -373,10 +550,43 @@ function blockMountedHostItems(items, mounts, pathApi) {
373
550
  }
374
551
 
375
552
  export async function scan(input = {}) {
553
+ const env = input.env ?? new LocalEnv();
554
+ try {
555
+ return await environmentScope(env, input.signal, () =>
556
+ scanScoped({ ...input, env })
557
+ );
558
+ } catch (error) {
559
+ if (!input.signal?.aborted) {
560
+ throw error;
561
+ }
562
+ env.scanStatus = 'aborted';
563
+ const options = resolveOptions(input);
564
+ return buildReport({
565
+ options,
566
+ env,
567
+ items: [],
568
+ environments: [envDescriptor(env, 0, [])],
569
+ docker: null,
570
+ errors: [{ env: env.id, scanner: 'context', message: error.message }],
571
+ startedAt: options.now(),
572
+ });
573
+ }
574
+ }
575
+
576
+ async function scanScoped(input) {
376
577
  const options = resolveOptions(input);
377
578
  const env = input.env ?? new LocalEnv();
378
579
  const startedAt = options.now();
379
580
  const errors = [];
581
+ env.scanMetrics = {
582
+ filesSized: 0,
583
+ directoriesSized: 0,
584
+ indexHits: 0,
585
+ sizeCacheHits: 0,
586
+ };
587
+ env.scanIndex = new ScanIndex(options.cacheDir);
588
+ env.platformIndex = options.platformIndex;
589
+ await env.scanIndex.load();
380
590
  resetWritableLayers(env.executor);
381
591
  env.writableLayer = null;
382
592
  env.scanPolicy = null;
@@ -390,7 +600,10 @@ export async function scan(input = {}) {
390
600
  options.host === false
391
601
  ? []
392
602
  : await scanEnvironment(env, options, { roots: options.roots, errors });
393
- const docker = await dockerSection(env, options, errors);
603
+ const docker =
604
+ env.scanStatus === 'goal-reached'
605
+ ? null
606
+ : await dockerSection(env, options, errors);
394
607
  blockMountedHostItems(hostItems, docker?.bindMounts ?? [], env.path);
395
608
  const items = filterItems(
396
609
  [...hostItems, ...(docker?.items ?? [])],
@@ -413,6 +626,10 @@ export async function scan(input = {}) {
413
626
  startedAt,
414
627
  });
415
628
  reportEnvironments.set(report, env);
629
+ await env.scanIndex.save();
630
+ await authenticateReport(report, options.cacheDir);
631
+ sealReport(report);
632
+ await saveScan(report, options.cacheDir);
416
633
  return report;
417
634
  }
418
635
 
@@ -420,6 +637,9 @@ function reportOptions(options) {
420
637
  const serializable = { ...options };
421
638
  delete serializable.now;
422
639
  delete serializable.env;
640
+ delete serializable.signal;
641
+ delete serializable.onScanProgress;
642
+ delete serializable.onScanPhase;
423
643
  return serializable;
424
644
  }
425
645
 
@@ -440,6 +660,7 @@ export function buildReport({
440
660
  tool: 'disk-space-saviour',
441
661
  createdAt: new Date(options.now()).toISOString(),
442
662
  durationMs: options.now() - startedAt,
663
+ aborted: Boolean(options.signal?.aborted),
443
664
  host: { env: env.id, label: env.label, platform: env.platform },
444
665
  options: reportOptions(options),
445
666
  environments,
@@ -1,3 +1,4 @@
1
+ import { scanItems } from '../env/scope.js';
1
2
  /**
2
3
  * Snapshot stores of AI coding agents built on the opencode storage layout
3
4
  * (`@link-assistant/agent`, opencode).
@@ -112,7 +113,7 @@ async function appItems(context, app, root) {
112
113
  mtimeMs: usages.get(state.entry.path)?.newestMtimeMs ?? 0,
113
114
  }))
114
115
  .sort((a, b) => b.mtimeMs - a.mtimeMs)[0].state;
115
- const items = [];
116
+ const items = scanItems(context);
116
117
  for (const state of states) {
117
118
  const usage = usages.get(state.entry.path);
118
119
  const verdict =
@@ -148,7 +149,7 @@ async function appItems(context, app, root) {
148
149
  export async function scanAgents(context, { apps = AGENT_APPS } = {}) {
149
150
  const { env, options } = context;
150
151
  const homes = options.homes ?? (await env.homeDirs());
151
- const items = [];
152
+ const items = scanItems(context);
152
153
  const seen = new Set();
153
154
  for (const home of homes) {
154
155
  for (const dataDir of dataDirs(env, home)) {
@@ -26,10 +26,15 @@ export async function listMany(env, dirs) {
26
26
  * @returns {string|null}
27
27
  */
28
28
  export async function scanTimeBusy(context, item) {
29
- await context.liveness?.resolve(item.paths);
29
+ await resolvePaths(context, item.paths);
30
30
  return context.liveness ? context.liveness.busyReason(item) : null;
31
31
  }
32
32
 
33
+ /** Prime one batch before constructing items, so later per-item probes are cached. */
34
+ export async function resolvePaths(context, paths) {
35
+ await context.liveness?.resolve(paths);
36
+ }
37
+
33
38
  /**
34
39
  * Whether `now - timestamp` exceeds `windowMs` (unknown timestamps count as
35
40
  * recent, so they are never treated as abandoned).