@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.
- package/dist/commands/clean.js +58 -5
- package/dist/commands/doctor.js +7 -2
- package/dist/commands/local-args-collector.js +12 -3
- package/dist/commands/local-help.js +12 -4
- package/dist/commands/public-root.js +1 -1
- package/dist/commands/session-sync-attribution.js +55 -0
- package/dist/commands/session-sync-failures.js +140 -0
- package/dist/commands/session-sync-health.js +102 -0
- package/dist/commands/session-sync-plan.js +81 -0
- package/dist/commands/session-sync-record.js +279 -0
- package/dist/commands/session-sync-scan.js +209 -0
- package/dist/commands/session-sync-types.js +12 -0
- package/dist/commands/session-sync-upload.js +215 -0
- package/dist/commands/session-sync.js +44 -987
- package/dist/commands/sync-followups.js +47 -2
- package/dist/cursors/raw-evidence-reconcile-cursor.js +132 -0
- package/dist/disk-usage.js +55 -0
- package/dist/evidence-reconcile-client.js +224 -0
- package/package.json +3 -3
|
@@ -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
|
|
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
|
|
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
|
+
}
|
package/dist/disk-usage.js
CHANGED
|
@@ -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.
|
|
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.
|
|
31
|
-
"@bli-cockpit/telemetry-core": "0.1.
|
|
30
|
+
"@bli-cockpit/memory-mcp": "0.1.2",
|
|
31
|
+
"@bli-cockpit/telemetry-core": "0.1.27"
|
|
32
32
|
}
|
|
33
33
|
}
|