@bli-cockpit/cli 0.2.51 → 0.2.53

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,224 @@
1
+ /**
2
+ * Asks `POST /api/ambient/evidence/reconcile` about every hash this laptop
3
+ * can no longer classify on its own, then writes the answer into
4
+ * `cursors/raw-evidence-reconcile.json` so `disk-usage.ts` reads it back on
5
+ * every run after this one.
6
+ *
7
+ * BLI-3619. Three properties, same as the prune it feeds:
8
+ *
9
+ * - **Never deletes.** This module only asks and records; `disk-prune.ts`
10
+ * decides what goes, unchanged.
11
+ * - **A server failure never blocks the prune.** A batch that cannot be asked
12
+ * leaves its hashes exactly as unresolved as they were — still `unknown`
13
+ * locally — and the run says `reconcile_unavailable: <reason>` rather than
14
+ * throwing. The ordinary retention prune runs whether this succeeded,
15
+ * partly succeeded, or could not run at all.
16
+ * - **Bounded.** `maxBatches` lets a caller (the daily sync fold) ask only one
17
+ * batch of up to 500 hashes per tick rather than draining years of
18
+ * accumulated `unknown` state in one request storm; `cockpit clean
19
+ * --reconcile` itself asks every batch there is.
20
+ */
21
+ import { EVIDENCE_RECONCILE_MAX_BATCH, EVIDENCE_RECONCILE_SCHEMA_VERSION, EvidenceReconcileResponseSchema, } from "@bli-cockpit/telemetry-core";
22
+ import { describeError } from "./health-detail.js";
23
+ import { getCollectorRuntimePaths, readLocalCollectorSessionFile } from "./local-state.js";
24
+ import { readReconcileCursor, recordReconcileResult, writeReconcileCursor, } from "./cursors/raw-evidence-reconcile-cursor.js";
25
+ import { readStagingInventory } from "./disk-usage.js";
26
+ const RECONCILE_BATCH_TIMEOUT_MS = 15_000;
27
+ /** The prune the sync tick and `cockpit clean --reconcile` both call. Never throws. */
28
+ export async function runEvidenceReconcile(options) {
29
+ const paths = getCollectorRuntimePaths(options.homeDir);
30
+ const now = options.now ?? new Date();
31
+ const doFetch = options.fetch ?? fetch;
32
+ try {
33
+ const inventory = options.inventory ?? (await readStagingInventory(paths, now));
34
+ const hashes = uniqueUnknownHashes(inventory);
35
+ if (hashes.length === 0) {
36
+ // No stderr line here on purpose: the sync tick calls this every 15
37
+ // minutes and the steady state is "nothing unknown" — logging that
38
+ // every tick forever is exactly the receipt-on-95-of-96-ticks spam the
39
+ // daily prune throttle exists to avoid. `cockpit clean --reconcile`
40
+ // still tells a person this on stdout (`reconcileLines` in clean.ts).
41
+ return emptyResult("nothing_unknown");
42
+ }
43
+ const session = await readLocalCollectorSessionFile(paths).catch(() => null);
44
+ if (!session ||
45
+ session.session_state !== "valid" ||
46
+ typeof session.device_token !== "string" ||
47
+ !session.device_token) {
48
+ const result = {
49
+ ...emptyResult("no_device_session"),
50
+ total_batches: chunk(hashes, EVIDENCE_RECONCILE_MAX_BATCH).length,
51
+ };
52
+ reportReconcile(result);
53
+ return result;
54
+ }
55
+ const batches = chunk(hashes, EVIDENCE_RECONCILE_MAX_BATCH);
56
+ const bounded = options.maxBatches != null ? batches.slice(0, options.maxBatches) : batches;
57
+ const cursor = await readReconcileCursor(paths);
58
+ let committed = 0;
59
+ let notCommitted = 0;
60
+ let unknownToServer = 0;
61
+ let failedBatches = 0;
62
+ let firstFailureReason = null;
63
+ let askedThisRun = 0;
64
+ for (const batch of bounded) {
65
+ const outcome = await sendReconcileBatch({
66
+ dashboardUrl: options.dashboardUrl,
67
+ deviceToken: session.device_token,
68
+ hashes: batch,
69
+ fetchImpl: doFetch,
70
+ });
71
+ if (!outcome.ok) {
72
+ failedBatches += 1;
73
+ firstFailureReason ??= outcome.reason;
74
+ continue;
75
+ }
76
+ askedThisRun += batch.length;
77
+ for (const item of outcome.results) {
78
+ recordReconcileResult(cursor, item.content_hash_sha256, {
79
+ verdict: item.verdict,
80
+ checked_at: now.toISOString(),
81
+ server_committed_at: item.verdict === "committed" ? item.committed_at : null,
82
+ });
83
+ if (item.verdict === "committed")
84
+ committed += 1;
85
+ else if (item.verdict === "not_committed")
86
+ notCommitted += 1;
87
+ else
88
+ unknownToServer += 1;
89
+ }
90
+ }
91
+ if (askedThisRun > 0) {
92
+ cursor.updated_at = now.toISOString();
93
+ await writeReconcileCursor(paths, cursor).catch((error) => {
94
+ // The prune's own forgetStagedObjects follows the same rule: the
95
+ // answer is not lost (the server has it), but a reader of THIS
96
+ // machine will ask again next run rather than silently drift.
97
+ console.error("[collector reconcile] reconcile cursor not written after answers landed", JSON.stringify({
98
+ reason: "reconcile_cursor_write_failed",
99
+ answered_count: askedThisRun,
100
+ ...describeError(error),
101
+ }));
102
+ });
103
+ }
104
+ const result = {
105
+ status: askedThisRun > 0 ? "ok" : "fail",
106
+ reason: reconcileReason({
107
+ askedThisRun,
108
+ failedBatches,
109
+ firstFailureReason,
110
+ boundedShortOfAvailable: bounded.length < batches.length,
111
+ }),
112
+ asked: askedThisRun,
113
+ committed,
114
+ not_committed: notCommitted,
115
+ unknown_to_server: unknownToServer,
116
+ batches: bounded.length,
117
+ failed_batches: failedBatches,
118
+ total_batches: batches.length,
119
+ };
120
+ reportReconcile(result);
121
+ return result;
122
+ }
123
+ catch (error) {
124
+ const result = {
125
+ ...emptyResult(`reconcile_unavailable: ${describeError(error).error_name}`),
126
+ status: "fail",
127
+ };
128
+ console.error("[collector reconcile] the reconcile pass threw; nothing was answered or deleted", JSON.stringify({ reason: "reconcile_threw", ...describeError(error) }));
129
+ return result;
130
+ }
131
+ }
132
+ function reconcileReason(input) {
133
+ if (input.askedThisRun === 0) {
134
+ return `reconcile_unavailable: ${input.firstFailureReason ?? "unknown"}`;
135
+ }
136
+ if (input.failedBatches > 0)
137
+ return "reconciled_partial";
138
+ if (input.boundedShortOfAvailable)
139
+ return "reconciled_bounded";
140
+ return "reconciled";
141
+ }
142
+ /** Every `unknown`-state object's hash, deduplicated — the question is per hash. */
143
+ function uniqueUnknownHashes(inventory) {
144
+ const hashes = new Set();
145
+ for (const pack of inventory.packs) {
146
+ for (const object of pack.objects) {
147
+ if (object.state === "unknown" && object.content_hash) {
148
+ hashes.add(object.content_hash);
149
+ }
150
+ }
151
+ }
152
+ return [...hashes];
153
+ }
154
+ function chunk(items, size) {
155
+ const batches = [];
156
+ for (let index = 0; index < items.length; index += size) {
157
+ batches.push(items.slice(index, index + size));
158
+ }
159
+ return batches;
160
+ }
161
+ async function sendReconcileBatch(options) {
162
+ const controller = new AbortController();
163
+ const timeout = setTimeout(() => controller.abort(), RECONCILE_BATCH_TIMEOUT_MS);
164
+ try {
165
+ const response = await options.fetchImpl(`${options.dashboardUrl}/api/ambient/evidence/reconcile`, {
166
+ method: "POST",
167
+ headers: {
168
+ "Content-Type": "application/json",
169
+ Authorization: `Bearer ${options.deviceToken}`,
170
+ },
171
+ body: JSON.stringify({
172
+ schema_version: EVIDENCE_RECONCILE_SCHEMA_VERSION,
173
+ items: options.hashes.map((hash) => ({ content_hash_sha256: hash })),
174
+ }),
175
+ signal: controller.signal,
176
+ });
177
+ if (!response.ok) {
178
+ return { ok: false, reason: `http_${response.status}`, results: [] };
179
+ }
180
+ const body = await response.json().catch(() => null);
181
+ const parsed = EvidenceReconcileResponseSchema.safeParse(body);
182
+ if (!parsed.success) {
183
+ return { ok: false, reason: "invalid_response", results: [] };
184
+ }
185
+ return { ok: true, reason: "ok", results: parsed.data.results };
186
+ }
187
+ catch (error) {
188
+ return { ok: false, reason: describeError(error).error_name, results: [] };
189
+ }
190
+ finally {
191
+ clearTimeout(timeout);
192
+ }
193
+ }
194
+ function emptyResult(reason) {
195
+ return {
196
+ status: "skipped",
197
+ reason,
198
+ asked: 0,
199
+ committed: 0,
200
+ not_committed: 0,
201
+ unknown_to_server: 0,
202
+ batches: 0,
203
+ failed_batches: 0,
204
+ total_batches: 0,
205
+ };
206
+ }
207
+ /**
208
+ * The receipt, on stderr, exactly the shape the ticket names:
209
+ * `{asked, committed, not_committed, unknown_to_server, batches, failed_batches}`,
210
+ * plus `total_batches` so a bounded (daily-fold) run says the bound it hit.
211
+ * Metadata only — hashes and pack ids never travel in this line.
212
+ */
213
+ function reportReconcile(result) {
214
+ console.error("[collector reconcile]", JSON.stringify({
215
+ reason: result.reason,
216
+ asked: result.asked,
217
+ committed: result.committed,
218
+ not_committed: result.not_committed,
219
+ unknown_to_server: result.unknown_to_server,
220
+ batches: result.batches,
221
+ total_batches: result.total_batches,
222
+ failed_batches: result.failed_batches,
223
+ }));
224
+ }
@@ -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
  }
@@ -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.51",
3
+ "version": "0.2.53",
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.1",
31
- "@bli-cockpit/telemetry-core": "0.1.26"
30
+ "@bli-cockpit/memory-mcp": "0.1.2",
31
+ "@bli-cockpit/telemetry-core": "0.1.27"
32
32
  }
33
33
  }