@bli-cockpit/cli 0.2.30 → 0.2.32

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 (46) 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 +21 -3
  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/jarvis.js +136 -0
  19. package/dist/commands/local-args.js +29 -0
  20. package/dist/commands/local-auth.js +14 -0
  21. package/dist/commands/local-help.js +15 -0
  22. package/dist/commands/local.js +24 -1
  23. package/dist/commands/public-root.js +1 -1
  24. package/dist/commands/session-sync.js +35 -6
  25. package/dist/commands/status.js +17 -1
  26. package/dist/cursors/backfill-cursor.js +23 -2
  27. package/dist/cursors/raw-evidence-cursor.js +14 -1
  28. package/dist/discovery-limits.js +12 -1
  29. package/dist/evidence-upload-client.js +41 -4
  30. package/dist/health-detail.js +111 -2
  31. package/dist/local-state.js +98 -11
  32. package/dist/onboarding-roots.js +3 -0
  33. package/dist/raw-evidence-attribution-policy.js +7 -0
  34. package/dist/raw-evidence-gc.js +5 -0
  35. package/dist/raw-evidence-staging.js +12 -1
  36. package/dist/repo-identity.js +15 -1
  37. package/dist/scheduled-self-update.js +14 -1
  38. package/dist/spool/install-event-outbox.js +11 -1
  39. package/dist/spool/local-spool.js +3 -0
  40. package/dist/sync-lock.js +24 -1
  41. package/dist/upload-agent-artifacts.js +11 -1
  42. package/dist/upload-envelope.js +30 -3
  43. package/dist/upload-http.js +10 -0
  44. package/dist/upload-session-reports.js +36 -3
  45. package/dist/upload.js +16 -5
  46. package/package.json +2 -2
@@ -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
  }
