@bli-cockpit/cli 0.2.94 → 0.2.95

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.
@@ -9,7 +9,7 @@
9
9
  * The collector staged raw evidence, uploaded it, committed it, and then kept
10
10
  * the local copy forever.
11
11
  *
12
- * Nothing here deletes anything. This module answers ONE question per file —
12
+ * Nothing here deletes anything. This family answers ONE question per file —
13
13
  * "does a ledger on this machine say these bytes are durable remotely?" — and
14
14
  * the answer has three values, not two, because the honest third one is what
15
15
  * the same probe turned up:
@@ -45,401 +45,38 @@
45
45
  *
46
46
  * Metadata only: pack ids, pack-relative paths, byte counts, hashes, reason
47
47
  * labels. Never a person's absolute path and never a byte of content.
48
- */
49
- import crypto from "node:crypto";
50
- import fs from "node:fs/promises";
51
- import path from "node:path";
52
- import { readRawEvidenceCursor } from "./cursors/raw-evidence-cursor.js";
53
- import { readReconcileCursor, } from "./cursors/raw-evidence-reconcile-cursor.js";
54
- import { describeError } from "./health-detail.js";
55
- import { rotatedLogFootprint } from "./log-rotation.js";
56
- import { readRawEvidenceStagingState } from "./raw-evidence-staging.js";
57
- /** Bytes this inventory may spend hashing files no manifest names. */
58
- export const UNNAMED_FILE_HASH_BUDGET_BYTES = 256 * 1024 * 1024;
59
- /** Directory name of the staged raw-evidence tree under the state directory. */
60
- export const RAW_EVIDENCE_DIR = "raw-evidence";
61
- /** One-off evidence vaults a person made by hand; named, never auto-deleted. */
62
- export const MANUAL_VAULT_PREFIX = "manual-study-evidence-vault-";
63
- /**
64
- * Everything on this machine Cockpit put there, in one read. Used by the sync
65
- * tick's prune, by `cockpit clean` and by `cockpit doctor`'s disk row, so all
66
- * three quote the same numbers rather than three near-misses.
67
- */
68
- export async function readDiskFootprint(paths, now = new Date()) {
69
- return {
70
- staging: await readStagingInventory(paths, now),
71
- logs: await rotatedLogFootprint(paths),
72
- spool_bytes: await directoryBytes(paths.spool_dir),
73
- vaults: await readManualVaults(paths),
74
- };
75
- }
76
- export async function readStagingInventory(paths, now = new Date()) {
77
- const root = path.join(paths.state_dir, RAW_EVIDENCE_DIR);
78
- const ledger = await readCommitLedger(paths);
79
- const reconciled = await readReconcileLedger(paths);
80
- const stagedAt = await readStagedAtIndex(paths);
81
- const entries = await fs
82
- .readdir(root, { withFileTypes: true })
83
- .catch(() => []);
84
- const inventory = emptyStagingInventory();
85
- const budget = { remaining: UNNAMED_FILE_HASH_BUDGET_BYTES };
86
- for (const entry of entries) {
87
- if (!entry.isDirectory())
88
- continue;
89
- const dir = path.join(root, entry.name);
90
- if (entry.name.startsWith(".staging-")) {
91
- inventory.orphan_staging_dirs += 1;
92
- inventory.orphan_staging_bytes += await directoryBytes(dir);
93
- continue;
94
- }
95
- if (!entry.name.startsWith("work-"))
96
- continue;
97
- const pack = await readPack(dir, entry.name, {
98
- ledger,
99
- reconciled,
100
- stagedAt,
101
- now,
102
- budget,
103
- });
104
- inventory.packs.push(pack);
105
- accumulatePack(inventory, pack);
106
- }
107
- rankUncommittedReasons(inventory);
108
- return inventory;
109
- }
110
- /**
111
- * The commit record, read from the one place that writes it: the raw-evidence
112
- * cursor's `objects` map (`upload.ts` → `markObjectCommitted`, only ever for an
113
- * outcome that was not `upload_failed`).
114
- */
115
- async function readCommitLedger(paths) {
116
- const cursor = await readRawEvidenceCursor(paths);
117
- const committed = new Map();
118
- let oldest = null;
119
- for (const [hash, entry] of Object.entries(cursor.objects)) {
120
- committed.set(hash, entry.committed_at);
121
- if (!oldest || entry.committed_at.localeCompare(oldest) < 0) {
122
- oldest = entry.committed_at;
123
- }
124
- }
125
- return { committed, reachesBackTo: oldest };
126
- }
127
- /** `pack_id/relative_path` → when this machine staged those bytes. */
128
- async function readStagedAtIndex(paths) {
129
- const staging = await readRawEvidenceStagingState(paths.state_dir);
130
- const index = new Map();
131
- for (const entry of Object.values(staging.staged)) {
132
- index.set(`${entry.pack_id}/${entry.relative_path}`, entry.staged_at);
133
- }
134
- return index;
135
- }
136
- /**
137
- * The reconcile cursor as a lookup map. Read alongside the commit ledger so an
138
- * object the server confirmed committed classifies exactly like one this
139
- * machine's own ledger still remembers.
140
- */
141
- async function readReconcileLedger(paths) {
142
- const cursor = await readReconcileCursor(paths);
143
- return new Map(Object.entries(cursor.results));
144
- }
145
- async function readPack(dir, packId, options) {
146
- const manifest = await readManifestIndex(dir);
147
- const pack = {
148
- pack_id: packId,
149
- objects: [],
150
- manifest_bytes: 0,
151
- manifest_present: manifest !== null,
152
- };
153
- for (const relative of await listPackFiles(dir)) {
154
- // One stat, two facts: how big the staged copy is and WHEN it was written.
155
- // The mtime is the only unfalsifiable record of how long undelivered
156
- // evidence has been sitting here (BLI-3797) — every other timestamp on the
157
- // machine records when something last had an opinion about it.
158
- const info = await fs.stat(path.join(dir, relative)).catch(() => null);
159
- const size = info?.isFile() ? info.size : 0;
160
- if (relative === "manifest.json") {
161
- pack.manifest_bytes += size;
162
- continue;
163
- }
164
- const stagedOnDiskAt = info && Number.isFinite(info.mtimeMs)
165
- ? new Date(info.mtimeMs).toISOString()
166
- : null;
167
- pack.objects.push(await classifyObject(dir, packId, relative, size, manifest, {
168
- ...options,
169
- stagedOnDiskAt,
170
- }));
171
- }
172
- return pack;
173
- }
174
- async function classifyObject(dir, packId, relative, size, manifest, options) {
175
- const hash = manifest?.get(relative) ??
176
- (await hashUnnamedFile(path.join(dir, relative), size, options.budget));
177
- const stagedAt = options.stagedAt.get(`${packId}/${relative}`) ?? null;
178
- const committedAt = hash ? options.ledger.committed.get(hash) : undefined;
179
- const base = {
180
- pack_id: packId,
181
- relative_path: relative,
182
- byte_size: size,
183
- staged_on_disk_at: options.stagedOnDiskAt,
184
- disk_age_ms: ageMs(options.stagedOnDiskAt, options.now),
185
- };
186
- if (committedAt) {
187
- return {
188
- ...base,
189
- content_hash: hash ?? null,
190
- state: "committed",
191
- reason: "committed_in_ledger",
192
- decided_at: committedAt,
193
- age_ms: ageMs(committedAt, options.now),
194
- };
195
- }
196
- if (hash) {
197
- const reconciled = options.reconciled.get(hash);
198
- if (reconciled) {
199
- return classifyFromReconcileAnswer(base, hash, reconciled, options.now);
200
- }
201
- }
202
- const unknownReason = unknownStateReason(hash, manifest, stagedAt, options);
203
- return {
204
- ...base,
205
- content_hash: hash ?? null,
206
- state: unknownReason ? "unknown" : "uncommitted",
207
- reason: unknownReason ?? "absent_from_ledger",
208
- decided_at: stagedAt,
209
- age_ms: ageMs(stagedAt, options.now),
210
- };
211
- }
212
- /**
213
- * The server's own answer for this hash, already on disk from an earlier
214
- * `cockpit clean --reconcile` run. This takes priority over
215
- * `unknownStateReason`'s guesswork about the LOCAL ledger's memory, because
216
- * the server was actually asked and actually answered.
217
- */
218
- function classifyFromReconcileAnswer(base, hash, reconciled, now) {
219
- if (reconciled.verdict === "committed") {
220
- const decidedAt = reconciled.server_committed_at ?? reconciled.checked_at;
221
- return {
222
- ...base,
223
- content_hash: hash,
224
- state: "committed",
225
- reason: "reconciled_committed",
226
- decided_at: decidedAt,
227
- age_ms: ageMs(decidedAt, now),
228
- };
229
- }
230
- return {
231
- ...base,
232
- content_hash: hash,
233
- state: "uncommitted",
234
- reason: reconciled.verdict === "not_committed"
235
- ? "reconciled_not_committed"
236
- : "reconciled_unknown_to_server",
237
- decided_at: reconciled.checked_at,
238
- age_ms: ageMs(reconciled.checked_at, now),
239
- };
240
- }
241
- /**
242
- * Why the ledger's silence about this object is NOT the same as a "no".
243
48
  *
244
- * The `objects` map is capped at 5,000 rows and drops the oldest, so anything
245
- * staged before the oldest row it still holds is outside its memory. Saying
246
- * "uncommitted" there would be a claim the ledger never made.
247
- */
248
- function unknownStateReason(hash, manifest, stagedAt, options) {
249
- if (!manifest)
250
- return "manifest_unreadable";
251
- if (!hash)
252
- return "no_manifest_row";
253
- const reachesBackTo = options.ledger.reachesBackTo;
254
- if (!reachesBackTo)
255
- return "ledger_empty";
256
- if (stagedAt && stagedAt.localeCompare(reachesBackTo) < 0) {
257
- return "ledger_evicted";
258
- }
259
- if (!stagedAt)
260
- return "ledger_evicted";
261
- return null;
262
- }
263
- /**
264
- * A file its own manifest does not name gets hashed — but only while the run's
265
- * byte budget lasts. Past it the object stays `unknown` and says which budget
266
- * ran out, because reading 20 GB to answer a housekeeping question is how the
267
- * old GC earned its once-a-day throttle.
268
- */
269
- async function hashUnnamedFile(file, size, budget) {
270
- if (size > budget.remaining)
271
- return null;
272
- const bytes = await fs.readFile(file).catch(() => null);
273
- if (!bytes)
274
- return null;
275
- budget.remaining -= bytes.byteLength;
276
- return crypto.createHash("sha256").update(bytes).digest("hex");
277
- }
278
- async function readManifestIndex(dir) {
279
- const raw = await fs
280
- .readFile(path.join(dir, "manifest.json"), "utf8")
281
- .catch(() => null);
282
- if (!raw)
283
- return null;
284
- try {
285
- const parsed = JSON.parse(raw);
286
- const index = new Map();
287
- for (const file of parsed.files ?? []) {
288
- if (typeof file.relative_path === "string" &&
289
- typeof file.content_hash_sha256 === "string" &&
290
- file.content_hash_sha256.length === 64) {
291
- index.set(file.relative_path, file.content_hash_sha256);
292
- }
293
- }
294
- return index;
295
- }
296
- catch (error) {
297
- // Named, not swallowed: an unreadable manifest turns every file in the
298
- // pack into `unknown`, which is the difference between a pack that gets
299
- // pruned and one that is kept forever (BLI-3238's rule, BLI-3619's cost).
300
- console.error("[collector disk] pack manifest unreadable, its files cannot be classified", JSON.stringify({
301
- reason: "manifest_unreadable",
302
- pack_id: path.basename(dir),
303
- ...describeError(error),
304
- }));
305
- return null;
306
- }
307
- }
308
- async function listPackFiles(dir) {
309
- const files = [];
310
- const stack = [{ dir, prefix: "" }];
311
- while (stack.length > 0) {
312
- const current = stack.pop();
313
- if (!current)
314
- continue;
315
- const entries = await fs
316
- .readdir(current.dir, { withFileTypes: true })
317
- .catch(() => []);
318
- for (const entry of entries) {
319
- const relative = current.prefix
320
- ? `${current.prefix}/${entry.name}`
321
- : entry.name;
322
- if (entry.isDirectory()) {
323
- stack.push({ dir: path.join(current.dir, entry.name), prefix: relative });
324
- }
325
- else if (entry.isFile()) {
326
- files.push(relative);
327
- }
328
- }
329
- }
330
- return files;
331
- }
332
- async function readManualVaults(paths) {
333
- const entries = await fs
334
- .readdir(paths.state_dir, { withFileTypes: true })
335
- .catch(() => []);
336
- const vaults = [];
337
- for (const entry of entries) {
338
- if (!entry.isDirectory() || !entry.name.startsWith(MANUAL_VAULT_PREFIX)) {
339
- continue;
340
- }
341
- const dir = path.join(paths.state_dir, entry.name);
342
- vaults.push({
343
- name: entry.name,
344
- byte_size: await directoryBytes(dir),
345
- file_count: (await listPackFiles(dir)).length,
346
- });
347
- }
348
- return vaults;
349
- }
350
- export async function directoryBytes(dir) {
351
- let total = 0;
352
- for (const relative of await listPackFiles(dir)) {
353
- const info = await fs.stat(path.join(dir, relative)).catch(() => null);
354
- total += info?.isFile() ? info.size : 0;
355
- }
356
- return total;
357
- }
358
- function accumulatePack(inventory, pack) {
359
- inventory.pack_count += 1;
360
- inventory.manifest_bytes += pack.manifest_bytes;
361
- inventory.total_bytes += pack.manifest_bytes;
362
- for (const object of pack.objects) {
363
- inventory.object_count += 1;
364
- inventory.total_bytes += object.byte_size;
365
- if (object.state === "committed") {
366
- inventory.committed_count += 1;
367
- inventory.committed_bytes += object.byte_size;
368
- continue;
369
- }
370
- if (object.state === "unknown") {
371
- inventory.unknown_count += 1;
372
- inventory.unknown_bytes += object.byte_size;
373
- continue;
374
- }
375
- inventory.uncommitted_count += 1;
376
- inventory.uncommitted_bytes += object.byte_size;
377
- tallyUncommittedReason(inventory, object);
378
- // The age of the BYTES, not of the answer about them (BLI-3797). Falls back
379
- // to `decided_at` only when the file has no readable mtime, which is the
380
- // one case where the disk itself cannot say.
381
- const stagedAt = object.staged_on_disk_at ?? object.decided_at;
382
- if (stagedAt &&
383
- (!inventory.oldest_uncommitted_at ||
384
- stagedAt.localeCompare(inventory.oldest_uncommitted_at) < 0)) {
385
- inventory.oldest_uncommitted_at = stagedAt;
386
- }
387
- }
388
- }
389
- /**
390
- * One more object against its reason label. Kept as a mutable array rather than
391
- * a Map so the inventory stays a plain serializable value — it rides `--json`
392
- * output and a receipt, and a Map would silently become `{}` in both.
393
- */
394
- function tallyUncommittedReason(inventory, object) {
395
- const existing = inventory.uncommitted_reasons.find((tally) => tally.reason === object.reason);
396
- const tally = existing ?? {
397
- reason: object.reason,
398
- count: 0,
399
- bytes: 0,
400
- oldest_disk_age_ms: 0,
401
- };
402
- tally.count += 1;
403
- tally.bytes += object.byte_size;
404
- tally.oldest_disk_age_ms = Math.max(tally.oldest_disk_age_ms, object.disk_age_ms || object.age_ms);
405
- if (!existing)
406
- inventory.uncommitted_reasons.push(tally);
407
- }
408
- /**
409
- * Rank the reasons by BYTES, then name the biggest one.
49
+ * This file is the table of contents and holds no logic (BLI-3979). Each
50
+ * responsibility lives in a named sibling, and every public name below is
51
+ * still importable from `./disk-usage.js`, which is what the prune, the
52
+ * retention plan, `cockpit clean`, `cockpit status`, `cockpit doctor`, the
53
+ * heartbeat and the redelivery plan all depend on:
54
+ *
55
+ * - `disk-usage-facts.ts` — the shapes: one staged object and its reason
56
+ * label, one pack, the inventory, a manual vault, the whole footprint, plus
57
+ * the two directory names that decide which folders are looked at.
58
+ * - `disk-usage-ledger.ts` — the three records an object is classified
59
+ * against, read once per run: the commit ledger and how far back its capped
60
+ * memory reaches, the recorded server answers, and when each object was
61
+ * staged.
62
+ * - `disk-usage-classify.ts` — the three-state question per file, in the order
63
+ * that makes it safe: this machine's ledger, then a server answer, then the
64
+ * named reason its silence has.
65
+ * - `disk-usage-files.ts` — the only code here that touches a file for its own
66
+ * sake: the pack walk, directory bytes, the manifest index, and the budgeted
67
+ * hash of a file no manifest names.
68
+ * - `disk-usage-totals.ts` — classified objects into the numbers a person
69
+ * reads: per-state totals, the reason ranking by BYTES, and the oldest
70
+ * undelivered bytes' own age.
71
+ * - `disk-usage-scan.ts` the staging walk itself: `readStagingInventory`,
72
+ * one pack at a time.
73
+ * - `disk-usage-footprint.ts` — the whole-machine read: staging plus rotated
74
+ * logs, the spool and the hand-made vaults.
410
75
  *
411
- * Bytes, not object count, because the question the ranking answers is "what is
412
- * holding this laptop over its cap" 234 objects at 1,836 MB and 17 at 81 MB
413
- * are not the same finding, and counting rows would have called them close.
76
+ * A new sibling here must also join `scripts/build-public-cli.mjs`
77
+ * `runtimeFiles`, or the repo tests stay green while the packed CLI breaks.
414
78
  */
