@bli-cockpit/cli 0.2.30 → 0.2.31

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.
Files changed (44) hide show
  1. package/dist/adapters/agent-image-evidence.js +4 -0
  2. package/dist/adapters/attribution-core.js +12 -0
  3. package/dist/adapters/car-state.js +12 -1
  4. package/dist/adapters/claude-attribution.js +81 -7
  5. package/dist/adapters/codex-attribution.js +37 -3
  6. package/dist/adapters/raw-evidence-manifest.js +12 -1
  7. package/dist/adapters/raw-evidence-pack-store.js +28 -3
  8. package/dist/adapters/raw-evidence-sanitize.js +46 -2
  9. package/dist/adapters/raw-evidence.js +51 -6
  10. package/dist/agent-rules.js +34 -3
  11. package/dist/autostart.js +3 -0
  12. package/dist/backfill-lock.js +22 -1
  13. package/dist/commands/backfill.js +41 -7
  14. package/dist/commands/cli-io.js +3 -0
  15. package/dist/commands/doctor.js +35 -4
  16. package/dist/commands/install-receipts.js +43 -6
  17. package/dist/commands/install-update.js +3 -1
  18. package/dist/commands/local-args.js +4 -0
  19. package/dist/commands/local-auth.js +14 -0
  20. package/dist/commands/local.js +20 -1
  21. package/dist/commands/public-root.js +1 -1
  22. package/dist/commands/session-sync.js +35 -6
  23. package/dist/commands/status.js +17 -1
  24. package/dist/cursors/backfill-cursor.js +23 -2
  25. package/dist/cursors/raw-evidence-cursor.js +14 -1
  26. package/dist/discovery-limits.js +12 -1
  27. package/dist/evidence-upload-client.js +41 -4
  28. package/dist/health-detail.js +111 -2
  29. package/dist/local-state.js +98 -11
  30. package/dist/onboarding-roots.js +3 -0
  31. package/dist/raw-evidence-attribution-policy.js +7 -0
  32. package/dist/raw-evidence-gc.js +5 -0
  33. package/dist/raw-evidence-staging.js +12 -1
  34. package/dist/repo-identity.js +15 -1
  35. package/dist/scheduled-self-update.js +14 -1
  36. package/dist/spool/install-event-outbox.js +11 -1
  37. package/dist/spool/local-spool.js +3 -0
  38. package/dist/sync-lock.js +24 -1
  39. package/dist/upload-agent-artifacts.js +11 -1
  40. package/dist/upload-envelope.js +30 -3
  41. package/dist/upload-http.js +10 -0
  42. package/dist/upload-session-reports.js +36 -3
  43. package/dist/upload.js +16 -5
  44. package/package.json +2 -2
@@ -1,6 +1,7 @@
1
1
  import { mkdir, readFile, writeFile } from "node:fs/promises";
2
2
  import os from "node:os";
3
3
  import path from "node:path";
4
+ import { describeError, isMissingFileFailure } from "./health-detail.js";
4
5
  const MANAGED_BLOCK_START = "<!-- BLI_COCKPIT_AGENT_RULES:START -->";
5
6
  const MANAGED_BLOCK_END = "<!-- BLI_COCKPIT_AGENT_RULES:END -->";
