@bli-cockpit/cli 0.2.52 → 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.
@@ -19,7 +19,10 @@ import { runSelfUpdate, SelfUpdateError } from "./install-update.js";
19
19
  import { getCollectorRuntimePaths, LOCAL_COLLECTOR_VERSION, } from "../local-state.js";
20
20
  import { runAutostartSelfHeal, } from "../autostart-self-heal.js";
21
21
  import { runStagingPrune } from "../disk-prune.js";
22
+ import { runEvidenceReconcile, } from "../evidence-reconcile-client.js";
22
23
  import { envWithNodeRuntimeOnPath, runScheduledSelfUpdate, } from "../scheduled-self-update.js";
24
+ /** How many reconcile batches (of up to 500 hashes each) one sync tick may spend. */
25
+ const RECONCILE_BATCHES_PER_TICK = 1;
23
26
  /**
24
27
  * BLI-2721: after the tick's collection and self-update are done and
25
28
  * reported, repair a broken/legacy autostart registration in place (Windows
@@ -197,8 +200,23 @@ function memoryInstallEvent(outcome) {
197
200
  * The rule itself is `disk-retention.ts`: only objects the upload ledger
198
201
  * vouches for are ever deleted, and anything undelivered is counted, aged and
199
202
  * named instead.
203
+ *
204
+ * BLI-3619's second half runs FIRST, every tick, bounded to
205
+ * `RECONCILE_BATCHES_PER_TICK` (one batch of up to 500 hashes): the local
206
+ * ledger's own memory is capped, so a laptop that keeps accumulating
207
+ * `unknown` state needs SOMETHING asking the server on a schedule, not only
208
+ * when a person happens to type `cockpit clean --reconcile`. One batch a tick
209
+ * is not throttled to once a day like the prune below — an `unknown` backlog
210
+ * drains at up to 500 hashes/15 min, and the ordinary case (nothing unknown)
211
+ * costs one cheap local read and no network call at all, so it never competes
212
+ * with collection for the tick's time. A reconcile failure never blocks the
213
+ * prune that follows it.
200
214
  */