415
- function rankUncommittedReasons(inventory) {
416
- inventory.uncommitted_reasons.sort((left, right) => right.bytes - left.bytes || right.count - left.count);
417
- inventory.dominant_uncommitted_reason =
418
- inventory.uncommitted_reasons[0] ?? null;
419
- }
420
- function emptyStagingInventory() {
421
- return {
422
- pack_count: 0,
423
- object_count: 0,
424
- total_bytes: 0,
425
- committed_count: 0,
426
- committed_bytes: 0,
427
- uncommitted_count: 0,
428
- uncommitted_bytes: 0,
429
- oldest_uncommitted_at: null,
430
- uncommitted_reasons: [],
431
- dominant_uncommitted_reason: null,
432
- unknown_count: 0,
433
- unknown_bytes: 0,
434
- manifest_bytes: 0,
435
- orphan_staging_dirs: 0,
436
- orphan_staging_bytes: 0,
437
- packs: [],
438
- };
439
- }
440
- function ageMs(at, now) {
441
- if (!at)
442
- return 0;
443
- const parsed = Date.parse(at);
444
- return Number.isFinite(parsed) ? Math.max(0, now.getTime() - parsed) : 0;
445
- }
79
+ export { MANUAL_VAULT_PREFIX, RAW_EVIDENCE_DIR } from "./disk-usage-facts.js";
80
+ export { directoryBytes, UNNAMED_FILE_HASH_BUDGET_BYTES, } from "./disk-usage-files.js";
81
+ export { readStagingInventory } from "./disk-usage-scan.js";
82
+ export { readDiskFootprint } from "./disk-usage-footprint.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bli-cockpit/cli",
3
- "version": "0.2.94",
3
+ "version": "0.2.95",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "bin": {
@@ -27,8 +27,8 @@
27
27
  "test": "node dist/cli.js --help && node ../../scripts/assert-public-cli-routing.mjs && node ../../scripts/assert-public-cli-verb-help.mjs && node ../../scripts/assert-public-cli-runtime-files.mjs && node ../../scripts/assert-public-cli-no-fleet-posts.mjs && node ../../scripts/assert-public-package-pack.mjs --workspace=@bli-cockpit/cli"
28
28
  },
29
29
  "dependencies": {
30
- "@bli-cockpit/memory-mcp": "0.1.24",
31
- "@bli-cockpit/mcp": "0.1.27",
32
- "@bli-cockpit/telemetry-core": "0.1.41"
30
+ "@bli-cockpit/memory-mcp": "0.1.25",
31
+ "@bli-cockpit/mcp": "0.1.28",
32
+ "@bli-cockpit/telemetry-core": "0.1.42"
33
33
  }
34
34
  }