6
7
  export async function installCodexAgentRules(options = {}) {
@@ -25,7 +26,18 @@ async function installAgentRulesForHost(host, options = {}) {
25
26
  try {
26
27
  existing = await readFile(rulesFile, "utf8");
27
28
  }
28
- catch {
29
+ catch (error) {
30
+ // No rules file yet is the ordinary first install. A file that exists and
31
+ // will not read is treated as absent, and the write that follows would
32
+ // OVERWRITE it with a fresh block — so the reason is on the record before
33
+ // that happens (BLI-3238).
34
+ if (!isMissingFileFailure(error)) {
35
+ console.error("[agent-rules] existing rules file unreadable, treating the host as uninstalled", JSON.stringify({
36
+ reason: "agent_rules_unreadable",
37
+ host,
38
+ ...describeError(error),
39
+ }));
40
+ }
29
41
  existed = false;
30
42
  }
31
43
  const prepared = prepareManagedBlockInstall(existing, block, scopePaths);
@@ -57,7 +69,16 @@ async function uninstallAgentRulesForHost(host, options = {}) {
57
69
  try {
58
70
  existing = await readFile(rulesFile, "utf8");
59
71
  }
60
- catch {
72
+ catch (error) {
73
+ // `missing` is honest when the file is genuinely absent. When it exists
74
+ // and cannot be read, uninstall reports success having removed nothing.
75
+ if (!isMissingFileFailure(error)) {
76
+ console.error("[agent-rules] rules file unreadable, reporting nothing to uninstall", JSON.stringify({
77
+ reason: "agent_rules_unreadable",
78
+ host,
79
+ ...describeError(error),
80
+ }));
81
+ }
61
82
  return agentRulesResult(host, rulesFile, "missing", block, "missing");
62
83
  }
63
84
  const next = removeManagedBlock(existing);
@@ -91,7 +112,17 @@ async function inspectAgentRulesForHost(host, options = {}) {
91
112
  try {
92
113
  existing = await readFile(rulesFile, "utf8");
93
114
  }
94
- catch {
115
+ catch (error) {
116
+ // `installed: false` is what an operator's doctor run sees. Absent is the
117
+ // truthful version of that; unreadable is a different problem wearing the
118
+ // same answer, and re-running the install would not fix it.
119
+ if (!isMissingFileFailure(error)) {
120
+ console.error("[agent-rules] rules file unreadable, reporting the host as not installed", JSON.stringify({
121
+ reason: "agent_rules_unreadable",
122
+ host,
123
+ ...describeError(error),
124
+ }));
125
+ }
95
126
  return {
96
127
  ...agentRulesResult(host, rulesFile, "missing", block, "missing"),
97
128
  installed: false,
package/dist/autostart.js CHANGED
@@ -131,6 +131,9 @@ export async function uninstallAutostartAgent(options) {
131
131
  if (!(await fileExists(plistPath))) {
132
132
  return { status: "absent", label: AUTOSTART_LABEL, plist_path: plistPath };
133
133
  }
134
+ // Same reasoning as the install path above: an agent that was never loaded
135
+ // makes unload fail harmlessly, and the plist is removed either way, so the
136
+ // error is deliberately ignored (BLI-3238).
134
137
  await options.exec("launchctl", ["unload", plistPath]).catch(() => undefined);
135
138
  await rm(plistPath, { force: true });
136
139
  return { status: "uninstalled", label: AUTOSTART_LABEL, plist_path: plistPath };
@@ -1,6 +1,7 @@
1
1
  import crypto from "node:crypto";
2
2
  import fs from "node:fs/promises";
3
3
  import path from "node:path";
4
+ import { describeError } from "./health-detail.js";
4
5
  const BACKFILL_LOCK_FILENAME = "backfill.lock";
5
6
  const HEARTBEAT_INTERVAL_MS = 30_000;
6
7
  export const BACKFILL_LOCK_STALE_TAKEOVER_MS = 5 * 60_000;
@@ -65,10 +66,26 @@ async function tryExclusiveCreate(lockPath, token, now) {
65
66
  await handle.close();
66
67
  return true;
67
68
  }
68
- catch {
69
+ catch (error) {
70
+ // Same trap as sync-lock: EEXIST means another backfill holds it, and
71
+ // anything else (permissions, full disk, read-only volume) is reported as
72
+ // the identical "already running" and would keep the historical sweep from
73
+ // ever starting, permanently and silently (BLI-3238).
74
+ if (!isBackfillLockHeldError(error)) {
75
+ console.error("[backfill-lock] could not create the lock file; reporting the lock as held", JSON.stringify({
76
+ reason: "backfill_lock_create_failed",
77
+ ...describeError(error),
78
+ }));
79
+ }
69
80
  return false;
70
81
  }
71
82
  }
83
+ /** `wx` refusing because the lock exists — the ordinary contended case. */
84
+ function isBackfillLockHeldError(error) {
85
+ return (typeof error === "object" &&
86
+ error !== null &&
87
+ error.code === "EEXIST");
88
+ }
72
89
  async function writeBackfillLock(lockPath, token, now) {
73
90
  await fs.writeFile(lockPath, serializeBackfillLock(token, now), {
74
91
  mode: 0o600,
@@ -103,6 +120,10 @@ async function readBackfillLockRecord(lockPath) {
103
120
  };
104
121
  }
105
122
  catch {
123
+ // Deliberately silent (BLI-3238), same reasoning as sync-lock's reader:
124
+ // this is the "is anyone holding it?" probe, and every way it can fail
125
+ // means "not held", which is the answer the caller acts on. The failing
126
+ // ACQUIRE above owns the reporting.
106
127
  return null;
107
128
  }
108
129
  }
@@ -1,8 +1,9 @@
1
- import { NO_UPLOAD_ATTEMPT_RECORDED, RAW_EVIDENCE_UPLOAD_MAX_FILE_BYTES } from "@bli-cockpit/telemetry-core";
1
+ import { NO_UPLOAD_ATTEMPT_RECORDED, RAW_EVIDENCE_UPLOAD_MAX_FILE_BYTES, notUploadableAttributionStateReason } from "@bli-cockpit/telemetry-core";
2
2
  import crypto from "node:crypto";
3
3
  import fs from "node:fs/promises";
4
4
  import os from "node:os";
5
5
  import path from "node:path";
6
+ import { describeError } from "../health-detail.js";
6
7
  import { scanAndAttributeClaudeSessions } from "../adapters/claude-attribution.js";
7
8
  import { defaultCodexSessionDirs, scanAndAttributeCodexSessions } from "../adapters/codex-attribution.js";
8
9
  import { RAW_EVIDENCE_DEFAULT_BYTE_BUDGET, RAW_EVIDENCE_DEFAULT_OBJECT_BUDGET } from "../adapters/raw-evidence.js";
@@ -984,6 +985,10 @@ function isMissingFsError(error) {
984
985
  async function countReadOnlyGuards(candidates) {
985
986
  const counts = new Map();
986
987
  const retryableCandidateKeys = new Set();
988
+ // Aggregated: this runs over the whole archived history, so a per-candidate
989
+ // line could be thousands. The count already travels; the reason did not
990
+ // (BLI-3238).
991
+ let firstReadFailure = null;
987
992
  for (const candidate of candidates) {
988
993
  if (candidate.reason === "repo_not_on_disk") {
989
994
  increment(counts, "repo_not_on_disk");
@@ -1004,11 +1009,21 @@ async function countReadOnlyGuards(candidates) {
1004
1009
  try {
1005
1010
  await fs.readFile(candidate.file_path);
1006
1011
  }
1007
- catch {
1012
+ catch (error) {
1008
1013
  increment(counts, "file_read_failed");
1014
+ firstReadFailure ??= describeError(error);
1009
1015
  retryableCandidateKeys.add(candidateCursorKey(candidate));
1010
1016
  }
1011
1017
  }
1018
+ const readFailedCount = counts.get("file_read_failed") ?? 0;
1019
+ if (readFailedCount > 0) {
1020
+ console.error("[cockpit-backfill] archived sessions could not be read", JSON.stringify({
1021
+ reason: "file_read_failed",
1022
+ read_failed_count: readFailedCount,
1023
+ candidate_count: candidates.length,
1024
+ ...firstReadFailure,
1025
+ }));
1026
+ }
1012
1027
  return {
1013
1028
  counts,
1014
1029
  retryable_candidate_keys: retryableCandidateKeys,
@@ -1128,13 +1143,23 @@ async function ensureBackfillReportContext(options) {
1128
1143
  await readLocalWorkContextForRepo(options.paths, repoRoot);
1129
1144
  }
1130
1145
  catch {
1131
- // Context creation is best-effort here. postCodexSessionReport converts a
1132
- // remaining local-context failure into a retryable report reason.
1146
+ // Reading it can legitimately fail — there is no context yet, which is
1147
+ // precisely why the next line creates one. That read is a probe and stays
1148
+ // silent; the CREATE is the branch that has to speak (BLI-3238).
1133
1149
  await startLocalWorkContext({
1134
1150
  homeDir: options.homeDir,
1135
1151
  repoRoot,
1136
1152
  branch: representative?.branch,
1137
- }).catch(() => undefined);
1153
+ }).catch((error) => {
1154
+ // Context creation is best-effort here: postCodexSessionReport converts
1155
+ // a remaining local-context failure into a retryable report reason. But
1156
+ // that reason is `collector_not_ready`, which points the operator at
1157
+ // setup rather than at whatever actually failed here.
1158
+ console.error("[cockpit-backfill] could not start a local work context for the batch", JSON.stringify({
1159
+ reason: "work_context_start_failed",
1160
+ ...describeError(error),
1161
+ }));
1162
+ });
1138
1163
  }
1139
1164
  return { repoRoot };
1140
1165
  }
@@ -1188,7 +1213,8 @@ async function syncBackfillBatch(options) {
1188
1213
  throw error;
1189
1214
  }
1190
1215
  }
1191
- function buildBackfillSessionReport(options) {
1216
+ /** Exported for the BLI-3272 regression test; not part of the CLI surface. */
1217
+ export function buildBackfillSessionReport(options) {
1192
1218
  const uploadByKey = new Map();
1193
1219
  // BLI-2107: an outcome that names a failure but has no pointer used to be
1194
1220
  // dropped on the floor here, taking its reason with it.
@@ -1275,7 +1301,15 @@ function buildBackfillSessionReport(options) {
1275
1301
  upload_reason: noUploadReasonBySessionId.get(candidate.session_id) ??
1276
1302
  NO_UPLOAD_ATTEMPT_RECORDED,
1277
1303
  }
1278
- : {}),
1304
+ : {
1305
+ // BLI-3272: and the refused-attribution branch says why too. Same
1306
+ // NULL/NULL hole as live sync, same fix — backfill is the path
1307
+ // that revisits old sessions, so leaving it silent would keep
1308
+ // rewriting the very rows this ticket found.
1309
+ upload_state: "not_uploaded",
1310
+ upload_reason: noUploadReasonBySessionId.get(candidate.session_id) ??
1311
+ notUploadableAttributionStateReason(candidate.reason),
1312
+ }),
1279
1313
  };
1280
1314
  });
1281
1315
  }
@@ -67,6 +67,9 @@ export function parseCapturedJson(chunks) {
67
67
  return JSON.parse(text);
68
68
  }
69
69
  catch {
70
+ // Deliberately silent (BLI-3238). The parse IS the question this function
71
+ // asks — "did the nested command print JSON or human text?" — and both
72
+ // answers are valid; the caller replays the raw text when it is not JSON.
70
73
  return text;
71
74
  }
72
75
  }
@@ -2,7 +2,7 @@ import fs from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { autostartStatus, installAutostartAgent } from "../autostart.js";
4
4
  import { savedDiscoveryLimitArgs } from "../discovery-limits.js";
5
- import { redactedHealthDetail } from "../health-detail.js";
5
+ import { describeError, redactedHealthDetail } from "../health-detail.js";
6
6
  import { inspectBackfillLock } from "../backfill-lock.js";
7
7
  import { backfillCompletionCovers, readBackfillCompletionMarker, readBackfillCursor, } from "../cursors/backfill-cursor.js";
8
8
  import { DEFAULT_DASHBOARD_URL, getCollectorRuntimePaths, LOCAL_COLLECTOR_VERSION, readLocalCollectorConfig, readLocalCollectorSessionFile, } from "../local-state.js";
@@ -146,14 +146,32 @@ async function fixCliLatest(context, state) {
146
146
  };
147
147
  }
148
148
  async function fixAuthState(context, state) {
149
- const code = await context.deps.runLogin(context).catch(() => 1);
149
+ const code = await context.deps.runLogin(context).catch((error) => {
150
+ // Exit code 1 with no reason at all is what an operator saw when doctor
151
+ // tried and failed to repair their auth — the same output as a login that
152
+ // ran and was declined (BLI-3238).
153
+ console.error("[cockpit-doctor] login repair threw", JSON.stringify({
154
+ reason: "auth_repair_failed",
155
+ ...describeError(error),
156
+ }));
157
+ return 1;
158
+ });
150
159
  if (code !== 0)
151
160
  return state;
152
161
  const checked = await context.deps.readAuth(context);
153
162
  return checked.status === "ok" ? checked : state;
154
163
  }
155
164
  async function fixRootState(context, state) {
156
- await context.deps.resolveAndSaveRoots(context).catch(() => undefined);
165
+ await context.deps.resolveAndSaveRoots(context).catch((error) => {
166
+ // Roots ARE the collection boundary. If saving them throws and nothing
167
+ // says so, the machine converges to "no approved root" and collects
168
+ // nothing — a green-looking doctor run over an empty boundary, which is
169
+ // the failure mode this whole ticket exists for.
170
+ console.error("[cockpit-doctor] collection roots could not be resolved or saved", JSON.stringify({
171
+ reason: "roots_repair_failed",
172
+ ...describeError(error),
173
+ }));
174
+ });
157
175
  const checked = await context.deps.readRoots(context);
158
176
  return checked.status === "ok" ? checked : state;
159
177
  }
@@ -350,7 +368,16 @@ function parseDoctorSyncJson(stdout) {
350
368
  const parsed = JSON.parse(stdout.trim());
351
369
  return parsed && typeof parsed === "object" ? parsed : null;
352
370
  }
353
- catch {
371
+ catch (error) {
372
+ // `null` sends doctor back to its regex field-scrape, quietly losing the
373
+ // BLI-2728 disambiguation. Something wrote to stdout that was not the one
374
+ // JSON document the contract promises — a stray console.log in the
375
+ // collector would do exactly this and look like nothing at all.
376
+ console.error("[cockpit-doctor] sync --json stdout was not one JSON document", JSON.stringify({
377
+ reason: "sync_json_unparseable",
378
+ byte_size: stdout.length,
379
+ ...describeError(error),
380
+ }));
354
381
  return null;
355
382
  }
356
383
  }
@@ -547,6 +574,10 @@ function parseNpmVersion(stdout) {
547
574
  return typeof parsed === "string" && parsed.trim() ? parsed.trim() : null;
548
575
  }
549
576
  catch {
577
+ // Deliberately silent (BLI-3238). `npm view ... version` prints a bare
578
+ // `1.2.3` without `--json` and a quoted `"1.2.3"` with it; the parse is
579
+ // the test for which one this npm produced, and the unquoted fallback is
580
+ // the intended handling of the other form, not a failure.
550
581
  return trimmed.replace(/^"|"$/gu, "") || null;
551
582
  }
552
583
  }
@@ -11,7 +11,7 @@
11
11
  */
12
12
  import os from "node:os";
13
13
  import { errorMessage, writeLine } from "./cli-io.js";
14
- import { maskLocalIdentifiers, redactedHealthDetail, } from "../health-detail.js";
14
+ import { describeError, maskLocalIdentifiers, redactedHealthDetail, } from "../health-detail.js";
15
15
  import { getCollectorRuntimePaths, readLocalCollectorSessionFile, LOCAL_COLLECTOR_VERSION, } from "../local-state.js";
16
16
  import { redactSecretLikeContent } from "@bli-cockpit/telemetry-core";
17
17
  import { COLLECTION_ROOT_REQUIRED } from "../onboarding-roots.js";
@@ -74,7 +74,17 @@ export async function reportInstallEventsBestEffort(options) {
74
74
  })),
75
75
  });
76
76
  }
77
- catch {
77
+ catch (error) {
78
+ // The existing operator line survives, and is `--json`-only. This one is
79
+ // unconditional and carries the reason: the machine has just dropped the
80
+ // health receipts for a whole command, and a `--json` gate meant the
81
+ // normal interactive run said nothing at all (BLI-3238).
82
+ console.error("[install-receipts] could not queue install events; the receipts are lost", JSON.stringify({
83
+ reason: "local_write_failed",
84
+ command: options.command,
85
+ event_count: options.events.length,
86
+ ...describeError(error),
87
+ }));
78
88
  if (options.json) {
79
89
  writeLine(options.io.stderr, "Install event outbox unavailable: local_write_failed");
80
90
  }
@@ -112,9 +122,18 @@ export async function reportInstallEventsBestEffort(options) {
112
122
  if (!response.ok) {
113
123
  throw new Error(`http_${response.status}`);
114
124
  }
115
- const receipt = (await response
116
- .json()
117
- .catch(() => null));
125
+ const receipt = (await response.json().catch((error) => {
126
+ // This reply carries the server-published `min_cli_version` floor
127
+ // (BLI-2678). A body that will not parse means the floor is not
128
+ // observed on this tick and the forced-update path silently does
129
+ // nothing — while the 2xx above says the receipt landed fine.
130
+ console.error("[install-receipts] receipt body unreadable; no min_cli_version observed", JSON.stringify({
131
+ reason: "receipt_body_unreadable",
132
+ http_status: response.status,
133
+ ...describeError(error),
134
+ }));
135
+ return null;
136
+ }));
118
137
  if (typeof receipt?.min_cli_version === "string" &&
119
138
  receipt.min_cli_version.trim()) {
120
139
  observedMinCliVersion = receipt.min_cli_version.trim();
@@ -124,10 +143,28 @@ export async function reportInstallEventsBestEffort(options) {
124
143
  catch (error) {
125
144
  const failureReason = classifyInstallTelemetryError(error);
126
145
  failures.push(failureReason);
146
+ // The classified reason is the coarse bucket the outbox row keeps;
147
+ // beside it, what actually happened. `network_error` covers DNS,
148
+ // TLS, timeout and abort, and only one of those is worth waking up
149
+ // for (BLI-3238).
150
+ console.error("[install-receipts] install event delivery failed, entry kept for retry", JSON.stringify({
151
+ reason: failureReason,
152
+ outbox_id: entry.outbox_id,
153
+ ...describeError(error),
154
+ }));
127
155
  await recordInstallEventAttemptFailure(paths, entry, {
128
156
  attemptedAt: new Date().toISOString(),
129
157
  failureReason,
130
- }).catch(() => undefined);
158
+ }).catch((writeError) => {
159
+ // Double failure: delivery failed AND the retry bookkeeping did.
160
+ // The entry stays queued, so nothing is lost, but the attempt
161
+ // count stops advancing and the outbox looks stuck for no reason.
162
+ console.error("[install-receipts] could not record the delivery failure against the entry", JSON.stringify({
163
+ reason: "attempt_bookkeeping_failed",
164
+ outbox_id: entry.outbox_id,
165
+ ...describeError(writeError),
166
+ }));
167
+ });
131
168
  }
132
169
  finally {
133
170
  clearTimeout(timeout);
@@ -295,7 +295,9 @@ async function findPublicReleaseRoot(startDir) {
295
295
  }
296
296
  catch {
297
297
  // Keep walking: nested packages may be missing package.json or have one
298
- // without the release script.
298
+ // without the release script. Deliberately silent (BLI-3238) — this is
299
+ // a search, every level that is not the answer fails here, and the
300
+ // caller reports `null` when the walk finds nothing.
299
301
  }
300
302
  const parent = path.dirname(current);
301
303
  if (parent === current)
@@ -739,6 +739,10 @@ function looksLikeServiceRoleSecret(value) {
739
739
  return serviceCredentialPayloadPattern().test(payload);
740
740
  }
741
741
  catch {
742
+ // Deliberately silent (BLI-3238), and it must stay silent: this is the
743
+ // "is this argument a service-role JWT?" test, so a value that will not
744
+ // decode is simply not one. Anything logged here would be a fragment of a
745
+ // credential.
742
746
  return false;
743
747
  }
744
748
  }
@@ -185,6 +185,15 @@ async function readJsonResponse(response) {
185
185
  return JSON.parse(text);
186
186
  }
187
187
  catch {
188
+ // OTP path. The text is handed to the caller for the human-facing message
189
+ // but never logged — it is an unbounded page from whatever answered. The
190
+ // shape is what says "a proxy replied, not the dashboard" (BLI-3238).
191
+ console.error("[local-auth] auth reply was not JSON", JSON.stringify({
192
+ reason: "response_body_not_json",
193
+ http_status: response.status,
194
+ byte_size: text.length,
195
+ content_type: response.headers.get("content-type") ?? "none",
196
+ }));
188
197
  return text;
189
198
  }
190
199
  }
@@ -260,6 +269,11 @@ export async function readOnboardSessionReuseCandidate(homeDir) {
260
269
  return await readLocalCollectorSessionFile(paths);
261
270
  }
262
271
  catch {
272
+ // Deliberately silent (BLI-3238). This is the "can we reuse an existing
273
+ // login?" probe and the fallback below reads the same file through the
274
+ // looser schema — which reports its own reason when it also fails
275
+ // (`session_file_unusable` in local-state). Logging here would double
276
+ // every line for one read.
263
277
  return readLocalSessionReference(paths);
264
278
  }
265
279
  }
@@ -23,6 +23,7 @@
23
23
  import path from "node:path";
24
24
  import { bufferedWritable, defaultExec, defaultIo, errorMessage, parseCapturedJson, replayCaptured, writeLine, } from "./cli-io.js";
25
25
  import { isLocalHelpRequest, localCommandHelp } from "./local-help.js";
26
+ import { describeError, isMissingFileFailure } from "../health-detail.js";
26
27
  import { addInstallEvent, classifySyncHealthError, redactedSyncErrorDetail, reportInstallEventsBestEffort, } from "./install-receipts.js";
27
28
  import { canReuseOnboardSession, pairLocalCollectorWithAuthFallback, readOnboardSessionReuseCandidate, requestPairingAccessToken, requestPairingAccessTokenDetailed, resolveInteractiveLoginEmail, resolveOnboardEmail, } from "./local-auth.js";
28
29
  import { collectionRootConsentAliases, persistOnboardingRootConfig, resolveOnboardingRootsForCommand, } from "./collection-roots.js";
@@ -1372,7 +1373,16 @@ async function runAnalyze(command, io) {
1372
1373
  return syncExitCode === 0 ? 1 : syncExitCode;
1373
1374
  }
1374
1375
  const paths = getCollectorRuntimePaths(command.homeDir);
1375
- const session = await readLocalCollectorSessionFile(paths).catch(() => {
1376
+ const session = await readLocalCollectorSessionFile(paths).catch((error) => {
1377
+ // "Not signed in" is the right sentence for an absent session file and the
1378
+ // wrong one for a corrupt one, which `cockpit login` will not repair
1379
+ // (BLI-3238).
1380
+ if (!isMissingFileFailure(error)) {
1381
+ console.error("[cockpit-analyze] session file present but unreadable, reporting as not signed in", JSON.stringify({
1382
+ reason: "session_file_unusable",
1383
+ ...describeError(error),
1384
+ }));
1385
+ }
1376
1386
  throw new Error("Cockpit is not signed in. Run `cockpit onboard` or `cockpit login` first.");
1377
1387
  });
1378
1388
  const dashboardUrl = normalizeUrl(command.dashboardUrl ?? session.dashboard_url ?? DEFAULT_DASHBOARD_URL);
@@ -1436,6 +1446,15 @@ async function readAnalyzeApiResponse(response) {
1436
1446
  return value && typeof value === "object" ? value : {};
1437
1447
  }
1438
1448
  catch {
1449
+ // `{}` erases whatever the analyze endpoint said, including its error, and
1450
+ // the operator is left with a job id of `undefined` and no reason. The
1451
+ // body is never logged; its shape is what identifies a proxy reply.
1452
+ console.error("[cockpit-analyze] reply was not JSON", JSON.stringify({
1453
+ reason: "response_body_not_json",
1454
+ http_status: response.status,
1455
+ byte_size: text.length,
1456
+ content_type: response.headers.get("content-type") ?? "none",
1457
+ }));
1439
1458
  return {};
1440
1459
  }
1441
1460
  }
@@ -15,7 +15,7 @@ export async function runCockpitCli(argv, io) {
15
15
  }
16
16
 
17
17
  if (command === "--version" || command === "-V" || command === "version") {
18
- writeLine(io?.stdout ?? process.stdout, "0.2.30");
18
+ writeLine(io?.stdout ?? process.stdout, "0.2.31");
19
19
  return 0;
20
20
  }
21
21
 
@@ -6,7 +6,8 @@
6
6
  import os from "node:os";
7
7
  import path from "node:path";
8
8
  import { getCollectorRuntimePaths, readLocalCollectorConfig, startLocalWorkContext, startLocalWorkContextForAttributedTarget, } from "../local-state.js";
9
- import { CODEX_SESSION_ATTRIBUTION_STATE_RANK, NO_UPLOAD_ATTEMPT_RECORDED, } from "@bli-cockpit/telemetry-core";
9
+ import { describeError } from "../health-detail.js";
10
+ import { CODEX_SESSION_ATTRIBUTION_STATE_RANK, NO_UPLOAD_ATTEMPT_RECORDED, notUploadableAttributionStateReason, } from "@bli-cockpit/telemetry-core";
10
11
  import { flushPendingCodexSessionReports, LocalUploadBlockedError, queueCodexSessionReport, syncLocalAmbientEnvelope, } from "../upload.js";
11
12
  import { CODEX_ATTRIBUTION_SCAN_WINDOW_SESSION_LIMIT, CODEX_ATTRIBUTION_SCAN_WINDOW_MINUTES, defaultCodexSessionDirs, scanAndAttributeCodexSessions, } from "../adapters/codex-attribution.js";
12
13
  import { scanAndAttributeClaudeSessions } from "../adapters/claude-attribution.js";
@@ -376,8 +377,17 @@ export async function runAttributedWorktreeSync(options) {
376
377
  codexCursor.updated_at = now.toISOString();
377
378
  await writeRawEvidenceCursor(paths, codexCursor);
378
379
  }
379
- catch {
380
- // Best-effort: stale counts read 0 and observations re-record next sync.
380
+ catch (error) {
381
+ // Best-effort: stale counts read 0 and observations re-record next sync
382
+ // which is fine ONCE. A cursor write that keeps failing means the sessions
383
+ // cursor never advances, every sync re-does the same work, and the only
384
+ // symptom is a stale count that is permanently zero (BLI-3238).
385
+ console.error("[session-sync] Codex session observations were not recorded", JSON.stringify({
386
+ reason: "session_cursor_update_failed",
387
+ source: "codex",
388
+ session_count: sessions.length,
389
+ ...describeError(error),
390
+ }));
381
391
  }
382
392
  if (claudeEnabled) {
383
393
  try {
@@ -398,8 +408,16 @@ export async function runAttributedWorktreeSync(options) {
398
408
  sessionsOnly: true,
399
409
  });
400
410
  }
401
- catch {
402
- // Best-effort: a broken Claude cursor must not fail the sync.
411
+ catch (error) {
412
+ // Best-effort: a broken Claude cursor must not fail the sync. It must
413
+ // still say it is broken — otherwise the Claude half of collection
414
+ // quietly repeats itself forever.
415
+ console.error("[session-sync] Claude session observations were not recorded", JSON.stringify({
416
+ reason: "session_cursor_update_failed",
417
+ source: "claude_code",
418
+ session_count: sessions.length,
419
+ ...describeError(error),
420
+ }));
403
421
  }
404
422
  }
405
423
  const report = hadPendingSessionReports || queuedCurrentSessionReport
@@ -639,7 +657,18 @@ export function buildAgentSessionReport(options) {
639
657
  ? "sync_incomplete_this_pass"
640
658
  : NO_UPLOAD_ATTEMPT_RECORDED),
641
659
  }
642
- : {}),
660
+ : {
661
+ // BLI-3272: the upload policy refused this session's
662
+ // attribution state. That refusal used to spread `{}` here, so
663
+ // the row landed with upload_state NULL and upload_reason NULL
664
+ // — a withhold that named nothing, on 1,256 production
665
+ // sessions. It is a decision like any other and it says so.
666
+ // Any reason the pipeline did record still wins: it is the more
667
+ // specific answer.
668
+ upload_state: "not_uploaded",
669
+ upload_reason: noUploadReasonByKey.get(key) ??
670
+ notUploadableAttributionStateReason(result.reason),
671
+ }),
643
672
  };