@@ -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
  /**
5
6
  * Durable local cursor for raw-evidence harvesting.
6
7
  *
@@ -41,7 +42,19 @@ export async function readRawEvidenceCursor(paths, options = {}) {
41
42
  const raw = JSON.parse(await fs.readFile(rawEvidenceCursorPath(paths, options.filename), "utf8"));
42
43
  return parseCursorState(raw);
43
44
  }
44
- catch {
45
+ catch (error) {
46
+ // An empty cursor is the correct answer before the first sync, and saying
47
+ // so every 15 minutes would bury the case that matters. A cursor that
48
+ // exists and cannot be read is a different event entirely: the collector
49
+ // is about to re-offer every object it already delivered and no line
50
+ // anywhere says why (BLI-3238).
51
+ if (!isMissingFileFailure(error)) {
52
+ console.error("[raw-evidence-cursor] cursor unreadable, starting from empty", JSON.stringify({
53
+ reason: "cursor_unreadable",
54
+ cursor_file: options.filename ?? PRIMARY_CURSOR_FILENAME,
55
+ ...describeError(error),
56
+ }));
57
+ }
45
58
  return emptyRawEvidenceCursorState();
46
59
  }
47
60
  }
@@ -3,6 +3,7 @@ import os from "node:os";
3
3
  import path from "node:path";
4
4
  import { DEFAULT_DISCOVERY_MAX_DEPTH, DEFAULT_DISCOVERY_MAX_REPOS, } from "./repo-identity.js";
5
5
  import { getCollectorRuntimePaths } from "./local-state.js";
6
+ import { describeError, isMissingFileFailure } from "./health-detail.js";
6
7
  const SCHEMA_VERSION = "cockpit-discovery-limits.v1";
7
8
  // A depth beyond this is a typo rather than a workspace, and an unbounded walk
8
9
  // on a huge tree is its own outage. Same for the repo cap.
@@ -32,7 +33,17 @@ export async function readSavedDiscoveryLimits(homeDir = os.homedir()) {
32
33
  max_repos: sanitizeLimit(parsed.max_repos, MAX_ALLOWED_REPOS),
33
34
  };
34
35
  }
35
- catch {
36
+ catch (error) {
37
+ // No saved limits is the normal state and stays quiet. A file that exists
38
+ // and will not parse silently reinstates the shipped defaults, which is
39
+ // exactly the shape of the outage where depth 3 / 50 repos quietly
40
+ // undercollected for days — so it gets a line (BLI-3238).
41
+ if (!isMissingFileFailure(error)) {
42
+ console.error("[discovery-limits] saved limits unreadable, falling back to defaults", JSON.stringify({
43
+ reason: "discovery_limits_unreadable",
44
+ ...describeError(error),
45
+ }));
46
+ }
36
47
  return {};
37
48
  }
38
49
  }
@@ -1,6 +1,7 @@
1
1
  import { COMMIT_CRASHED_PLATFORM, RAW_EVIDENCE_UPLOAD_DEFAULT_CHUNK_BYTES, RAW_EVIDENCE_UPLOAD_MAX_FILE_BYTES, RAW_EVIDENCE_UPLOAD_MAX_OBJECTS_PER_BEGIN, RawEvidenceLegacyUploadResponseSchema, RawEvidenceRedactionMetadataSchema, RawEvidenceUploadBeginResponseSchema, RawEvidenceUploadCommitResponseSchema, isPermanentUploadFailure, } from "@bli-cockpit/telemetry-core";
2
2
  import crypto from "node:crypto";
3
3
  import fs from "node:fs/promises";
4
+ import { describeError } from "./health-detail.js";
4
5
  const DEFAULT_MAX_ATTEMPTS = 3;
5
6
  const RETRY_DELAY_MS = 250;
6
7
  export async function uploadRawEvidenceFilesChunked(options) {
@@ -16,7 +17,16 @@ export async function uploadRawEvidenceFilesChunked(options) {
16
17
  try {
17
18
  stat = await fs.stat(file.local_path);
18
19
  }
19
- catch {
20
+ catch (error) {
21
+ // `file_read_failed` is a durable ledger label and stays. Beside it: a
22
+ // staged object that vanished (the GC won a race) and one the process
23
+ // has no permission to open are the same word and opposite repairs
24
+ // (BLI-3238).
25
+ console.error("[evidence-upload] staged object could not be stat'd", JSON.stringify({
26
+ reason: "file_read_failed",
27
+ stage: "stat",
28
+ ...describeError(error),
29
+ }));
20
30
  outcomes.push(failedOutcome(file, "file_read_failed"));
21
31
  continue;
22
32
  }
@@ -32,7 +42,15 @@ export async function uploadRawEvidenceFilesChunked(options) {
32
42
  try {
33
43
  bytes = await fs.readFile(file.local_path);
34
44
  }
35
- catch {
45
+ catch (error) {
46
+ // Stat succeeded and the read did not — narrower than the stat failure
47
+ // above and worth telling apart, so it carries its own stage label.
48
+ console.error("[evidence-upload] staged object could not be read after a successful stat", JSON.stringify({
49
+ reason: "file_read_failed",
50
+ stage: "read",
51
+ byte_size: stat.size,
52
+ ...describeError(error),
53
+ }));
36
54
  outcomes.push(failedOutcome(file, "file_read_failed"));
37
55
  continue;
38
56
  }
@@ -437,7 +455,18 @@ async function requestJson(options, routePath, body) {
437
455
  return { ok: response.ok, status: response.status, body: lastBody };
438
456
  }
439
457
  }
440
- catch {
458
+ catch (error) {
459
+ // `status: 0` is this function's word for "no answer came back", and it
460
+ // travels all the way to the upload ledger without ever saying whether
461
+ // the request left the machine. This is the exact silence BLI-2528 sat
462
+ // behind for 57 days; the route is named, the body never is.
463
+ console.error("[evidence-upload] request failed before a status came back", JSON.stringify({
464
+ reason: "upload_request_transport_error",
465
+ route: routePath,
466
+ attempt,
467
+ max_attempts: maxAttempts,
468
+ ...describeError(error),
469
+ }));
441
470
  lastStatus = 0;
442
471
  lastBody = null;
443
472
  }
@@ -578,7 +607,15 @@ async function readResponseJson(response) {
578
607
  }
579
608
  catch {
580
609
  // The raw text is kept for the caller that wants to show it, never for a
581
- // label or a log — it is an unbounded HTML page.
610
+ // label or a log — it is an unbounded HTML page. Its SHAPE is safe and is
611
+ // the part that matters: a non-JSON body means something in front of the
612
+ // dashboard answered instead of it (BLI-3067, BLI-3238).
613
+ console.error("[evidence-upload] reply was not JSON", JSON.stringify({
614
+ reason: "response_body_not_json",
615
+ http_status: response.status,
616
+ byte_size: text.length,
617
+ content_type: response.headers.get("content-type") ?? "none",
618
+ }));
582
619
  return { message: text, [NON_JSON_RESPONSE_BODY_KEY]: "non_json" };
583
620
  }
584
621
  }
@@ -87,17 +87,126 @@ function localIdentifiers() {
87
87
  identifiers.push([os.hostname(), "[host]"]);
88
88
  }
89
89
  catch {
90
- // A host with no resolvable name has nothing to leak.
90
+ // A host with no resolvable name has nothing to leak. Deliberately silent
91
+ // (BLI-3238): this runs inside the redactor, so reporting it here would
92
+ // recurse into the very function that is failing.
91
93
  }
92
94
  try {
93
95
  identifiers.push([os.userInfo().username, "[user]"]);
94
96
  }
95
97
  catch {
96
- // Same: no account name available means none can travel.
98
+ // Same: no account name available means none can travel, and the same
99
+ // recursion argument applies.
97
100
  }
98
101
  // A one- or two-character name would match far too much ordinary text.
99
102
  return identifiers.filter(([value]) => value && value.length > 2);
100
103
  }
101
104
  function literalPattern(value) {
102
105
  return new RegExp(value.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"), "giu");
106
+ }
107
+ /**
108
+ * Describes a caught error in fields that are safe to log, and never throws
109
+ * doing it.
110
+ *
111
+ * BLI-3238. A bare `catch {}` in the harvester is how collection dies for days
112
+ * without anyone noticing (BLI-2528: 57 days, one probe away the whole time).
113
+ * The counter-pressure is that an error message on this machine is not neutral
114
+ * text — an fs error carries the operator's absolute path, a fetch error
115
+ * carries a host, and a parse error can carry a fragment of the transcript
116
+ * being parsed. So:
117
+ *
118
+ * - A syscall error (`code` + `syscall`) reports its code and syscall and
119
+ * drops the message entirely. `ENOENT: no such file or directory, open
120
+ * '/Users/<person>/…'` says nothing `ENOENT open` does not, and the tail is
121
+ * pure local identifier.
122
+ * - Every other message goes through `redactedHealthDetail`, the same
123
+ * secret-and-path scrubber the health receipts use before leaving the
124
+ * machine, and is capped at `HEALTH_DETAIL_MAX_CHARS`.
125
+ * - One level of `cause` is unwrapped, because `TypeError: fetch failed` on
126
+ * its own names nothing and its cause is the whole answer.
127
+ */
128
+ export function describeError(error) {
129
+ if (!(error instanceof Error)) {
130
+ // `null` and `undefined` are fully described by their name; repeating them
131
+ // as a detail says nothing twice.
132
+ const named = error === null || error === undefined;
133
+ return {
134
+ error_name: error === null ? "null" : typeof error,
135
+ ...(named ? {} : detailField(safeString(error))),
136
+ };
137
+ }
138
+ const fields = {
139
+ error_name: error.name || "Error",
140
+ ...codeField(error),
141
+ ...syscallField(error),
142
+ ...messageField(error),
143
+ };
144
+ const cause = error.cause;
145
+ if (cause instanceof Error) {
146
+ const causeFields = describeError(cause);
147
+ if (causeFields.error_name)
148
+ fields.cause_name = causeFields.error_name;
149
+ if (causeFields.error_code)
150
+ fields.cause_code = causeFields.error_code;
151
+ if (causeFields.error_detail)
152
+ fields.cause_detail = causeFields.error_detail;
153
+ }
154
+ return fields;
155
+ }
156
+ /**
157
+ * Is this the ordinary "there is nothing there yet" failure?
158
+ *
159
+ * Nearly every collector read has a legitimate empty state — no cursor before
160
+ * the first sync, no config before onboarding, no session before login — and
161
+ * logging that on every 15-minute tick would bury the failures that matter.
162
+ * A read that fails for any OTHER reason is the interesting one: a corrupt
163
+ * cursor and a permission-denied config both read as "first run" today, and
164
+ * that is the exact shape of BLI-2528.
165
+ */
166
+ export function isMissingFileFailure(error) {
167
+ const code = errorCode(error);
168
+ return code === "ENOENT" || code === "ENOTDIR";
169
+ }
170
+ function codeField(error) {
171
+ const code = errorCode(error);
172
+ return code ? { error_code: code } : {};
173
+ }
174
+ function errorCode(error) {
175
+ if (!error || typeof error !== "object")
176
+ return undefined;
177
+ const code = error.code;
178
+ if (typeof code === "string" && code)
179
+ return code;
180
+ if (typeof code === "number")
181
+ return String(code);
182
+ return undefined;
183
+ }
184
+ function syscallField(error) {
185
+ const syscall = error.syscall;
186
+ return typeof syscall === "string" && syscall ? { error_syscall: syscall } : {};
187
+ }
188
+ /**
189
+ * The message, or nothing at all when the code and syscall already carry the
190
+ * whole meaning. Dropping it is not lost information: it is a path.
191
+ */
192
+ function messageField(error) {
193
+ const syscall = error.syscall;
194
+ if (errorCode(error) && typeof syscall === "string" && syscall)
195
+ return {};
196
+ return detailField(error.message);
197
+ }
198
+ function detailField(message) {
199
+ const detail = redactedHealthDetail(message);
200
+ return detail ? { error_detail: detail } : {};
201
+ }
202
+ /** `String(value)` can itself throw on an exotic object; a describer must not. */
203
+ function safeString(value) {
204
+ try {
205
+ return String(value);
206
+ }
207
+ catch {
208
+ // Deliberately silent, and the only silence in this module that cannot be
209
+ // reported: the failure happened inside the reporter.
210
+ return "";
211
+ }
103
212
  }