201
215
  export async function runStagingPruneAfterSync(command, io, dashboardUrl, options = {}) {
216
+ const events = [];
217
+ const reconciled = await runReconcileFollowUp(command, io, dashboardUrl, options);
218
+ if (reconciled)
219
+ events.push(reconcileEvent(reconciled));
202
220
  let result;
203
221
  try {
204
222
  result = await runStagingPrune(getCollectorRuntimePaths(command.homeDir), {
@@ -217,17 +235,36 @@ export async function runStagingPruneAfterSync(command, io, dashboardUrl, option
217
235
  }
218
236
  // The daily throttle is the steady state — reporting it would post a receipt
219
237
  // on 95 of every 96 ticks for no new information.
220
- if (result.reason === "throttled_recent_run")
238
+ if (result.reason !== "throttled_recent_run") {
239
+ events.push(stagingPruneEvent(result));
240
+ }
241
+ if (events.length === 0)
221
242
  return;
222
243
  await reportInstallEventsBestEffort({
223
244
  homeDir: command.homeDir,
224
245
  dashboardUrl,
225
246
  command: "sync",
226
- events: [stagingPruneEvent(result)],
247
+ events,
227
248
  json: command.json,
228
249
  io,
229
250
  });
230
251
  }
252
+ /**
253
+ * Never throws (`runEvidenceReconcile` already never does); returns `null`
254
+ * for the boring, common case — nothing this tick was `unknown` — so that
255
+ * case costs no receipt either, the same rule the prune's own daily throttle
256
+ * follows above.
257
+ */
258
+ async function runReconcileFollowUp(command, io, dashboardUrl, options) {
259
+ const result = await runEvidenceReconcile({
260
+ homeDir: command.homeDir,
261
+ dashboardUrl,
262
+ maxBatches: RECONCILE_BATCHES_PER_TICK,
263
+ fetch: io.fetch,
264
+ ...(options.now ? { now: options.now } : {}),
265
+ });
266
+ return result.reason === "nothing_unknown" ? null : result;
267
+ }
231
268
  function prunedNothing() {
232
269
  return {
233
270
  status: "skipped",
@@ -276,6 +313,14 @@ function stagingPruneEvent(result) {
276
313
  }
277
314
  return { step: "staging_prune", status: "ok", error_detail: detail };
278
315
  }
316
+ /** Counts only; no hash and no pack id travels in a receipt. */
317
+ function reconcileEvent(result) {
318
+ const detail = `asked ${result.asked} hash(es) across ${result.batches}/${result.total_batches} batch(es); committed ${result.committed}, not_committed ${result.not_committed}, unknown_to_server ${result.unknown_to_server}, failed_batches ${result.failed_batches}`;
319
+ if (result.status === "fail") {
320
+ return { step: "evidence_reconcile", status: "fail", error_code: result.reason, error_detail: detail };
321
+ }
322
+ return { step: "evidence_reconcile", status: "ok", error_detail: detail };
323
+ }
279
324
  /**
280
325
  * BLI-2601: the fleet keeps itself current on npm `latest` without anyone
281
326
  * re-running `npm i -g @bli-cockpit/cli` by hand after day 0. This always
@@ -0,0 +1,132 @@
1
+ /**
2
+ * The server's own answer, kept durably on this machine.
3
+ *
4
+ * BLI-3619. `cursors/raw-evidence.json` `objects` answers "did this content
5
+ * hash commit?" but is capped at 5,000 rows and prunes the oldest, so a pack
6
+ * staged before its own memory begins reads `unknown` — the ledger cannot say,
7
+ * not that it says no. `cockpit clean --reconcile` asks the dashboard once per
8
+ * hash and this file is where that answer lives afterward, so the same
9
+ * question is never asked twice for the same hash and a `--dry-run` run can
10
+ * read a reconciled state from an earlier `--reconcile` run without spending
11
+ * another round trip.
12
+ *
13
+ * Keyed on `content_hash_sha256`, same as the commit ledger and for the same
14
+ * reason: `pack_id` is a local directory name the server has never seen.
15
+ *
16
+ * Metadata only: hashes, a verdict label, and two timestamps. Never a path,
17
+ * never a byte of content.
18
+ */
19
+ import crypto from "node:crypto";
20
+ import fs from "node:fs/promises";
21
+ import path from "node:path";
22
+ import { describeError, isMissingFileFailure } from "../health-detail.js";
23
+ export const RECONCILE_CURSOR_FILENAME = "raw-evidence-reconcile.json";
24
+ // Same order of magnitude as the commit ledger's own cap (D4): entries are
25
+ // small, and the question this file answers is bounded by how many hashes a
26
+ // laptop can hold `unknown` at once, which the byte cap already keeps small.
27
+ const MAX_TRACKED_RESULTS = 5_000;
28
+ export function emptyReconcileCursorState() {
29
+ return {
30
+ schema_version: "cockpit-raw-evidence-reconcile-cursor.v1",
31
+ updated_at: null,
32
+ results: {},
33
+ };
34
+ }
35
+ export async function readReconcileCursor(paths) {
36
+ try {
37
+ const raw = JSON.parse(await fs.readFile(reconcileCursorPath(paths), "utf8"));
38
+ return parseReconcileCursorState(raw);
39
+ }
40
+ catch (error) {
41
+ if (!isMissingFileFailure(error)) {
42
+ console.error("[collector reconcile] reconcile cursor unreadable, starting from empty", JSON.stringify({
43
+ reason: "reconcile_cursor_unreadable",
44
+ ...describeError(error),
45
+ }));
46
+ }
47
+ return emptyReconcileCursorState();
48
+ }
49
+ }
50
+ export async function writeReconcileCursor(paths, state) {
51
+ const filePath = reconcileCursorPath(paths);
52
+ await fs.mkdir(path.dirname(filePath), { recursive: true, mode: 0o700 });
53
+ const pruned = pruneReconcileState(state);
54
+ const serialized = `${JSON.stringify(pruned, null, 2)}\n`;
55
+ const tempPath = `${filePath}.tmp-${process.pid}-${crypto.randomUUID()}`;
56
+ let handle = null;
57
+ try {
58
+ handle = await fs.open(tempPath, "w", 0o600);
59
+ await handle.writeFile(serialized);
60
+ await handle.sync();
61
+ }
62
+ finally {
63
+ await handle?.close();
64
+ }
65
+ try {
66
+ await fs.rename(tempPath, filePath);
67
+ }
68
+ catch (error) {
69
+ await fs.rm(tempPath, { force: true }).catch(() => undefined);
70
+ throw error;
71
+ }
72
+ if (process.platform !== "win32") {
73
+ await fs.chmod(filePath, 0o600).catch(() => undefined);
74
+ }
75
+ }
76
+ export function recordReconcileResult(state, contentHash, entry) {
77
+ state.results[contentHash] = entry;
78
+ }
79
+ function pruneReconcileState(state) {
80
+ const entries = Object.entries(state.results);
81
+ if (entries.length <= MAX_TRACKED_RESULTS)
82
+ return state;
83
+ entries.sort((a, b) => b[1].checked_at.localeCompare(a[1].checked_at));
84
+ return {
85
+ ...state,
86
+ results: Object.fromEntries(entries.slice(0, MAX_TRACKED_RESULTS)),
87
+ };
88
+ }
89
+ function reconcileCursorPath(paths) {
90
+ return path.join(paths.cursors_dir, RECONCILE_CURSOR_FILENAME);
91
+ }
92
+ function parseReconcileCursorState(value) {
93
+ if (!value || typeof value !== "object")
94
+ return emptyReconcileCursorState();
95
+ const record = value;
96
+ const results = {};
97
+ const rawResults = record["results"];
98
+ if (rawResults && typeof rawResults === "object") {
99
+ for (const [hash, entry] of Object.entries(rawResults)) {
100
+ const parsed = parseReconcileEntry(entry);
101
+ if (parsed)
102
+ results[hash] = parsed;
103
+ }
104
+ }
105
+ return {
106
+ schema_version: "cockpit-raw-evidence-reconcile-cursor.v1",
107
+ updated_at: optionalString(record["updated_at"]),
108
+ results,
109
+ };
110
+ }
111
+ function parseReconcileEntry(value) {
112
+ if (!value || typeof value !== "object")
113
+ return null;
114
+ const record = value;
115
+ const verdict = record["verdict"];
116
+ const checkedAt = optionalString(record["checked_at"]);
117
+ if (!checkedAt)
118
+ return null;
119
+ if (verdict !== "committed" &&
120
+ verdict !== "not_committed" &&
121
+ verdict !== "unknown_to_server") {
122
+ return null;
123
+ }
124
+ return {
125
+ verdict,
126
+ checked_at: checkedAt,
127
+ server_committed_at: optionalString(record["server_committed_at"]),
128
+ };
129
+ }
130
+ function optionalString(value) {
131
+ return typeof value === "string" && value.trim() ? value : null;
132
+ }
@@ -28,6 +28,14 @@
28
28
  * 7.93 GB of "I forgot" must not read as 7.93 GB of
29
29
  * "it never landed".
30
30
  *
31
+ * BLI-3619's second half: `cockpit clean --reconcile` asks the dashboard's own
32
+ * upload ledger about a hash this laptop can no longer classify and writes the
33
+ * answer into `cursors/raw-evidence-reconcile.json` — read here, alongside the
34
+ * commit ledger, before an object is allowed to fall back to `unknown`. A
35
+ * server-confirmed `committed` object counts as `committed`; `not_committed` /
36
+ * `unknown_to_server` count as `uncommitted`, never `unknown` — the server
37
+ * spoke, so "the ledger cannot say" is no longer true of that hash.
38
+ *
31
39
  * Content hashes come from each pack's own `manifest.json`, which already
32
40
  * records `relative_path` + `content_hash_sha256` + `byte_size` for every file
33
41
  * it staged. That is why a daily inventory does not re-read 20 GB. A physical
@@ -42,6 +50,7 @@ import crypto from "node:crypto";
42
50
  import fs from "node:fs/promises";
43
51
  import path from "node:path";
44
52
  import { readRawEvidenceCursor } from "./cursors/raw-evidence-cursor.js";
53
+ import { readReconcileCursor, } from "./cursors/raw-evidence-reconcile-cursor.js";
45
54
  import { describeError } from "./health-detail.js";
46
55
  import { rotatedLogFootprint } from "./log-rotation.js";
47
56
  import { readRawEvidenceStagingState } from "./raw-evidence-staging.js";
@@ -67,6 +76,7 @@ export async function readDiskFootprint(paths, now = new Date()) {
67
76
  export async function readStagingInventory(paths, now = new Date()) {
68
77
  const root = path.join(paths.state_dir, RAW_EVIDENCE_DIR);
69
78
  const ledger = await readCommitLedger(paths);
79
+ const reconciled = await readReconcileLedger(paths);
70
80
  const stagedAt = await readStagedAtIndex(paths);
71
81
  const entries = await fs
72
82
  .readdir(root, { withFileTypes: true })
@@ -86,6 +96,7 @@ export async function readStagingInventory(paths, now = new Date()) {
86
96
  continue;
87
97
  const pack = await readPack(dir, entry.name, {
88
98
  ledger,
99
+ reconciled,
89
100
  stagedAt,
90
101
  now,
91
102
  budget,
@@ -121,6 +132,15 @@ async function readStagedAtIndex(paths) {
121
132
  }
122
133
  return index;
123
134
  }
135
+ /**
136
+ * The reconcile cursor as a lookup map. Read alongside the commit ledger so an
137
+ * object the server confirmed committed classifies exactly like one this
138
+ * machine's own ledger still remembers.
139
+ */
140
+ async function readReconcileLedger(paths) {
141
+ const cursor = await readReconcileCursor(paths);
142
+ return new Map(Object.entries(cursor.results));
143
+ }
124
144
  async function readPack(dir, packId, options) {
125
145
  const manifest = await readManifestIndex(dir);
126
146
  const pack = {
@@ -158,6 +178,12 @@ async function classifyObject(dir, packId, relative, size, manifest, options) {
158
178
  age_ms: ageMs(committedAt, options.now),
159
179
  };
160
180
  }
181
+ if (hash) {
182
+ const reconciled = options.reconciled.get(hash);
183
+ if (reconciled) {
184
+ return classifyFromReconcileAnswer(base, hash, reconciled, options.now);
185
+ }
186
+ }
161
187
  const unknownReason = unknownStateReason(hash, manifest, stagedAt, options);
162
188
  return {
163
189
  ...base,
@@ -168,6 +194,35 @@ async function classifyObject(dir, packId, relative, size, manifest, options) {
168
194
  age_ms: ageMs(stagedAt, options.now),
169
195
  };
170
196
  }
197
+ /**
198
+ * The server's own answer for this hash, already on disk from an earlier
199
+ * `cockpit clean --reconcile` run. This takes priority over
200
+ * `unknownStateReason`'s guesswork about the LOCAL ledger's memory, because
201
+ * the server was actually asked and actually answered.
202
+ */
203
+ function classifyFromReconcileAnswer(base, hash, reconciled, now) {
204
+ if (reconciled.verdict === "committed") {
205
+ const decidedAt = reconciled.server_committed_at ?? reconciled.checked_at;
206
+ return {
207
+ ...base,
208
+ content_hash: hash,
209
+ state: "committed",
210
+ reason: "reconciled_committed",
211
+ decided_at: decidedAt,
212
+ age_ms: ageMs(decidedAt, now),
213
+ };
214
+ }
215
+ return {
216
+ ...base,
217
+ content_hash: hash,
218
+ state: "uncommitted",
219
+ reason: reconciled.verdict === "not_committed"
220
+ ? "reconciled_not_committed"
221
+ : "reconciled_unknown_to_server",
222
+ decided_at: reconciled.checked_at,
223
+ age_ms: ageMs(reconciled.checked_at, now),
224
+ };
225
+ }
171
226
  /**
172
227
  * Why the ledger's silence about this object is NOT the same as a "no".
173
228
  *
@@ -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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bli-cockpit/cli",
3
- "version": "0.2.52",
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
  }