644
673
  });
645
674
  }
@@ -16,6 +16,7 @@ import { writeLine } from "./cli-io.js";
16
16
  import { displayTicketId, displayWorkLabel, shortSha, stuckEvidenceLine, } from "./collection-report.js";
17
17
  import { discoverCommandWorktrees } from "./local-discovery.js";
18
18
  import { defaultCodexSessionDirs } from "../adapters/codex-attribution.js";
19
+ import { describeError } from "../health-detail.js";
19
20
  import { backfillCompletionCovers, emptyBackfillCursorState, prepareBackfillCursorForScope, readBackfillCompletionMarker, readBackfillCursor, } from "../cursors/backfill-cursor.js";
20
21
  import { getCollectorRuntimePaths, inspectLocalCollectorStatus, readLocalCollectorConfig, } from "../local-state.js";
21
22
  import { normalizeCollectionRoots } from "../root-normalization.js";
@@ -204,6 +205,12 @@ async function countClaudeMainFilesBeforeCursor(projectsDir, oldestProcessedMs)
204
205
  /** Unreadable folders are skipped rather than failing the walk. */
205
206
  async function walkFiles(roots, onFile, shouldStop = () => false) {
206
207
  const stack = [...roots];
208
+ // Counted, then reported once at the end: `cockpit status` is what an
209
+ // operator reads to decide whether collection is healthy, and a walk that
210
+ // silently skipped half the store would answer that question wrong
211
+ // (BLI-3238).
212
+ let unreadableDirCount = 0;
213
+ let firstFailure = null;
207
214
  while (stack.length > 0 && !shouldStop()) {
208
215
  const current = stack.pop();
209
216
  if (!current)
@@ -212,7 +219,9 @@ async function walkFiles(roots, onFile, shouldStop = () => false) {
212
219
  try {
213
220
  entries = await readdir(current, { withFileTypes: true });
214
221
  }
215
- catch {
222
+ catch (error) {
223
+ unreadableDirCount += 1;
224
+ firstFailure ??= describeError(error);
216
225
  continue;
217
226
  }
218
227
  for (const entry of entries) {
@@ -227,4 +236,11 @@ async function walkFiles(roots, onFile, shouldStop = () => false) {
227
236
  }
228
237
  }
229
238
  }
239
+ if (unreadableDirCount > 0) {
240
+ console.error("[cockpit-status] folders skipped while counting", JSON.stringify({
241
+ reason: "status_walk_dir_unreadable",
242
+ unreadable_dir_count: unreadableDirCount,
243
+ ...firstFailure,
244
+ }));
245
+ }
230
246
  }
@@ -1,6 +1,7 @@
1
1
  import crypto from "node:crypto";
2
2
  import fs from "node:fs/promises";
3
3
  import path from "node:path";
4
+ import { describeError, isMissingFileFailure } from "../health-detail.js";
4
5
  export const BACKFILL_CURSOR_FILENAME = "backfill.json";
5
6
  export const BACKFILL_COMPLETION_MARKER_FILENAME = "backfill-complete.json";
6
7
  export const BACKFILL_COVERAGE_VERSION = "redacted-session-backfill.v3";
@@ -20,7 +21,17 @@ export async function readBackfillCursor(paths) {
20
21
  const raw = JSON.parse(await fs.readFile(backfillCursorPath(paths), "utf8"));
21
22
  return parseBackfillCursor(raw);
22
23
  }
23
- catch {
24
+ catch (error) {
25
+ // Missing is the normal pre-backfill state. Present-but-unreadable means
26
+ // the historical sweep is about to restart from scratch, which looks
27
+ // identical from the outside and costs a full re-walk (BLI-3238).
28
+ if (!isMissingFileFailure(error)) {
29
+ console.error("[backfill-cursor] cursor unreadable, restarting the sweep from empty", JSON.stringify({
30
+ reason: "backfill_cursor_unreadable",
31
+ cursor_file: BACKFILL_CURSOR_FILENAME,
32
+ ...describeError(error),
33
+ }));
34
+ }
24
35
  return emptyBackfillCursorState();
25
36
  }
26
37
  }
@@ -36,7 +47,17 @@ export async function readBackfillCompletionMarker(paths) {
36
47
  const raw = JSON.parse(await fs.readFile(backfillCompletionMarkerPath(paths), "utf8"));
37
48
  return parseBackfillCompletionMarker(raw);
38
49
  }
39
- catch {
50
+ catch (error) {
51
+ // No marker means backfill has not finished, which is the ordinary state.
52
+ // A marker that cannot be read means a finished backfill will be re-run,
53
+ // and the machine should say so rather than quietly redo a day of work.
54
+ if (!isMissingFileFailure(error)) {
55
+ console.error("[backfill-cursor] completion marker unreadable, treating backfill as unfinished", JSON.stringify({
56
+ reason: "backfill_marker_unreadable",
57
+ marker_file: BACKFILL_COMPLETION_MARKER_FILENAME,
58
+ ...describeError(error),
59
+ }));
60
+ }
40
61
  return null;
41
62
  }
42
63
  }