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/CHANGELOG.md +587 -0
- package/LICENSE +24 -0
- package/README.md +538 -2
- package/bin/dss.js +5 -0
- package/package.json +96 -4
- package/src/audit.js +84 -0
- package/src/clean.js +716 -0
- package/src/cli.js +448 -0
- package/src/docker/cli.js +289 -0
- package/src/docker/containers.js +334 -0
- package/src/docker/images.js +122 -0
- package/src/docker/scan.js +531 -0
- package/src/emergency.js +169 -0
- package/src/env/local.js +715 -0
- package/src/env/resolve.js +36 -0
- package/src/env/shell.js +433 -0
- package/src/exec.js +185 -0
- package/src/git.js +338 -0
- package/src/health.js +174 -0
- package/src/index.d.ts +255 -0
- package/src/index.js +52 -0
- package/src/items.js +166 -0
- package/src/liveness.js +190 -0
- package/src/options.js +112 -0
- package/src/paths.js +182 -0
- package/src/report.js +376 -0
- package/src/rules/ecosystems.js +916 -0
- package/src/rules/other.js +375 -0
- package/src/rules/versions.js +256 -0
- package/src/scan.js +384 -0
- package/src/scanners/agents.js +166 -0
- package/src/scanners/common.js +38 -0
- package/src/scanners/global.js +217 -0
- package/src/scanners/projects.js +335 -0
- package/src/scanners/rust.js +404 -0
- package/src/scanners/system.js +171 -0
- package/src/scanners/versions.js +230 -0
- package/src/units.js +130 -0
package/src/index.d.ts
ADDED
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Type declarations for disk-space-saviour.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export type Tier = 'safe' | 'moderate' | 'aggressive';
|
|
6
|
+
export type Size = number | string;
|
|
7
|
+
export type Duration = number | string;
|
|
8
|
+
|
|
9
|
+
export interface ScanOptions {
|
|
10
|
+
/** Directories to search for projects (default: homes and tmp dirs). */
|
|
11
|
+
roots?: string[] | string | null;
|
|
12
|
+
/** Directory depth for project search. */
|
|
13
|
+
maxDepth?: number;
|
|
14
|
+
/** Activity window: newer files are kept (`1h`). */
|
|
15
|
+
staleAge?: Duration;
|
|
16
|
+
/** Alias of `staleAge`. */
|
|
17
|
+
olderThan?: Duration;
|
|
18
|
+
/** Inactivity before whole dependency dirs become `moderate` (`30d`). */
|
|
19
|
+
inactive?: Duration;
|
|
20
|
+
/** Items smaller than this are dropped (`1M`). */
|
|
21
|
+
minSize?: Size;
|
|
22
|
+
/** Scan the host file system (`false` for Docker only). */
|
|
23
|
+
host?: boolean;
|
|
24
|
+
/** `true` requires Docker, `false` skips it, `null` uses it if reachable. */
|
|
25
|
+
docker?: boolean | null;
|
|
26
|
+
/** Docker nesting depth (1 = containers of the host daemon). */
|
|
27
|
+
dockerDepth?: number;
|
|
28
|
+
/** Container ids or names to limit Docker work to. */
|
|
29
|
+
containers?: string[] | string;
|
|
30
|
+
scanners?: Array<'projects' | 'global' | 'versions' | 'agents' | 'system'>;
|
|
31
|
+
/** Only these ecosystems, rules or kinds. */
|
|
32
|
+
only?: string[] | string;
|
|
33
|
+
/** Paths or basename globs never touched. */
|
|
34
|
+
exclude?: string[] | string;
|
|
35
|
+
includeVolumes?: boolean;
|
|
36
|
+
removeStoppedContainers?: boolean;
|
|
37
|
+
/**
|
|
38
|
+
* Stopped containers (name, id or id prefix of 12+ characters) the
|
|
39
|
+
* operator approves one by one, including ones kept for investigation.
|
|
40
|
+
*/
|
|
41
|
+
removeContainers?: string[] | string;
|
|
42
|
+
/**
|
|
43
|
+
* Unused tagged images (`repo:tag`, `repo` for `:latest`, or an id prefix
|
|
44
|
+
* of 12+ characters) the operator approves one by one. No tier removes
|
|
45
|
+
* an image without it or an interactive yes.
|
|
46
|
+
*/
|
|
47
|
+
removeImages?: string[] | string;
|
|
48
|
+
allowDirtyRepos?: boolean;
|
|
49
|
+
/** Exact container ids whose verified Git changes may be removed. */
|
|
50
|
+
allowDirtyContainers?: string[] | string;
|
|
51
|
+
/** Delete files directly, never through native cache commands. */
|
|
52
|
+
noNative?: boolean;
|
|
53
|
+
/** journald size to keep (`512M`). */
|
|
54
|
+
journalKeep?: Size;
|
|
55
|
+
backupDir?: string | null;
|
|
56
|
+
auditDir?: string | null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface CleanOptions extends ScanOptions {
|
|
60
|
+
tier?: Tier;
|
|
61
|
+
dryRun?: boolean;
|
|
62
|
+
/** Interactive confirmation for items that need it. */
|
|
63
|
+
confirm?: (item: Item) => boolean | Promise<boolean>;
|
|
64
|
+
/** `false` skips writing the audit log file. */
|
|
65
|
+
audit?: boolean;
|
|
66
|
+
onEntry?: (entry: AuditEntry, item: Item) => void;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface EmergencyOptions extends CleanOptions {
|
|
70
|
+
/** Available bytes wanted, e.g. `20G`. */
|
|
71
|
+
free?: Size;
|
|
72
|
+
/** Maximum used percentage, e.g. `80%`. */
|
|
73
|
+
until?: number | string;
|
|
74
|
+
/** Volume to watch (default `/`). */
|
|
75
|
+
path?: string;
|
|
76
|
+
/** Reuse an existing scan. */
|
|
77
|
+
report?: Report;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface Action {
|
|
81
|
+
type: 'remove' | 'command' | 'docker-rm' | 'none';
|
|
82
|
+
paths?: string[];
|
|
83
|
+
argv?: string[];
|
|
84
|
+
containerId?: string;
|
|
85
|
+
name?: string;
|
|
86
|
+
[key: string]: unknown;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export interface Item {
|
|
90
|
+
id: string;
|
|
91
|
+
env: string;
|
|
92
|
+
envLabel: string;
|
|
93
|
+
kind: string;
|
|
94
|
+
ecosystem: string;
|
|
95
|
+
rule: string;
|
|
96
|
+
description: string;
|
|
97
|
+
path: string | null;
|
|
98
|
+
paths: string[];
|
|
99
|
+
bytes: number;
|
|
100
|
+
newestMtimeMs: number;
|
|
101
|
+
tier: Tier;
|
|
102
|
+
reason: string;
|
|
103
|
+
blockers: string[];
|
|
104
|
+
requiresConfirmation: string | null;
|
|
105
|
+
parentId: string | null;
|
|
106
|
+
action: Action;
|
|
107
|
+
/** Unused tagged images (`docker-unused-image`). */
|
|
108
|
+
image?: {
|
|
109
|
+
ref: string;
|
|
110
|
+
id: string;
|
|
111
|
+
bytes: number;
|
|
112
|
+
createdAt: string | null;
|
|
113
|
+
/** Last removed container that ran this image, when dss backed it up. */
|
|
114
|
+
lastUsedBy: {
|
|
115
|
+
container: string;
|
|
116
|
+
session: string | null;
|
|
117
|
+
taskUrl: string | null;
|
|
118
|
+
finishedAt: string | null;
|
|
119
|
+
} | null;
|
|
120
|
+
};
|
|
121
|
+
[key: string]: unknown;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export interface Disk {
|
|
125
|
+
total?: number;
|
|
126
|
+
free: number;
|
|
127
|
+
used: number;
|
|
128
|
+
path?: string;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export interface EnvironmentDescriptor {
|
|
132
|
+
id: string;
|
|
133
|
+
label: string;
|
|
134
|
+
kind: 'host' | 'container';
|
|
135
|
+
depth: number;
|
|
136
|
+
chain: Array<{ containerId: string; name: string }>;
|
|
137
|
+
disk?: Disk | null;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export interface TierTotal {
|
|
141
|
+
items: number;
|
|
142
|
+
bytes: number;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export interface Report {
|
|
146
|
+
schema: number;
|
|
147
|
+
tool: 'disk-space-saviour';
|
|
148
|
+
createdAt: string;
|
|
149
|
+
durationMs: number;
|
|
150
|
+
host: { env: string; label: string; platform: string };
|
|
151
|
+
options: Record<string, unknown>;
|
|
152
|
+
environments: EnvironmentDescriptor[];
|
|
153
|
+
items: Item[];
|
|
154
|
+
/** Tier totals exclude items that need explicit consent (`consent`). */
|
|
155
|
+
totals: Record<Tier | 'blocked' | 'consent', TierTotal>;
|
|
156
|
+
docker: {
|
|
157
|
+
daemons: Array<Record<string, unknown>>;
|
|
158
|
+
containers: Array<Record<string, unknown>>;
|
|
159
|
+
hints: Array<Record<string, unknown>>;
|
|
160
|
+
volumes: Array<Record<string, unknown>>;
|
|
161
|
+
} | null;
|
|
162
|
+
errors: Array<{ env: string; scanner: string; message: string }>;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export interface AuditEntry {
|
|
166
|
+
id: string;
|
|
167
|
+
env: string;
|
|
168
|
+
envLabel: string;
|
|
169
|
+
rule: string;
|
|
170
|
+
tier: Tier;
|
|
171
|
+
kind: string;
|
|
172
|
+
description: string;
|
|
173
|
+
path: string | null;
|
|
174
|
+
paths: string[];
|
|
175
|
+
pathCount: number;
|
|
176
|
+
deletedPaths: string[];
|
|
177
|
+
status: 'planned' | 'removed' | 'skipped' | 'failed';
|
|
178
|
+
reason: string | null;
|
|
179
|
+
plannedBytes: number;
|
|
180
|
+
freedBytes: number;
|
|
181
|
+
durationMs: number;
|
|
182
|
+
backup?: string;
|
|
183
|
+
[key: string]: unknown;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export interface EnvironmentTotal {
|
|
187
|
+
label: string;
|
|
188
|
+
freedBytes: number;
|
|
189
|
+
leftBytes: number;
|
|
190
|
+
removed: number;
|
|
191
|
+
planned: number;
|
|
192
|
+
skipped: number;
|
|
193
|
+
failed: number;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export interface AuditLog {
|
|
197
|
+
schema: number;
|
|
198
|
+
tool: 'disk-space-saviour';
|
|
199
|
+
command: string;
|
|
200
|
+
dryRun: boolean;
|
|
201
|
+
startedAt: string;
|
|
202
|
+
finishedAt: string | null;
|
|
203
|
+
entries: AuditEntry[];
|
|
204
|
+
freedBytes: number;
|
|
205
|
+
plannedBytes: number;
|
|
206
|
+
environments: Record<string, EnvironmentTotal>;
|
|
207
|
+
file?: string;
|
|
208
|
+
goal?: { freeBytes: number | null; untilPercent: number | null };
|
|
209
|
+
goalMet?: boolean;
|
|
210
|
+
reachedTier?: Tier;
|
|
211
|
+
diskBefore?: Disk;
|
|
212
|
+
diskAfter?: Disk;
|
|
213
|
+
[key: string]: unknown;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export declare const TIERS: Tier[];
|
|
217
|
+
|
|
218
|
+
/** Finds reclaimable space. Never deletes. */
|
|
219
|
+
export declare function scan(options?: ScanOptions): Promise<Report>;
|
|
220
|
+
|
|
221
|
+
/** Deletes what `options.tier` allows, re-checking every item first. */
|
|
222
|
+
export declare function clean(
|
|
223
|
+
report: Report,
|
|
224
|
+
options?: CleanOptions
|
|
225
|
+
): Promise<AuditLog>;
|
|
226
|
+
|
|
227
|
+
/** Escalates tiers until the free-space goal is met, then stops. */
|
|
228
|
+
export declare function emergency(options: EmergencyOptions): Promise<AuditLog>;
|
|
229
|
+
|
|
230
|
+
export declare function formatReport(
|
|
231
|
+
report: Report,
|
|
232
|
+
options?: { verbose?: boolean }
|
|
233
|
+
): string;
|
|
234
|
+
export declare function formatAudit(
|
|
235
|
+
audit: AuditLog,
|
|
236
|
+
options?: { verbose?: boolean }
|
|
237
|
+
): string;
|
|
238
|
+
|
|
239
|
+
/** Runs the `dss` command line and resolves to its exit code. */
|
|
240
|
+
export declare function runCli(
|
|
241
|
+
argv: string[],
|
|
242
|
+
deps?: { io?: object; api?: object }
|
|
243
|
+
): Promise<number>;
|
|
244
|
+
|
|
245
|
+
export declare function parseSize(value: string | number): number;
|
|
246
|
+
export declare function parseDuration(value: string | number): number;
|
|
247
|
+
export declare function parsePercent(value: string | number): number;
|
|
248
|
+
export declare function formatBytes(bytes: number): string;
|
|
249
|
+
export declare function formatDuration(ms: number): string;
|
|
250
|
+
export declare function goalMet(
|
|
251
|
+
goal: { freeBytes: number | null; untilPercent: number | null },
|
|
252
|
+
disk: Disk
|
|
253
|
+
): boolean;
|
|
254
|
+
export declare function selectByTier(items: Item[], tier: Tier): Item[];
|
|
255
|
+
export declare function dropNested(items: Item[]): Item[];
|
package/src/index.js
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* disk-space-saviour: safely reclaims disk space on hosts, inside running
|
|
3
|
+
* containers and in nested Docker daemons.
|
|
4
|
+
*
|
|
5
|
+
* - `scan(options) → Report` never deletes anything;
|
|
6
|
+
* - `clean(report, options) → AuditLog` deletes what the tier allows;
|
|
7
|
+
* - `emergency({free, until}) → AuditLog` escalates tiers until the goal
|
|
8
|
+
* is met.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export { scan, SCANNERS, REPORT_SCHEMA, CONTAINER_ROOTS } from './scan.js';
|
|
12
|
+
export {
|
|
13
|
+
clean,
|
|
14
|
+
planItems,
|
|
15
|
+
environmentTotals,
|
|
16
|
+
CONFIRMATION_FLAGS,
|
|
17
|
+
} from './clean.js';
|
|
18
|
+
export { emergency, goalMet, bytesNeeded, usedPercent } from './emergency.js';
|
|
19
|
+
export {
|
|
20
|
+
AUDIT_SCHEMA,
|
|
21
|
+
auditDirectory,
|
|
22
|
+
backupDirectory,
|
|
23
|
+
writeAudit,
|
|
24
|
+
} from './audit.js';
|
|
25
|
+
export { formatReport, formatAudit, formatDisk } from './report.js';
|
|
26
|
+
export { runCli, parseCli, USAGE } from './cli.js';
|
|
27
|
+
export { DEFAULTS, resolveOptions, resolveGoal } from './options.js';
|
|
28
|
+
export {
|
|
29
|
+
TIERS,
|
|
30
|
+
tierRank,
|
|
31
|
+
tierTotals,
|
|
32
|
+
selectByTier,
|
|
33
|
+
dropNested,
|
|
34
|
+
} from './items.js';
|
|
35
|
+
export {
|
|
36
|
+
parseSize,
|
|
37
|
+
parseDuration,
|
|
38
|
+
parsePercent,
|
|
39
|
+
formatBytes,
|
|
40
|
+
formatDuration,
|
|
41
|
+
} from './units.js';
|
|
42
|
+
export { ECOSYSTEMS, PROJECT_RULES, CACHE_RULES } from './rules/ecosystems.js';
|
|
43
|
+
export { OTHER_RULES } from './rules/other.js';
|
|
44
|
+
export { VERSION_RULES } from './rules/versions.js';
|
|
45
|
+
export { analyzeRustProfile, findRustProfiles } from './scanners/rust.js';
|
|
46
|
+
export { GitInspector } from './git.js';
|
|
47
|
+
export { LivenessProbe } from './liveness.js';
|
|
48
|
+
export { LocalEnv } from './env/local.js';
|
|
49
|
+
export { ShellEnv } from './env/shell.js';
|
|
50
|
+
export { resolveEnvironment } from './env/resolve.js';
|
|
51
|
+
export { hostExecutor, containerExecutor, setTrace } from './exec.js';
|
|
52
|
+
export { DockerCli, assertAllowed } from './docker/cli.js';
|
package/src/items.js
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Report item model shared by scanners, cleaner, emergency mode and output.
|
|
3
|
+
*
|
|
4
|
+
* An item is one reclaimable thing: a directory, a set of stale files, a
|
|
5
|
+
* native clean command, or a Docker object. Items with `blockers` are
|
|
6
|
+
* reported but never acted on. Items with `requiresConfirmation` need the
|
|
7
|
+
* named flag (or an interactive yes) on top of their tier being selected.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export const TIERS = ['safe', 'moderate', 'aggressive'];
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* @param {string} tier
|
|
14
|
+
* @returns {number} 0 for safe, 1 for moderate, 2 for aggressive
|
|
15
|
+
*/
|
|
16
|
+
export function tierRank(tier) {
|
|
17
|
+
const rank = TIERS.indexOf(tier);
|
|
18
|
+
if (rank === -1) {
|
|
19
|
+
throw new Error(`Unknown tier: ${tier} (expected ${TIERS.join(', ')})`);
|
|
20
|
+
}
|
|
21
|
+
return rank;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Creates an item with defaults filled in.
|
|
26
|
+
* @param {{id: string, label: string}} env
|
|
27
|
+
* @param {object} fields
|
|
28
|
+
*/
|
|
29
|
+
export function makeItem(env, fields) {
|
|
30
|
+
const paths = fields.paths ?? (fields.path ? [fields.path] : []);
|
|
31
|
+
const item = {
|
|
32
|
+
id: `${env.id}:${fields.rule}:${fields.path ?? fields.target ?? paths[0] ?? ''}`,
|
|
33
|
+
env: env.id,
|
|
34
|
+
envLabel: env.label,
|
|
35
|
+
kind: 'cache',
|
|
36
|
+
ecosystem: 'other',
|
|
37
|
+
rule: 'unknown',
|
|
38
|
+
description: '',
|
|
39
|
+
path: paths[0] ?? null,
|
|
40
|
+
bytes: 0,
|
|
41
|
+
newestMtimeMs: 0,
|
|
42
|
+
tier: 'safe',
|
|
43
|
+
reason: '',
|
|
44
|
+
blockers: [],
|
|
45
|
+
requiresConfirmation: null,
|
|
46
|
+
parentId: null,
|
|
47
|
+
checks: { busy: [], cwd: null, mtime: true },
|
|
48
|
+
...fields,
|
|
49
|
+
};
|
|
50
|
+
item.paths = paths;
|
|
51
|
+
item.action ??= { type: 'remove', paths };
|
|
52
|
+
return item;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The option that approves an item, as a command-line flag. An unused image
|
|
57
|
+
* is approved one at a time with `--remove-image REF`.
|
|
58
|
+
*/
|
|
59
|
+
export function consentFlag(item) {
|
|
60
|
+
if (item.requiresConfirmation === 'removeImages' && item.image) {
|
|
61
|
+
return `--remove-image ${item.image.ref}`;
|
|
62
|
+
}
|
|
63
|
+
const kebab = item.requiresConfirmation.replace(
|
|
64
|
+
/[A-Z]/g,
|
|
65
|
+
(letter) => `-${letter.toLowerCase()}`
|
|
66
|
+
);
|
|
67
|
+
return `--${kebab}`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Adds a blocker reason to an item (idempotent).
|
|
72
|
+
*/
|
|
73
|
+
export function block(item, reason) {
|
|
74
|
+
if (reason && !item.blockers.includes(reason)) {
|
|
75
|
+
item.blockers.push(reason);
|
|
76
|
+
}
|
|
77
|
+
return item;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Totals per tier. Each tier total includes the lower tiers, and nested
|
|
82
|
+
* items (whose parent is also counted) are not double counted.
|
|
83
|
+
* @param {object[]} items
|
|
84
|
+
*/
|
|
85
|
+
export function tierTotals(items) {
|
|
86
|
+
const totals = {};
|
|
87
|
+
// A tier total is what `clean --tier <tier> --yes` removes: items that
|
|
88
|
+
// need a per-object flag or an interactive yes are counted apart.
|
|
89
|
+
const sum = (selected) => ({
|
|
90
|
+
items: selected.length,
|
|
91
|
+
bytes: selected.reduce((total, item) => total + item.bytes, 0),
|
|
92
|
+
});
|
|
93
|
+
for (const tier of TIERS) {
|
|
94
|
+
totals[tier] = sum(
|
|
95
|
+
selectByTier(items, tier, (item) => !item.requiresConfirmation)
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
totals.consent = sum(
|
|
99
|
+
selectByTier(items, 'aggressive', (item) =>
|
|
100
|
+
Boolean(item.requiresConfirmation)
|
|
101
|
+
)
|
|
102
|
+
);
|
|
103
|
+
totals.blocked = {
|
|
104
|
+
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),
|
|
108
|
+
};
|
|
109
|
+
return totals;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function parentPath(target) {
|
|
113
|
+
let end = target.length;
|
|
114
|
+
while (end > 0 && (target[end - 1] === '/' || target[end - 1] === '\\')) {
|
|
115
|
+
end--;
|
|
116
|
+
}
|
|
117
|
+
const trimmed = target.slice(0, end);
|
|
118
|
+
const index = Math.max(trimmed.lastIndexOf('/'), trimmed.lastIndexOf('\\'));
|
|
119
|
+
return index > 0 ? trimmed.slice(0, index) : null;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Drops items contained in another item of the same environment: by
|
|
124
|
+
* `parentId`, or because every path lies below another item's path.
|
|
125
|
+
* @param {object[]} items
|
|
126
|
+
*/
|
|
127
|
+
export function dropNested(items) {
|
|
128
|
+
const ids = new Set(items.map((item) => item.id));
|
|
129
|
+
const paths = new Set(
|
|
130
|
+
items
|
|
131
|
+
.filter((item) => item.action?.type === 'remove')
|
|
132
|
+
.flatMap((item) => item.paths.map((p) => `${item.env}\0${p}`))
|
|
133
|
+
);
|
|
134
|
+
const inside = (item, target) => {
|
|
135
|
+
for (let dir = parentPath(target); dir; dir = parentPath(dir)) {
|
|
136
|
+
if (paths.has(`${item.env}\0${dir}`)) {
|
|
137
|
+
return true;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return false;
|
|
141
|
+
};
|
|
142
|
+
return items.filter(
|
|
143
|
+
(item) =>
|
|
144
|
+
!(item.parentId && ids.has(item.parentId)) &&
|
|
145
|
+
!(item.paths.length > 0 && item.paths.every((p) => inside(item, p)))
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Items at or below `maxTier` without blockers, dropping items nested in
|
|
151
|
+
* another selected item.
|
|
152
|
+
* @param {object[]} items
|
|
153
|
+
* @param {string} maxTier
|
|
154
|
+
* @param {(item: object) => boolean} [accept] extra filter
|
|
155
|
+
*/
|
|
156
|
+
export function selectByTier(items, maxTier, accept = () => true) {
|
|
157
|
+
const rank = tierRank(maxTier);
|
|
158
|
+
return dropNested(
|
|
159
|
+
items.filter(
|
|
160
|
+
(item) =>
|
|
161
|
+
tierRank(item.tier) <= rank &&
|
|
162
|
+
item.blockers.length === 0 &&
|
|
163
|
+
accept(item)
|
|
164
|
+
)
|
|
165
|
+
);
|
|
166
|
+
}
|
package/src/liveness.js
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Liveness checks: is anything using a path right now?
|
|
3
|
+
*
|
|
4
|
+
* Three independent signals, any of which blocks deletion:
|
|
5
|
+
* - recent writes: newest file mtime inside the stale-age window;
|
|
6
|
+
* - a relevant tool is running (by process name), optionally only when its
|
|
7
|
+
* working directory is inside the project;
|
|
8
|
+
* - a process holds a file, working directory, or executable inside the path
|
|
9
|
+
* (`/proc/<pid>/{fd,cwd,exe}` on Linux, `lsof` on macOS).
|
|
10
|
+
*
|
|
11
|
+
* The cleaner refreshes the probe right before acting on each item.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { isWithin } from './paths.js';
|
|
15
|
+
import { formatDuration } from './units.js';
|
|
16
|
+
|
|
17
|
+
const PROC_COMM_LIMIT = 15;
|
|
18
|
+
|
|
19
|
+
function nameMatches(name, candidate) {
|
|
20
|
+
return (
|
|
21
|
+
name === candidate ||
|
|
22
|
+
(name.length === PROC_COMM_LIMIT &&
|
|
23
|
+
candidate.startsWith(name) &&
|
|
24
|
+
candidate.length > PROC_COMM_LIMIT)
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Name of `proc` that matches one of `names`, or null. Besides the kernel
|
|
30
|
+
* name (`comm`, which programs may rename: Node.js calls its main thread
|
|
31
|
+
* `MainThread`), processes carry `aliases`: the executable and `argv[0]`
|
|
32
|
+
* base names.
|
|
33
|
+
*/
|
|
34
|
+
export function matchingName(proc, names) {
|
|
35
|
+
const own = [proc.name ?? '', ...(proc.aliases ?? [])];
|
|
36
|
+
for (const name of own) {
|
|
37
|
+
if (name && names.some((candidate) => nameMatches(name, candidate))) {
|
|
38
|
+
return name;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
const commandWords = (proc.command ?? '').toLowerCase().split(/[^a-z0-9_-]+/);
|
|
42
|
+
for (const candidate of names) {
|
|
43
|
+
if (commandWords.includes(candidate.toLowerCase())) {
|
|
44
|
+
return candidate;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export class LivenessProbe {
|
|
51
|
+
/**
|
|
52
|
+
* @param {object} env environment adapter
|
|
53
|
+
* @param {{staleAgeMs: number, now?: () => number, refreshMs?: number}} options
|
|
54
|
+
*/
|
|
55
|
+
constructor(env, { staleAgeMs, now = () => Date.now(), refreshMs = 2000 }) {
|
|
56
|
+
this.env = env;
|
|
57
|
+
this.staleAgeMs = staleAgeMs;
|
|
58
|
+
this.now = now;
|
|
59
|
+
this.refreshMs = refreshMs;
|
|
60
|
+
this.refreshedAt = -Infinity;
|
|
61
|
+
this.processes = [];
|
|
62
|
+
this.openPaths = null;
|
|
63
|
+
this.probeError = false;
|
|
64
|
+
this.realPaths = new Map();
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async refresh(force = false) {
|
|
68
|
+
if (!force && this.now() - this.refreshedAt < this.refreshMs) {
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
const processes = await this.env.processes().catch(() => null);
|
|
72
|
+
this.listed = processes !== null;
|
|
73
|
+
this.processes = processes ?? [];
|
|
74
|
+
this.openPaths = await this.env.openPaths().catch(() => null);
|
|
75
|
+
this.probeError =
|
|
76
|
+
(processes === null || this.openPaths === null) &&
|
|
77
|
+
(this.env.platform === 'linux' || this.env.platform === 'darwin');
|
|
78
|
+
this.refreshedAt = this.now();
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Processes of the last refresh, or null when they could not be listed. */
|
|
82
|
+
processList() {
|
|
83
|
+
return this.listed ? this.processes : null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Resolves symlinks in `paths` (cached), because processes report the
|
|
88
|
+
* paths they hold open with symlinks resolved: a scan of `/var/folders`
|
|
89
|
+
* on macOS must match `lsof` output under `/private/var/folders`.
|
|
90
|
+
* @param {string[]} paths
|
|
91
|
+
*/
|
|
92
|
+
async resolve(paths) {
|
|
93
|
+
if (typeof this.env.realPath !== 'function') {
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
for (const target of paths) {
|
|
97
|
+
if (!this.realPaths.has(target)) {
|
|
98
|
+
this.realPaths.set(target, await this.env.realPath(target));
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
recentWrite(newestMtimeMs) {
|
|
104
|
+
if (!newestMtimeMs || this.staleAgeMs <= 0) {
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
const age = this.now() - newestMtimeMs;
|
|
108
|
+
if (age < this.staleAgeMs) {
|
|
109
|
+
return `modified ${formatDuration(Math.max(age, 0))} ago, inside the ${formatDuration(this.staleAgeMs)} activity window`;
|
|
110
|
+
}
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
runningTool(names, cwd) {
|
|
115
|
+
if (!names || names.length === 0) {
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
const pathApi = this.env.path;
|
|
119
|
+
for (const proc of this.processes) {
|
|
120
|
+
const name = matchingName(proc, names);
|
|
121
|
+
if (!name) {
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
if (!cwd) {
|
|
125
|
+
return `${name} is running (pid ${proc.pid})`;
|
|
126
|
+
}
|
|
127
|
+
if (!proc.cwd) {
|
|
128
|
+
return `${name} is running (pid ${proc.pid}, working directory unknown)`;
|
|
129
|
+
}
|
|
130
|
+
if (isWithin(proc.cwd, cwd, pathApi)) {
|
|
131
|
+
return `${name} is running in ${proc.cwd} (pid ${proc.pid})`;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
openInside(paths) {
|
|
138
|
+
if (!this.openPaths || paths.length === 0) {
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
const pathApi = this.env.path;
|
|
142
|
+
const targets = paths.flatMap((target) => {
|
|
143
|
+
const real = this.realPaths.get(target);
|
|
144
|
+
return real && real !== target ? [target, real] : [target];
|
|
145
|
+
});
|
|
146
|
+
for (const open of this.openPaths) {
|
|
147
|
+
for (const target of targets) {
|
|
148
|
+
if (isWithin(open, target, pathApi)) {
|
|
149
|
+
return `in use: ${open}`;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Paths of `paths` that a process holds open, with the reason.
|
|
158
|
+
* @param {string[]} paths
|
|
159
|
+
* @returns {Map<string, string>}
|
|
160
|
+
*/
|
|
161
|
+
openPathsOf(paths) {
|
|
162
|
+
const open = new Map();
|
|
163
|
+
for (const target of paths) {
|
|
164
|
+
const reason = this.openInside([target]);
|
|
165
|
+
if (reason) {
|
|
166
|
+
open.set(target, reason);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
return open;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* First reason the item is busy, or null when it is idle. Items with
|
|
174
|
+
* `checks.perPath` leave open files to `openPathsOf`: the caller skips
|
|
175
|
+
* those paths and keeps the item.
|
|
176
|
+
* @param {object} item
|
|
177
|
+
*/
|
|
178
|
+
busyReason(item) {
|
|
179
|
+
const checks = item.checks ?? {};
|
|
180
|
+
const paths = checks.perPath ? [] : (item.paths ?? []);
|
|
181
|
+
return (
|
|
182
|
+
(this.probeError
|
|
183
|
+
? 'cannot verify process and open-file activity'
|
|
184
|
+
: null) ??
|
|
185
|
+
(checks.mtime === false ? null : this.recentWrite(item.newestMtimeMs)) ??
|
|
186
|
+
this.runningTool(checks.busy, checks.cwd) ??
|
|
187
|
+
this.openInside(paths)
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
}
|