@@ -9,6 +9,7 @@ import { isSamePath, normalizeCollectionRoots, } from "./root-normalization.js";
9
9
  import { summarizeLocalUploadSpool } from "./spool/local-spool.js";
10
10
  import { summarizeInstallEventOutbox } from "./spool/install-event-outbox.js";
11
11
  import { readRawEvidenceStagingState, summarizeStuckEvidence, } from "./raw-evidence-staging.js";
12
+ import { describeError, isMissingFileFailure } from "./health-detail.js";
12
13
  const localCollectorPackage = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
13
14
  export const LOCAL_COLLECTOR_VERSION = typeof localCollectorPackage.version === "string"
14
15
  ? localCollectorPackage.version
@@ -115,7 +116,16 @@ export async function pairLocalCollector(options = {}) {
115
116
  const homeDir = options.homeDir ?? os.homedir();
116
117
  const paths = getCollectorRuntimePaths(homeDir);
117
118
  await ensureRuntimeDirectories(paths);
118
- const config = await readLocalCollectorConfig(paths).catch(() => {
119
+ const config = await readLocalCollectorConfig(paths).catch((error) => {
120
+ // "Local config missing" is correct when it is absent. A config that
121
+ // exists and will not parse gets the same sentence and the same useless
122
+ // advice — run onboard again, which will not fix it (BLI-3238).
123
+ if (!isMissingFileFailure(error)) {
124
+ console.error("[local-state] collector config present but unreadable, reporting it as missing", JSON.stringify({
125
+ reason: "config_unreadable",
126
+ ...describeError(error),
127
+ }));
128
+ }
119
129
  throw new Error("Local config missing. Run `cockpit onboard` (or `cockpit install`) first, then retry `cockpit login`.");
120
130
  });
121
131
  const dashboardUrl = normalizeDashboardUrl(options.dashboardUrl ?? config.dashboard_url);
@@ -454,7 +464,18 @@ export async function readLocalSessionReference(paths, fallback = {}) {
454
464
  }
455
465
  return LocalUserSessionReferenceSchema.parse(rawSession);
456
466
  }
457
- catch {
467
+ catch (error) {
468
+ // `session_state: "missing"` is correct before login and says so loudly
469
+ // enough on its own. It is also what a session file that EXISTS but is
470
+ // corrupt, truncated or unreadable collapses to — a paired machine that
471
+ // silently reads as never-signed-in, which is indistinguishable in every
472
+ // downstream receipt (BLI-3238).
473
+ if (!isMissingFileFailure(error)) {
474
+ console.error("[local-state] session file present but unusable, reading as missing", JSON.stringify({
475
+ reason: "session_file_unusable",
476
+ ...describeError(error),
477
+ }));
478
+ }
458
479
  return LocalUserSessionReferenceSchema.parse({
459
480
  operator_id: fallback.operatorId ?? "unknown",
460
481
  auth_subject_id: "unknown",
@@ -480,7 +501,17 @@ export async function resolveGitBranch(repoRoot) {
480
501
  }
481
502
  return head ? `detached:${head.slice(0, 12)}` : "unknown";
482
503
  }
483
- catch {
504
+ catch (error) {
505
+ // A folder that is not a git repo is a legitimate workspace under the
506
+ // session-first commandment, so a missing `.git` stays quiet. A `.git`
507
+ // that exists and cannot be read is a different thing: every session
508
+ // collected from this repo gets branch `unknown` and nothing says why.
509
+ if (!isMissingFileFailure(error)) {
510
+ console.error("[local-state] could not read HEAD, branch recorded as unknown", JSON.stringify({
511
+ reason: "git_head_unreadable",
512
+ ...describeError(error),
513
+ }));
514
+ }
484
515
  return "unknown";
485
516
  }
486
517
  }
@@ -499,13 +530,30 @@ async function ensureRuntimeDirectories(paths) {
499
530
  await fs.mkdir(paths.cursors_dir, { recursive: true, mode: 0o700 });
500
531
  await fs.mkdir(paths.work_contexts_dir, { recursive: true, mode: 0o700 });
501
532
  if (process.platform !== "win32") {
502
- await Promise.all([
503
- fs.chmod(paths.config_dir, 0o700).catch(() => undefined),
504
- fs.chmod(paths.state_dir, 0o700).catch(() => undefined),
505
- fs.chmod(paths.spool_dir, 0o700).catch(() => undefined),
506
- fs.chmod(paths.cursors_dir, 0o700).catch(() => undefined),
507
- fs.chmod(paths.work_contexts_dir, 0o700).catch(() => undefined),
508
- ]);
533
+ // The `mode` above only applies to directories this call CREATES, so these
534
+ // chmods are what actually tightens a directory that already existed with
535
+ // looser bits. Failing means the operator's device token and cursors stay
536
+ // world-readable — non-fatal, deliberately, but not something to find out
537
+ // about never (BLI-3238). Reported by directory name only, never a path.
538
+ const tightened = [
539
+ ["config_dir", fs.chmod(paths.config_dir, 0o700)],
540
+ ["state_dir", fs.chmod(paths.state_dir, 0o700)],
541
+ ["spool_dir", fs.chmod(paths.spool_dir, 0o700)],
542
+ ["cursors_dir", fs.chmod(paths.cursors_dir, 0o700)],
543
+ ["work_contexts_dir", fs.chmod(paths.work_contexts_dir, 0o700)],
544
+ ];
545
+ await Promise.all(tightened.map(async ([name, work]) => {
546
+ try {
547
+ await work;
548
+ }
549
+ catch (error) {
550
+ console.error("[local-state] could not restrict a runtime directory to owner-only", JSON.stringify({
551
+ reason: "runtime_dir_chmod_failed",
552
+ directory: name,
553
+ ...describeError(error),
554
+ }));
555
+ }
556
+ }));
509
557
  }
510
558
  }
511
559
  function workContextFile(paths, worktreeFingerprint) {
@@ -545,8 +593,38 @@ function stableWorkContextId(input) {
545
593
  function sha256(value) {
546
594
  return crypto.createHash("sha256").update(value, "utf8").digest("hex");
547
595
  }
596
+ /**
597
+ * The one reader behind the config file, the session file and every work
598
+ * context — and therefore the one place worth reporting from.
599
+ *
600
+ * Roughly twenty call sites swallow this to `null` or a default with
601
+ * `.catch(() => null)`, each of them asking a reasonable question ("is this
602
+ * machine set up?") to which "no" is a legitimate answer. What none of them
603
+ * could distinguish is "no, nothing is there" from "yes, and it is corrupt or
604
+ * unreadable" — so the distinction is drawn HERE, once, rather than in twenty
605
+ * places where it would be twenty chances to forget (BLI-3238).
606
+ *
607
+ * The error still propagates unchanged; callers keep whatever they decided.
608
+ */
548
609
  async function readJsonFile(filePath) {
549
- return JSON.parse(await fs.readFile(filePath, "utf8"));
610
+ try {
611
+ return JSON.parse(await fs.readFile(filePath, "utf8"));
612
+ }
613
+ catch (error) {
614
+ // Absent is the ordinary pre-onboarding state on every one of these files
615
+ // and stays quiet; anything else means state exists and cannot be used.
616
+ if (!isMissingFileFailure(error)) {
617
+ console.error("[local-state] a collector state file exists but could not be read", JSON.stringify({
618
+ reason: "state_file_unreadable",
619
+ // Which file, without the path: the basename of these is a fixed
620
+ // vocabulary (`config.json`, `session.json`, a work-context
621
+ // fingerprint) and carries no repo or operator name.
622
+ state_file: path.basename(filePath),
623
+ ...describeError(error),
624
+ }));
625
+ }
626
+ throw error;
627
+ }
550
628
  }
551
629
  async function writeJsonFile(filePath, value) {
552
630
  await fs.mkdir(path.dirname(filePath), { recursive: true });
@@ -643,6 +721,15 @@ async function readResponseJson(response) {
643
721
  return JSON.parse(text);
644
722
  }
645
723
  catch {
724
+ // Pairing path. A captive portal or a proxy answering with HTML is the
725
+ // classic reason `cockpit login` fails on a new machine and the operator
726
+ // sees only "pair request failed". The body is never logged; its shape is.
727
+ console.error("[local-state] pairing reply was not JSON", JSON.stringify({
728
+ reason: "response_body_not_json",
729
+ http_status: response.status,
730
+ byte_size: text.length,
731
+ content_type: response.headers.get("content-type") ?? "none",
732
+ }));
646
733
  return { message: text };
647
734
  }
648
735
  }
@@ -294,6 +294,9 @@ function canonicalPath(input, pathApi = path, realpath = realpathSync) {
294
294
  return pathApi.resolve(realpath(resolved));
295
295
  }
296
296
  catch {
297
+ // Deliberately silent (BLI-3238), per the comment above: a path that does
298
+ // not exist yet is the ordinary input here, and the lexical resolve is the
299
+ // documented behavior for it, not a fallback after a failure.
297
300
  return resolved;
298
301
  }
299
302
  }
@@ -3,6 +3,13 @@
3
3
  * fallback that produced a worktree identity. Session-first terminal labels
4
4
  * are uploadable only when attribution attached an approved-root synthetic
5
5
  * workspace; the label stays terminal and no repository provenance is invented.
6
+ *
7
+ * A `false` answer is a decision, and every caller that builds a session row
8
+ * MUST record it as one — `upload_state: "not_uploaded"` plus
9
+ * `notUploadableAttributionStateReason(<attribution reason>)` from
10
+ * `@bli-cockpit/telemetry-core`. Spreading `{}` on this branch is what put 1,256
11
+ * production sessions in `ambient_codex_sessions` with a NULL upload_state and a
12
+ * NULL upload_reason (BLI-3272): withheld, and silent about it.
6
13
  */
7
14
  export function isRawEvidenceUploadableAttributionState(state, hasApprovedWorkspace = false) {
8
15
  return (state === "attributed" ||
@@ -227,6 +227,11 @@ async function packContentFingerprint(dir) {
227
227
  .digest("hex");
228
228
  }
229
229
  catch {
230
+ // Deliberately silent (BLI-3238) — and it is the one silence here that is
231
+ // already reported. `null` means "not fingerprintable", the dedup sweep
232
+ // counts every one of them as `unfingerprintable_dirs`, and that count is
233
+ // logged on EVERY sweep including the clean one. A line per pack would
234
+ // add nothing the sweep does not already say.
230
235
  return null;
231
236
  }
232
237
  }
@@ -2,6 +2,7 @@ import { DELIVERY_BACKOFF_HOLDING } from "@bli-cockpit/telemetry-core";
2
2
  import crypto from "node:crypto";
3
3
  import fs from "node:fs/promises";
4
4
  import path from "node:path";
5
+ import { describeError, isMissingFileFailure } from "./health-detail.js";
5
6
  /**
6
7
  * Durable local state for raw-evidence STAGING and DELIVERY ATTEMPTS.
7
8
  *
@@ -67,7 +68,17 @@ export async function readRawEvidenceStagingState(stateDir) {
67
68
  const raw = JSON.parse(await fs.readFile(rawEvidenceStagingStatePath(stateDir), "utf8"));
68
69
  return parseStagingState(raw);
69
70
  }
70
- catch {
71
+ catch (error) {
72
+ // Empty is right before the first staged pack. Unreadable is not: this
73
+ // file carries the delivery-attempt history, so losing it silently resets
74
+ // every backoff window and re-offers objects the server already refused
75
+ // (BLI-3066 is what that costs; BLI-3238 is why it now says so).
76
+ if (!isMissingFileFailure(error)) {
77
+ console.error("[raw-evidence-staging] staging state unreadable, delivery history reset", JSON.stringify({
78
+ reason: "staging_state_unreadable",
79
+ ...describeError(error),
80
+ }));
81
+ }
71
82
  return emptyRawEvidenceStagingState();
72
83
  }
73
84
  }