@bli-cockpit/cli 0.2.29 → 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 (47) hide show
  1. package/README.md +22 -15
  2. package/dist/adapters/agent-image-evidence.js +4 -0
  3. package/dist/adapters/attribution-core.js +12 -0
  4. package/dist/adapters/car-state.js +12 -1
  5. package/dist/adapters/claude-attribution.js +81 -7
  6. package/dist/adapters/codex-attribution.js +37 -3
  7. package/dist/adapters/raw-evidence-manifest.js +12 -1
  8. package/dist/adapters/raw-evidence-pack-store.js +28 -3
  9. package/dist/adapters/raw-evidence-sanitize.js +46 -2
  10. package/dist/adapters/raw-evidence.js +51 -6
  11. package/dist/agent-rules.js +34 -3
  12. package/dist/autostart.js +3 -0
  13. package/dist/backfill-lock.js +22 -1
  14. package/dist/commands/backfill.js +41 -7
  15. package/dist/commands/cli-io.js +3 -0
  16. package/dist/commands/collection-report.js +25 -21
  17. package/dist/commands/doctor.js +54 -21
  18. package/dist/commands/install-receipts.js +43 -6
  19. package/dist/commands/install-update.js +3 -1
  20. package/dist/commands/local-args.js +4 -0
  21. package/dist/commands/local-auth.js +14 -0
  22. package/dist/commands/local-help.js +9 -9
  23. package/dist/commands/local.js +34 -15
  24. package/dist/commands/public-root.js +1 -1
  25. package/dist/commands/session-sync.js +35 -6
  26. package/dist/commands/status.js +82 -27
  27. package/dist/cursors/backfill-cursor.js +23 -2
  28. package/dist/cursors/raw-evidence-cursor.js +14 -1
  29. package/dist/discovery-limits.js +12 -1
  30. package/dist/evidence-upload-client.js +41 -4
  31. package/dist/health-detail.js +111 -2
  32. package/dist/local-state.js +98 -11
  33. package/dist/onboarding-roots.js +3 -0
  34. package/dist/raw-evidence-attribution-policy.js +7 -0
  35. package/dist/raw-evidence-gc.js +6 -1
  36. package/dist/raw-evidence-staging.js +12 -1
  37. package/dist/repo-identity.js +15 -1
  38. package/dist/scheduled-self-update.js +14 -1
  39. package/dist/spool/install-event-outbox.js +11 -1
  40. package/dist/spool/local-spool.js +3 -0
  41. package/dist/sync-lock.js +24 -1
  42. package/dist/upload-agent-artifacts.js +11 -1
  43. package/dist/upload-envelope.js +30 -3
  44. package/dist/upload-http.js +10 -0
  45. package/dist/upload-session-reports.js +36 -3
  46. package/dist/upload.js +16 -5
  47. package/package.json +2 -2
@@ -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" ||
@@ -68,7 +68,7 @@ export async function runRawEvidenceLocalGc(paths, env = process.env, now = new
68
68
  };
69
69
  }
70
70
  export function rawEvidenceGcSummary(result) {
71
- return `raw-evidence GC: removed ${result.removed_dirs} dirs, freed ~${formatMb(result.freed_bytes)} MB`;
71
+ return `Cleaned up: removed ${result.removed_dirs} old folders, freed ~${formatMb(result.freed_bytes)} MB`;
72
72
  }
