@bli-cockpit/cli 0.2.50 → 0.2.52
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/dist/commands/clean.js +191 -0
- package/dist/commands/doctor.js +68 -0
- package/dist/commands/jarvis.js +101 -9
- package/dist/commands/local-args-collector.js +21 -0
- package/dist/commands/local-args.js +3 -1
- package/dist/commands/local-help.js +18 -0
- package/dist/commands/local.js +3 -0
- package/dist/commands/memory-install-contract.js +55 -0
- package/dist/commands/memory-install.js +113 -26
- package/dist/commands/public-root.js +1 -1
- package/dist/commands/sync-followups.js +95 -2
- package/dist/commands/sync.js +4 -1
- package/dist/disk-prune.js +246 -0
- package/dist/disk-retention.js +157 -0
- package/dist/disk-usage.js +337 -0
- package/dist/log-rotation.js +106 -2
- package/dist/tower-stream.js +5 -1
- package/package.json +2 -2
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/** How long a committed spare is kept so a retry still has local bytes. */
|
|
2
|
+
export const DEFAULT_STAGING_RETENTION_MS = 48 * 60 * 60 * 1000;
|
|
3
|
+
/** Ceiling on everything under `raw-evidence`, manifests included. */
|
|
4
|
+
export const DEFAULT_STAGING_CAP_BYTES = 2 * 1024 * 1024 * 1024;
|
|
5
|
+
/** Local knobs, read from the environment like `COCKPIT_DISABLE_GC`. */
|
|
6
|
+
export const STAGING_CAP_ENV = "COCKPIT_STAGING_CAP_BYTES";
|
|
7
|
+
export const STAGING_RETENTION_ENV = "COCKPIT_STAGING_RETENTION_HOURS";
|
|
8
|
+
/** The two knobs, from the environment, falling back to the defaults above. */
|
|
9
|
+
export function retentionOptionsFromEnv(env) {
|
|
10
|
+
const hours = Number(env[STAGING_RETENTION_ENV]);
|
|
11
|
+
const cap = Number(env[STAGING_CAP_ENV]);
|
|
12
|
+
return {
|
|
13
|
+
retentionMs: Number.isFinite(hours) && hours >= 0
|
|
14
|
+
? hours * 60 * 60 * 1000
|
|
15
|
+
: DEFAULT_STAGING_RETENTION_MS,
|
|
16
|
+
capBytes: Number.isFinite(cap) && cap > 0 ? cap : DEFAULT_STAGING_CAP_BYTES,
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
export function planStagingRetention(inventory, options = {}) {
|
|
20
|
+
const retentionMs = options.retentionMs ?? DEFAULT_STAGING_RETENTION_MS;
|
|
21
|
+
const capBytes = options.capBytes ?? DEFAULT_STAGING_CAP_BYTES;
|
|
22
|
+
const plan = emptyPlan(capBytes, retentionMs, inventory.total_bytes);
|
|
23
|
+
const committedInWindow = [];
|
|
24
|
+
for (const pack of inventory.packs) {
|
|
25
|
+
for (const object of pack.objects) {
|
|
26
|
+
if (object.state !== "committed") {
|
|
27
|
+
keepUnvouched(plan, object);
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
if (options.allCommitted || object.age_ms >= retentionMs) {
|
|
31
|
+
pushDeletion(plan, object, "committed_past_retry_window");
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
committedInWindow.push(object);
|
|
35
|
+
addToBucket(plan.kept_in_window, object);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
applyByteCap(plan, committedInWindow, capBytes, inventory);
|
|
39
|
+
markEmptyPacks(plan, inventory.packs);
|
|
40
|
+
plan.bytes_after =
|
|
41
|
+
inventory.total_bytes - plan.deleted_bytes - plan.empty_pack_manifest_bytes;
|
|
42
|
+
// Over the cap is only "blocked by uncommitted" when uncommitted bytes are
|
|
43
|
+
// what is left. Over the cap with nothing unvouched on disk is a different
|
|
44
|
+
// sentence — the remaining manifests alone exceed it — and saying the wrong
|
|
45
|
+
// one would send an operator hunting evidence that is not missing.
|
|
46
|
+
const unvouched = plan.kept_uncommitted.count + plan.kept_unknown.count;
|
|
47
|
+
plan.cap_blocked_by_uncommitted = plan.bytes_after > capBytes && unvouched > 0;
|
|
48
|
+
plan.cap_blocked_count = plan.cap_blocked_by_uncommitted ? unvouched : 0;
|
|
49
|
+
return plan;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* The oldest committed spares go until the total fits, newest kept last. Sorted
|
|
53
|
+
* by the ledger's `committed_at` age rather than a file mtime: mtime moves when
|
|
54
|
+
* a pack is refilled, and the question here is how long ago the bytes became
|
|
55
|
+
* safe elsewhere.
|
|
56
|
+
*
|
|
57
|
+
* It does not run at all when the floor is already above the cap. That branch
|
|
58
|
+
* is not theoretical: on the reference Mac (2026-09-05) 8.0 GB of staging is
|
|
59
|
+
* evidence no ledger vouches for, against a 2 GB cap, so spending the retry
|
|
60
|
+
* window on 1,027 committed spares would have deleted a day of local retry
|
|
61
|
+
* copies and still ended over the cap. The cap takes a spare only when taking
|
|
62
|
+
* it can actually get this machine under the cap.
|
|
63
|
+
*/
|
|
64
|
+
function applyByteCap(plan, committedInWindow, capBytes, inventory) {
|
|
65
|
+
let projected = plan.bytes_before - plan.deleted_bytes;
|
|
66
|
+
if (projected <= capBytes)
|
|
67
|
+
return;
|
|
68
|
+
if (unremovableFloor(inventory) >= capBytes)
|
|
69
|
+
return;
|
|
70
|
+
const oldestFirst = [...committedInWindow].sort((a, b) => b.age_ms - a.age_ms);
|
|
71
|
+
for (const object of oldestFirst) {
|
|
72
|
+
if (projected <= capBytes)
|
|
73
|
+
break;
|
|
74
|
+
pushDeletion(plan, object, "cap_committed_oldest_first");
|
|
75
|
+
removeFromBucket(plan.kept_in_window, object);
|
|
76
|
+
projected -= object.byte_size;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Bytes no retention rule may take: every object the ledger does not vouch for,
|
|
81
|
+
* plus the `manifest.json` of each pack holding one, because that pack survives
|
|
82
|
+
* whatever else is deleted from it.
|
|
83
|
+
*/
|
|
84
|
+
function unremovableFloor(inventory) {
|
|
85
|
+
let floor = 0;
|
|
86
|
+
for (const pack of inventory.packs) {
|
|
87
|
+
const unvouched = pack.objects.filter((object) => object.state !== "committed");
|
|
88
|
+
if (unvouched.length === 0)
|
|
89
|
+
continue;
|
|
90
|
+
floor += pack.manifest_bytes;
|
|
91
|
+
for (const object of unvouched)
|
|
92
|
+
floor += object.byte_size;
|
|
93
|
+
}
|
|
94
|
+
return floor;
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* A pack with no payload left is deleted whole, `manifest.json` included. The
|
|
98
|
+
* manifest is pack metadata — its only job is to describe files that are no
|
|
99
|
+
* longer there — and on the reference Mac those manifests were 836 MB across
|
|
100
|
+
* 1,612 packs, so leaving them behind would keep almost a gigabyte of
|
|
101
|
+
* descriptions of nothing.
|
|
102
|
+
*/
|
|
103
|
+
function markEmptyPacks(plan, packs) {
|
|
104
|
+
const deleted = new Set(plan.delete.map((entry) => `${entry.pack_id}/${entry.relative_path}`));
|
|
105
|
+
for (const pack of packs) {
|
|
106
|
+
const survives = pack.objects.some((object) => !deleted.has(`${pack.pack_id}/${object.relative_path}`));
|
|
107
|
+
if (survives)
|
|
108
|
+
continue;
|
|
109
|
+
plan.empty_packs.push({
|
|
110
|
+
pack_id: pack.pack_id,
|
|
111
|
+
manifest_bytes: pack.manifest_bytes,
|
|
112
|
+
});
|
|
113
|
+
plan.empty_pack_manifest_bytes += pack.manifest_bytes;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
function keepUnvouched(plan, object) {
|
|
117
|
+
const bucket = object.state === "unknown" ? plan.kept_unknown : plan.kept_uncommitted;
|
|
118
|
+
addToBucket(bucket, object);
|
|
119
|
+
}
|
|
120
|
+
function pushDeletion(plan, object, reason) {
|
|
121
|
+
plan.delete.push({
|
|
122
|
+
pack_id: object.pack_id,
|
|
123
|
+
relative_path: object.relative_path,
|
|
124
|
+
byte_size: object.byte_size,
|
|
125
|
+
content_hash: object.content_hash,
|
|
126
|
+
reason,
|
|
127
|
+
age_ms: object.age_ms,
|
|
128
|
+
});
|
|
129
|
+
plan.deleted_bytes += object.byte_size;
|
|
130
|
+
}
|
|
131
|
+
function addToBucket(bucket, object) {
|
|
132
|
+
bucket.count += 1;
|
|
133
|
+
bucket.bytes += object.byte_size;
|
|
134
|
+
bucket.oldest_age_ms = Math.max(bucket.oldest_age_ms, object.age_ms);
|
|
135
|
+
}
|
|
136
|
+
function removeFromBucket(bucket, object) {
|
|
137
|
+
bucket.count -= 1;
|
|
138
|
+
bucket.bytes -= object.byte_size;
|
|
139
|
+
}
|
|
140
|
+
function emptyPlan(capBytes, retentionMs, bytesBefore) {
|
|
141
|
+
const bucket = () => ({ count: 0, bytes: 0, oldest_age_ms: 0 });
|
|
142
|
+
return {
|
|
143
|
+
delete: [],
|
|
144
|
+
deleted_bytes: 0,
|
|
145
|
+
empty_packs: [],
|
|
146
|
+
empty_pack_manifest_bytes: 0,
|
|
147
|
+
kept_uncommitted: bucket(),
|
|
148
|
+
kept_unknown: bucket(),
|
|
149
|
+
kept_in_window: bucket(),
|
|
150
|
+
cap_bytes: capBytes,
|
|
151
|
+
retention_ms: retentionMs,
|
|
152
|
+
bytes_before: bytesBefore,
|
|
153
|
+
bytes_after: bytesBefore,
|
|
154
|
+
cap_blocked_by_uncommitted: false,
|
|
155
|
+
cap_blocked_count: 0,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What this laptop is actually holding, and which of it the upload ledger can
|
|
3
|
+
* vouch for.
|
|
4
|
+
*
|
|
5
|
+
* BLI-3619. Measured on the reference Mac 2026-09-05, not estimated:
|
|
6
|
+
* `~/.local/state/bli-cockpit/raw-evidence` held 1,612 pack directories,
|
|
7
|
+
* 5,776 payload files and 18.76 GB, plus 836 MB of `manifest.json` beside them
|
|
8
|
+
* — 20 GB of staging on a machine whose whole job is to upload it and move on.
|
|
9
|
+
* The collector staged raw evidence, uploaded it, committed it, and then kept
|
|
10
|
+
* the local copy forever.
|
|
11
|
+
*
|
|
12
|
+
* Nothing here deletes anything. This module answers ONE question per file —
|
|
13
|
+
* "does a ledger on this machine say these bytes are durable remotely?" — and
|
|
14
|
+
* the answer has three values, not two, because the honest third one is what
|
|
15
|
+
* the same probe turned up:
|
|
16
|
+
*
|
|
17
|
+
* committed the content hash is in `cursors/raw-evidence.json`
|
|
18
|
+
* `objects`, so it is durable remotely and the local
|
|
19
|
+
* copy is a spare.
|
|
20
|
+
* uncommitted the hash is absent AND the ledger's own memory
|
|
21
|
+
* reaches back past this object, so the ledger CAN
|
|
22
|
+
* speak about it and says no. Never deleted.
|
|
23
|
+
* unknown the ledger cannot answer: its `objects` map is
|
|
24
|
+
* capped at 5,000 rows and prunes the oldest, so on
|
|
25
|
+
* the reference Mac it only reached back to
|
|
26
|
+
* 2026-08-28 while 518 packs (7.93 GB) predate that.
|
|
27
|
+
* Also never deleted — but counted apart, because
|
|
28
|
+
* 7.93 GB of "I forgot" must not read as 7.93 GB of
|
|
29
|
+
* "it never landed".
|
|
30
|
+
*
|
|
31
|
+
* Content hashes come from each pack's own `manifest.json`, which already
|
|
32
|
+
* records `relative_path` + `content_hash_sha256` + `byte_size` for every file
|
|
33
|
+
* it staged. That is why a daily inventory does not re-read 20 GB. A physical
|
|
34
|
+
* file its own manifest does not name is hashed instead, under a byte budget
|
|
35
|
+
* (3 such files, 37 MB, on the reference Mac); past the budget it stays
|
|
36
|
+
* `unknown` with a reason rather than being guessed at.
|
|
37
|
+
*
|
|
38
|
+
* Metadata only: pack ids, pack-relative paths, byte counts, hashes, reason
|
|
39
|
+
* labels. Never a person's absolute path and never a byte of content.
|
|
40
|
+
*/
|
|
41
|
+
import crypto from "node:crypto";
|
|
42
|
+
import fs from "node:fs/promises";
|
|
43
|
+
import path from "node:path";
|
|
44
|
+
import { readRawEvidenceCursor } from "./cursors/raw-evidence-cursor.js";
|
|
45
|
+
import { describeError } from "./health-detail.js";
|
|
46
|
+
import { rotatedLogFootprint } from "./log-rotation.js";
|
|
47
|
+
import { readRawEvidenceStagingState } from "./raw-evidence-staging.js";
|
|
48
|
+
/** Bytes this inventory may spend hashing files no manifest names. */
|
|
49
|
+
export const UNNAMED_FILE_HASH_BUDGET_BYTES = 256 * 1024 * 1024;
|
|
50
|
+
/** Directory name of the staged raw-evidence tree under the state directory. */
|
|
51
|
+
export const RAW_EVIDENCE_DIR = "raw-evidence";
|
|
52
|
+
/** One-off evidence vaults a person made by hand; named, never auto-deleted. */
|
|
53
|
+
export const MANUAL_VAULT_PREFIX = "manual-study-evidence-vault-";
|
|
54
|
+
/**
|
|
55
|
+
* Everything on this machine Cockpit put there, in one read. Used by the sync
|
|
56
|
+
* tick's prune, by `cockpit clean` and by `cockpit doctor`'s disk row, so all
|
|
57
|
+
* three quote the same numbers rather than three near-misses.
|
|
58
|
+
*/
|
|
59
|
+
export async function readDiskFootprint(paths, now = new Date()) {
|
|
60
|
+
return {
|
|
61
|
+
staging: await readStagingInventory(paths, now),
|
|
62
|
+
logs: await rotatedLogFootprint(paths),
|
|
63
|
+
spool_bytes: await directoryBytes(paths.spool_dir),
|
|
64
|
+
vaults: await readManualVaults(paths),
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
export async function readStagingInventory(paths, now = new Date()) {
|
|
68
|
+
const root = path.join(paths.state_dir, RAW_EVIDENCE_DIR);
|
|
69
|
+
const ledger = await readCommitLedger(paths);
|
|
70
|
+
const stagedAt = await readStagedAtIndex(paths);
|
|
71
|
+
const entries = await fs
|
|
72
|
+
.readdir(root, { withFileTypes: true })
|
|
73
|
+
.catch(() => []);
|
|
74
|
+
const inventory = emptyStagingInventory();
|
|
75
|
+
const budget = { remaining: UNNAMED_FILE_HASH_BUDGET_BYTES };
|
|
76
|
+
for (const entry of entries) {
|
|
77
|
+
if (!entry.isDirectory())
|
|
78
|
+
continue;
|
|
79
|
+
const dir = path.join(root, entry.name);
|
|
80
|
+
if (entry.name.startsWith(".staging-")) {
|
|
81
|
+
inventory.orphan_staging_dirs += 1;
|
|
82
|
+
inventory.orphan_staging_bytes += await directoryBytes(dir);
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
if (!entry.name.startsWith("work-"))
|
|
86
|
+
continue;
|
|
87
|
+
const pack = await readPack(dir, entry.name, {
|
|
88
|
+
ledger,
|
|
89
|
+
stagedAt,
|
|
90
|
+
now,
|
|
91
|
+
budget,
|
|
92
|
+
});
|
|
93
|
+
inventory.packs.push(pack);
|
|
94
|
+
accumulatePack(inventory, pack);
|
|
95
|
+
}
|
|
96
|
+
return inventory;
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* The commit record, read from the one place that writes it: the raw-evidence
|
|
100
|
+
* cursor's `objects` map (`upload.ts` → `markObjectCommitted`, only ever for an
|
|
101
|
+
* outcome that was not `upload_failed`).
|
|
102
|
+
*/
|
|
103
|
+
async function readCommitLedger(paths) {
|
|
104
|
+
const cursor = await readRawEvidenceCursor(paths);
|
|
105
|
+
const committed = new Map();
|
|
106
|
+
let oldest = null;
|
|
107
|
+
for (const [hash, entry] of Object.entries(cursor.objects)) {
|
|
108
|
+
committed.set(hash, entry.committed_at);
|
|
109
|
+
if (!oldest || entry.committed_at.localeCompare(oldest) < 0) {
|
|
110
|
+
oldest = entry.committed_at;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return { committed, reachesBackTo: oldest };
|
|
114
|
+
}
|
|
115
|
+
/** `pack_id/relative_path` → when this machine staged those bytes. */
|
|
116
|
+
async function readStagedAtIndex(paths) {
|
|
117
|
+
const staging = await readRawEvidenceStagingState(paths.state_dir);
|
|
118
|
+
const index = new Map();
|
|
119
|
+
for (const entry of Object.values(staging.staged)) {
|
|
120
|
+
index.set(`${entry.pack_id}/${entry.relative_path}`, entry.staged_at);
|
|
121
|
+
}
|
|
122
|
+
return index;
|
|
123
|
+
}
|
|
124
|
+
async function readPack(dir, packId, options) {
|
|
125
|
+
const manifest = await readManifestIndex(dir);
|
|
126
|
+
const pack = {
|
|
127
|
+
pack_id: packId,
|
|
128
|
+
objects: [],
|
|
129
|
+
manifest_bytes: 0,
|
|
130
|
+
manifest_present: manifest !== null,
|
|
131
|
+
};
|
|
132
|
+
for (const relative of await listPackFiles(dir)) {
|
|
133
|
+
const size = await fs
|
|
134
|
+
.stat(path.join(dir, relative))
|
|
135
|
+
.then((info) => (info.isFile() ? info.size : 0))
|
|
136
|
+
.catch(() => 0);
|
|
137
|
+
if (relative === "manifest.json") {
|
|
138
|
+
pack.manifest_bytes += size;
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
pack.objects.push(await classifyObject(dir, packId, relative, size, manifest, options));
|
|
142
|
+
}
|
|
143
|
+
return pack;
|
|
144
|
+
}
|
|
145
|
+
async function classifyObject(dir, packId, relative, size, manifest, options) {
|
|
146
|
+
const hash = manifest?.get(relative) ??
|
|
147
|
+
(await hashUnnamedFile(path.join(dir, relative), size, options.budget));
|
|
148
|
+
const stagedAt = options.stagedAt.get(`${packId}/${relative}`) ?? null;
|
|
149
|
+
const committedAt = hash ? options.ledger.committed.get(hash) : undefined;
|
|
150
|
+
const base = { pack_id: packId, relative_path: relative, byte_size: size };
|
|
151
|
+
if (committedAt) {
|
|
152
|
+
return {
|
|
153
|
+
...base,
|
|
154
|
+
content_hash: hash ?? null,
|
|
155
|
+
state: "committed",
|
|
156
|
+
reason: "committed_in_ledger",
|
|
157
|
+
decided_at: committedAt,
|
|
158
|
+
age_ms: ageMs(committedAt, options.now),
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
const unknownReason = unknownStateReason(hash, manifest, stagedAt, options);
|
|
162
|
+
return {
|
|
163
|
+
...base,
|
|
164
|
+
content_hash: hash ?? null,
|
|
165
|
+
state: unknownReason ? "unknown" : "uncommitted",
|
|
166
|
+
reason: unknownReason ?? "absent_from_ledger",
|
|
167
|
+
decided_at: stagedAt,
|
|
168
|
+
age_ms: ageMs(stagedAt, options.now),
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* Why the ledger's silence about this object is NOT the same as a "no".
|
|
173
|
+
*
|
|
174
|
+
* The `objects` map is capped at 5,000 rows and drops the oldest, so anything
|
|
175
|
+
* staged before the oldest row it still holds is outside its memory. Saying
|
|
176
|
+
* "uncommitted" there would be a claim the ledger never made.
|
|
177
|
+
*/
|
|
178
|
+
function unknownStateReason(hash, manifest, stagedAt, options) {
|
|
179
|
+
if (!manifest)
|
|
180
|
+
return "manifest_unreadable";
|
|
181
|
+
if (!hash)
|
|
182
|
+
return "no_manifest_row";
|
|
183
|
+
const reachesBackTo = options.ledger.reachesBackTo;
|
|
184
|
+
if (!reachesBackTo)
|
|
185
|
+
return "ledger_empty";
|
|
186
|
+
if (stagedAt && stagedAt.localeCompare(reachesBackTo) < 0) {
|
|
187
|
+
return "ledger_evicted";
|
|
188
|
+
}
|
|
189
|
+
if (!stagedAt)
|
|
190
|
+
return "ledger_evicted";
|
|
191
|
+
return null;
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* A file its own manifest does not name gets hashed — but only while the run's
|
|
195
|
+
* byte budget lasts. Past it the object stays `unknown` and says which budget
|
|
196
|
+
* ran out, because reading 20 GB to answer a housekeeping question is how the
|
|
197
|
+
* old GC earned its once-a-day throttle.
|
|
198
|
+
*/
|
|
199
|
+
async function hashUnnamedFile(file, size, budget) {
|
|
200
|
+
if (size > budget.remaining)
|
|
201
|
+
return null;
|
|
202
|
+
const bytes = await fs.readFile(file).catch(() => null);
|
|
203
|
+
if (!bytes)
|
|
204
|
+
return null;
|
|
205
|
+
budget.remaining -= bytes.byteLength;
|
|
206
|
+
return crypto.createHash("sha256").update(bytes).digest("hex");
|
|
207
|
+
}
|
|
208
|
+
async function readManifestIndex(dir) {
|
|
209
|
+
const raw = await fs
|
|
210
|
+
.readFile(path.join(dir, "manifest.json"), "utf8")
|
|
211
|
+
.catch(() => null);
|
|
212
|
+
if (!raw)
|
|
213
|
+
return null;
|
|
214
|
+
try {
|
|
215
|
+
const parsed = JSON.parse(raw);
|
|
216
|
+
const index = new Map();
|
|
217
|
+
for (const file of parsed.files ?? []) {
|
|
218
|
+
if (typeof file.relative_path === "string" &&
|
|
219
|
+
typeof file.content_hash_sha256 === "string" &&
|
|
220
|
+
file.content_hash_sha256.length === 64) {
|
|
221
|
+
index.set(file.relative_path, file.content_hash_sha256);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
return index;
|
|
225
|
+
}
|
|
226
|
+
catch (error) {
|
|
227
|
+
// Named, not swallowed: an unreadable manifest turns every file in the
|
|
228
|
+
// pack into `unknown`, which is the difference between a pack that gets
|
|
229
|
+
// pruned and one that is kept forever (BLI-3238's rule, BLI-3619's cost).
|
|
230
|
+
console.error("[collector disk] pack manifest unreadable, its files cannot be classified", JSON.stringify({
|
|
231
|
+
reason: "manifest_unreadable",
|
|
232
|
+
pack_id: path.basename(dir),
|
|
233
|
+
...describeError(error),
|
|
234
|
+
}));
|
|
235
|
+
return null;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
async function listPackFiles(dir) {
|
|
239
|
+
const files = [];
|
|
240
|
+
const stack = [{ dir, prefix: "" }];
|
|
241
|
+
while (stack.length > 0) {
|
|
242
|
+
const current = stack.pop();
|
|
243
|
+
if (!current)
|
|
244
|
+
continue;
|
|
245
|
+
const entries = await fs
|
|
246
|
+
.readdir(current.dir, { withFileTypes: true })
|
|
247
|
+
.catch(() => []);
|
|
248
|
+
for (const entry of entries) {
|
|
249
|
+
const relative = current.prefix
|
|
250
|
+
? `${current.prefix}/${entry.name}`
|
|
251
|
+
: entry.name;
|
|
252
|
+
if (entry.isDirectory()) {
|
|
253
|
+
stack.push({ dir: path.join(current.dir, entry.name), prefix: relative });
|
|
254
|
+
}
|
|
255
|
+
else if (entry.isFile()) {
|
|
256
|
+
files.push(relative);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
return files;
|
|
261
|
+
}
|
|
262
|
+
async function readManualVaults(paths) {
|
|
263
|
+
const entries = await fs
|
|
264
|
+
.readdir(paths.state_dir, { withFileTypes: true })
|
|
265
|
+
.catch(() => []);
|
|
266
|
+
const vaults = [];
|
|
267
|
+
for (const entry of entries) {
|
|
268
|
+
if (!entry.isDirectory() || !entry.name.startsWith(MANUAL_VAULT_PREFIX)) {
|
|
269
|
+
continue;
|
|
270
|
+
}
|
|
271
|
+
const dir = path.join(paths.state_dir, entry.name);
|
|
272
|
+
vaults.push({
|
|
273
|
+
name: entry.name,
|
|
274
|
+
byte_size: await directoryBytes(dir),
|
|
275
|
+
file_count: (await listPackFiles(dir)).length,
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
return vaults;
|
|
279
|
+
}
|
|
280
|
+
export async function directoryBytes(dir) {
|
|
281
|
+
let total = 0;
|
|
282
|
+
for (const relative of await listPackFiles(dir)) {
|
|
283
|
+
const info = await fs.stat(path.join(dir, relative)).catch(() => null);
|
|
284
|
+
total += info?.isFile() ? info.size : 0;
|
|
285
|
+
}
|
|
286
|
+
return total;
|
|
287
|
+
}
|
|
288
|
+
function accumulatePack(inventory, pack) {
|
|
289
|
+
inventory.pack_count += 1;
|
|
290
|
+
inventory.manifest_bytes += pack.manifest_bytes;
|
|
291
|
+
inventory.total_bytes += pack.manifest_bytes;
|
|
292
|
+
for (const object of pack.objects) {
|
|
293
|
+
inventory.object_count += 1;
|
|
294
|
+
inventory.total_bytes += object.byte_size;
|
|
295
|
+
if (object.state === "committed") {
|
|
296
|
+
inventory.committed_count += 1;
|
|
297
|
+
inventory.committed_bytes += object.byte_size;
|
|
298
|
+
continue;
|
|
299
|
+
}
|
|
300
|
+
if (object.state === "unknown") {
|
|
301
|
+
inventory.unknown_count += 1;
|
|
302
|
+
inventory.unknown_bytes += object.byte_size;
|
|
303
|
+
continue;
|
|
304
|
+
}
|
|
305
|
+
inventory.uncommitted_count += 1;
|
|
306
|
+
inventory.uncommitted_bytes += object.byte_size;
|
|
307
|
+
if (object.decided_at &&
|
|
308
|
+
(!inventory.oldest_uncommitted_at ||
|
|
309
|
+
object.decided_at.localeCompare(inventory.oldest_uncommitted_at) < 0)) {
|
|
310
|
+
inventory.oldest_uncommitted_at = object.decided_at;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
function emptyStagingInventory() {
|
|
315
|
+
return {
|
|
316
|
+
pack_count: 0,
|
|
317
|
+
object_count: 0,
|
|
318
|
+
total_bytes: 0,
|
|
319
|
+
committed_count: 0,
|
|
320
|
+
committed_bytes: 0,
|
|
321
|
+
uncommitted_count: 0,
|
|
322
|
+
uncommitted_bytes: 0,
|
|
323
|
+
oldest_uncommitted_at: null,
|
|
324
|
+
unknown_count: 0,
|
|
325
|
+
unknown_bytes: 0,
|
|
326
|
+
manifest_bytes: 0,
|
|
327
|
+
orphan_staging_dirs: 0,
|
|
328
|
+
orphan_staging_bytes: 0,
|
|
329
|
+
packs: [],
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
function ageMs(at, now) {
|
|
333
|
+
if (!at)
|
|
334
|
+
return 0;
|
|
335
|
+
const parsed = Date.parse(at);
|
|
336
|
+
return Number.isFinite(parsed) ? Math.max(0, now.getTime() - parsed) : 0;
|
|
337
|
+
}
|
package/dist/log-rotation.js
CHANGED
|
@@ -50,7 +50,8 @@ export async function rotateCollectorLogs(paths, options = {}) {
|
|
|
50
50
|
const maxBytes = options.maxBytes ?? DEFAULT_LOG_ROTATION_MAX_BYTES;
|
|
51
51
|
const keep = options.keep ?? DEFAULT_LOG_ROTATION_KEEP;
|
|
52
52
|
const names = options.names ?? ROTATED_LOG_NAMES;
|
|
53
|
-
const result = { rotated: [], failures: [] };
|
|
53
|
+
const result = { rotated: [], failures: [], swept: [] };
|
|
54
|
+
result.swept = await sweepOrphanedLogArchives(paths, { keep, names });
|
|
54
55
|
for (const name of names) {
|
|
55
56
|
const filePath = path.join(paths.state_dir, name);
|
|
56
57
|
const size = await fs
|
|
@@ -107,18 +108,114 @@ async function rotateOne(filePath, maxBytes, keep) {
|
|
|
107
108
|
await handle.close();
|
|
108
109
|
}
|
|
109
110
|
}
|
|
111
|
+
/**
|
|
112
|
+
* Archives of the tick's own logs that the keep window does not cover.
|
|
113
|
+
*
|
|
114
|
+
* BLI-3553 promised `maxBytes x (keep + 1)` per stream as the worst case on
|
|
115
|
+
* disk. Two shapes escape it, and both were sitting on the reference Mac on
|
|
116
|
+
* 2026-09-05 (BLI-3619):
|
|
117
|
+
*
|
|
118
|
+
* - `sync.log.4` and beyond — `rotateOne` unlinks exactly `.{keep}` and renames
|
|
119
|
+
* downward, so an archive that already exists above the window is never
|
|
120
|
+
* touched again. Lowering `keep`, or a machine that once ran a build with a
|
|
121
|
+
* larger one, strands them forever.
|
|
122
|
+
* - `sync.log.tail-20260825` / `sync.err.log.tail-20260825`, 7 MB — a hand
|
|
123
|
+
* rotation from before this module existed. They are tails of the same two
|
|
124
|
+
* streams, superseded by `.1`…`.3`, and nothing in the codebase writes or
|
|
125
|
+
* reads them.
|
|
126
|
+
*
|
|
127
|
+
* Each removal is named with its own byte count, because "the cap works" and
|
|
128
|
+
* "the cap works and also quietly deleted a file you made" are different
|
|
129
|
+
* sentences and an operator is owed the second one.
|
|
130
|
+
*/
|
|
131
|
+
export async function sweepOrphanedLogArchives(paths, options) {
|
|
132
|
+
const entries = await fs
|
|
133
|
+
.readdir(paths.state_dir, { withFileTypes: true })
|
|
134
|
+
.catch(() => []);
|
|
135
|
+
const swept = [];
|
|
136
|
+
for (const entry of entries) {
|
|
137
|
+
if (!entry.isFile())
|
|
138
|
+
continue;
|
|
139
|
+
const reason = orphanArchiveReason(entry.name, options);
|
|
140
|
+
if (!reason)
|
|
141
|
+
continue;
|
|
142
|
+
const file = path.join(paths.state_dir, entry.name);
|
|
143
|
+
const size = await fs
|
|
144
|
+
.stat(file)
|
|
145
|
+
.then((info) => info.size)
|
|
146
|
+
.catch(() => 0);
|
|
147
|
+
const removed = await fs.rm(file, { force: true }).then(() => true, () => false);
|
|
148
|
+
if (!removed)
|
|
149
|
+
continue;
|
|
150
|
+
swept.push({ name: entry.name, byte_size: size, reason });
|
|
151
|
+
}
|
|
152
|
+
return swept;
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Which stream this file belongs to and why it is outside the keep window, or
|
|
156
|
+
* null when the rotation already accounts for it. Exact suffix matching only —
|
|
157
|
+
* `sync.log` never matches `sync.err.log`, and neither ever matches a file the
|
|
158
|
+
* collector did not write.
|
|
159
|
+
*/
|
|
160
|
+
function orphanArchiveReason(name, options) {
|
|
161
|
+
for (const stream of options.names) {
|
|
162
|
+
if (!name.startsWith(`${stream}.`))
|
|
163
|
+
continue;
|
|
164
|
+
const suffix = name.slice(stream.length + 1);
|
|
165
|
+
if (suffix.startsWith("tail-"))
|
|
166
|
+
return "manual_tail_archive";
|
|
167
|
+
if (!/^\d+$/u.test(suffix))
|
|
168
|
+
continue;
|
|
169
|
+
return Number(suffix) > options.keep ? "archive_beyond_keep" : null;
|
|
170
|
+
}
|
|
171
|
+
return null;
|
|
172
|
+
}
|
|
173
|
+
/** Live logs plus every archive beside them, for the disk row and `clean`. */
|
|
174
|
+
export async function rotatedLogFootprint(paths, names = ROTATED_LOG_NAMES) {
|
|
175
|
+
const entries = await fs
|
|
176
|
+
.readdir(paths.state_dir, { withFileTypes: true })
|
|
177
|
+
.catch(() => []);
|
|
178
|
+
const footprint = {
|
|
179
|
+
live_bytes: 0,
|
|
180
|
+
archive_bytes: 0,
|
|
181
|
+
archive_count: 0,
|
|
182
|
+
total_bytes: 0,
|
|
183
|
+
};
|
|
184
|
+
for (const entry of entries) {
|
|
185
|
+
if (!entry.isFile())
|
|
186
|
+
continue;
|
|
187
|
+
const live = names.includes(entry.name);
|
|
188
|
+
const archive = names.some((stream) => entry.name.startsWith(`${stream}.`));
|
|
189
|
+
if (!live && !archive)
|
|
190
|
+
continue;
|
|
191
|
+
const size = await fs
|
|
192
|
+
.stat(path.join(paths.state_dir, entry.name))
|
|
193
|
+
.then((info) => info.size)
|
|
194
|
+
.catch(() => 0);
|
|
195
|
+
if (live) {
|
|
196
|
+
footprint.live_bytes += size;
|
|
197
|
+
}
|
|
198
|
+
else {
|
|
199
|
+
footprint.archive_bytes += size;
|
|
200
|
+
footprint.archive_count += 1;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
footprint.total_bytes = footprint.live_bytes + footprint.archive_bytes;
|
|
204
|
+
return footprint;
|
|
205
|
+
}
|
|
110
206
|
/**
|
|
111
207
|
* Rotates and says what happened, on both branches. Safe to call every tick:
|
|
112
208
|
* the size check is one stat per file and a rotation is rare.
|
|
113
209
|
*/
|
|
114
210
|
export async function rotateCollectorLogsBestEffort(paths, options = {}) {
|
|
115
|
-
let result = { rotated: [], failures: [] };
|
|
211
|
+
let result = { rotated: [], failures: [], swept: [] };
|
|
116
212
|
try {
|
|
117
213
|
result = await rotateCollectorLogs(paths, options);
|
|
118
214
|
}
|
|
119
215
|
catch (error) {
|
|
120
216
|
result = {
|
|
121
217
|
rotated: [],
|
|
218
|
+
swept: [],
|
|
122
219
|
failures: [
|
|
123
220
|
{
|
|
124
221
|
name: "*",
|
|
@@ -137,6 +234,13 @@ export async function rotateCollectorLogsBestEffort(paths, options = {}) {
|
|
|
137
234
|
archives_kept: rotated.archives,
|
|
138
235
|
}));
|
|
139
236
|
}
|
|
237
|
+
for (const swept of result.swept) {
|
|
238
|
+
console.error("[log-rotation] removed a log archive outside the keep window", JSON.stringify({
|
|
239
|
+
file: swept.name,
|
|
240
|
+
reason: swept.reason,
|
|
241
|
+
byte_size: swept.byte_size,
|
|
242
|
+
}));
|
|
243
|
+
}
|
|
140
244
|
for (const failure of result.failures) {
|
|
141
245
|
console.error("[log-rotation] could not cap a scheduled-tick log", JSON.stringify({ file: failure.name, reason: failure.reason }));
|
|
142
246
|
}
|
package/dist/tower-stream.js
CHANGED
|
@@ -124,6 +124,10 @@ export async function readTowerTurn(response, options = {}) {
|
|
|
124
124
|
options.onToken?.(item.event);
|
|
125
125
|
continue;
|
|
126
126
|
}
|
|
127
|
+
if (item.event.type === "revision") {
|
|
128
|
+
options.onRevision?.(item.event);
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
127
131
|
activity.push(item.event);
|
|
128
132
|
options.onActivity?.(item.event);
|
|
129
133
|
}
|
|
@@ -277,7 +281,7 @@ function parseLine(line) {
|
|
|
277
281
|
return { kind: "note", reason: "unexpected_event_type" };
|
|
278
282
|
}
|
|
279
283
|
const type = parsed.type;
|
|
280
|
-
if (type !== "activity" && type !== "token" && type !== "final") {
|
|
284
|
+
if (type !== "activity" && type !== "token" && type !== "revision" && type !== "final") {
|
|
281
285
|
return { kind: "note", reason: "unexpected_event_type" };
|
|
282
286
|
}
|
|
283
287
|
return { kind: "event", event: parsed };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bli-cockpit/cli",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.52",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
"test": "node dist/cli.js --help && node ../../scripts/assert-public-cli-routing.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.
|
|
30
|
+
"@bli-cockpit/memory-mcp": "0.1.1",
|
|
31
31
|
"@bli-cockpit/telemetry-core": "0.1.26"
|
|
32
32
|
}
|
|
33
33
|
}
|