73
73
  /**
74
74
  * Collapse byte-identical staged packs down to one survivor.
@@ -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
  }
@@ -4,6 +4,7 @@ import fs from "node:fs/promises";
4
4
  import path from "node:path";
5
5
  import { promisify } from "node:util";
6
6
  import { containsPath, isCodexWorktreePath } from "./root-normalization.js";
7
+ import { describeError, isMissingFileFailure } from "./health-detail.js";
7
8
  const execFileAsync = promisify(execFile);
8
9
  const SKIPPED_DIR_NAMES = new Set([
9
10
  ".cache",
@@ -352,7 +353,16 @@ async function resolveBranchFromHead(repoRoot) {
352
353
  }
353
354
  return head ? `detached:${head.slice(0, 12)}` : "unknown";
354
355
  }
355
- catch {
356
+ catch (error) {
357
+ // Not a repo → quiet, that is an ordinary approved folder. A `.git` that
358
+ // exists and will not read → every session from this worktree is labelled
359
+ // branch `unknown` and, until BLI-3238, nothing said why.
360
+ if (!isMissingFileFailure(error)) {
361
+ console.error("[repo-identity] could not read HEAD, branch recorded as unknown", JSON.stringify({
362
+ reason: "git_head_unreadable",
363
+ ...describeError(error),
364
+ }));
365
+ }
356
366
  return "unknown";
357
367
  }
358
368
  }
@@ -377,6 +387,10 @@ export function normalizeGitOrigin(rawOrigin) {
377
387
  return normalizeOriginParts(url.hostname, url.pathname);
378
388
  }
379
389
  catch {
390
+ // Deliberately silent (BLI-3238). `new URL` is being used as the test for
391
+ // "is this origin URL-shaped?", and a plain path or an unusual remote form
392
+ // failing to parse IS the answer — the fallback below is the intended
393
+ // normalization for exactly that case, not a degradation.
380
394
  return trimmed
381
395
  .replace(/\.git$/i, "")
382
396
  .replace(/^\/+|\/+$/g, "")
@@ -1,5 +1,6 @@
1
1
  import fs from "node:fs/promises";
2
2
  import path from "node:path";
3
+ import { describeError } from "./health-detail.js";
3
4
  const SELF_UPDATE_MIN_INTERVAL_MS = 24 * 60 * 60 * 1000;
4
5
  export const SELF_UPDATE_THROTTLE_MARKER = ".last-self-update-check";
5
6
  /**
@@ -179,7 +180,16 @@ async function probeInstalledCliVersion(exec) {
179
180
  const version = parsed.dependencies?.["@bli-cockpit/cli"]?.version;
180
181
  return typeof version === "string" && version.trim() ? version.trim() : null;
181
182
  }
182
- catch {
183
+ catch (error) {
184
+ // `null` means "installed version unknown", which suppresses the whole
185
+ // self-update decision including the BLI-2678 forced-version floor. A
186
+ // fleet stuck on an old CLI because `npm ls` started printing something
187
+ // unparseable would look exactly like a fleet that is up to date.
188
+ console.error("[self-update] could not read the installed CLI version from npm ls", JSON.stringify({
189
+ reason: "installed_version_unreadable",
190
+ byte_size: result.stdout.length,
191
+ ...describeError(error),
192
+ }));
183
193
  return null;
184
194
  }
185
195
  }
@@ -197,6 +207,9 @@ function parseNpmVersionField(stdout) {
197
207
  return typeof parsed === "string" && parsed.trim() ? parsed.trim() : null;
198
208
  }
199
209
  catch {
210
+ // Deliberately silent (BLI-3238), same as doctor.ts's copy: the parse is
211
+ // the test for whether this npm quoted its output, and the unquoted
212
+ // fallback is the intended handling of the other form.
200
213
  return trimmed.replace(/^"|"$/gu, "") || null;
201
214
  }
202
215
  }
@@ -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 OUTBOX_DIRECTORY = "install-events";
5
6
  const MAX_PENDING_ENTRIES = 100;
6
7
  const MAX_EVENTS_PER_ENTRY = 40;
@@ -44,12 +45,21 @@ export async function readPendingInstallEventEntries(paths) {
44
45
  try {
45
46
  const parsed = parseEntry(JSON.parse(await fs.readFile(filePath, "utf8")));
46
47
  if (!parsed) {
48
+ console.error("[install-outbox] discarding an entry that does not match the schema", JSON.stringify({ reason: "outbox_entry_invalid" }));
47
49
  await fs.rm(filePath, { force: true });
48
50
  continue;
49
51
  }
50
52
  entries.push(parsed);
51
53
  }
52
- catch {
54
+ catch (error) {
55
+ // This branch DELETES a queued receipt. Whatever the reason — truncated
56
+ // write, bad JSON, no read permission — the machine loses a health event
57
+ // it was holding for the server, so it says which reason it was before
58
+ // dropping it (BLI-3238).
59
+ console.error("[install-outbox] discarding an unreadable entry", JSON.stringify({
60
+ reason: "outbox_entry_unreadable",
61
+ ...describeError(error),
62
+ }));
53
63
  await fs.rm(filePath, { force: true }).catch(() => undefined);
54
64
  }
55
65
  }
@@ -416,6 +416,9 @@ async function fsyncDirectoryBestEffort(directoryPath) {
416
416
  catch {
417
417
  // Directory fsync is not supported by every host/filesystem (notably some
418
418
  // Windows versions). The file itself was fsynced before the atomic rename.
419
+ // Deliberately silent (BLI-3238): on those hosts this fails on every
420
+ // single write, so a line here would be pure noise on exactly the
421
+ // platform the collector most needs readable logs on.
419
422
  }
420
423
  finally {
421
424
  await handle?.close().catch(() => undefined);
package/dist/sync-lock.js CHANGED
@@ -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
  /**
5
6
  * Single-flight lock for `cockpit sync`. A launchd timer and a manual sync can
6
7
  * fire at the same time and interleave the cursor read-modify-write; this lock
@@ -71,10 +72,27 @@ async function tryExclusiveCreate(lockPath, token, now) {
71
72
  await handle.close();
72
73
  return true;
73
74
  }
74
- catch {
75
+ catch (error) {
76
+ // EEXIST is the whole point of `wx`: another sync holds the lock, and the
77
+ // caller reports `sync_already_running`. Any OTHER code — no permission on
78
+ // the cursors directory, a full disk, a read-only volume — produces the
79
+ // identical false answer, and every sync on the machine then exits saying
80
+ // another one is running while none ever is (BLI-3238).
81
+ if (!isLockHeldError(error)) {
82
+ console.error("[sync-lock] could not create the lock file; reporting the lock as held", JSON.stringify({
83
+ reason: "sync_lock_create_failed",
84
+ ...describeError(error),
85
+ }));
86
+ }
75
87
  return false;
76
88
  }
77
89
  }
90
+ /** `wx` refusing because the lock exists — the ordinary contended case. */
91
+ function isLockHeldError(error) {
92
+ return (typeof error === "object" &&
93
+ error !== null &&
94
+ error.code === "EEXIST");
95
+ }
78
96
  async function writeLock(lockPath, token, now) {
79
97
  await fs.writeFile(lockPath, serializeLock(token, now), { mode: 0o600 });
80
98
  }
@@ -108,6 +126,11 @@ async function readLock(lockPath) {
108
126
  };
109
127
  }
110
128
  catch {
129
+ // Deliberately silent (BLI-3238). This read is the "is anyone holding it?"
130
+ // probe: no lock file, a half-written one, a lock being removed underneath
131
+ // us — every one of those means "not held", which is the answer the caller
132
+ // wants and acts on correctly. Failure IS the result here. The failing
133
+ // ACQUIRE above is the branch that owns the reporting.
111
134
  return null;
112
135
  }
113
136
  }