@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
@@ -7,6 +7,7 @@ import { makeSourceAdapterIdentity, } from "./common.js";
7
7
  import { collectAgentImageEvidenceFromJsonlFile, } from "./agent-image-evidence.js";
8
8
  import { defaultCodexSessionDirs, } from "./codex-attribution.js";
9
9
  import { isLiveRawEvidenceSyncAttribution } from "../raw-evidence-attribution-policy.js";
10
+ import { describeError } from "../health-detail.js";
10
11
  import { contentKeyedRawEvidencePackId, DELIVERY_BACKOFF_BYPASS_REASON, DELIVERY_BACKOFF_HOLDING_REASON, deliveryBackoffApplies, evidenceSourceKey, heldSourceKeys, readRawEvidenceStagingState, recordStagedObject, resolveStagedObject, } from "../raw-evidence-staging.js";
11
12
  import { isSecretLikePath, safeKeySegment, sha256, shortHash, } from "./raw-evidence-keys.js";
12
13
  import { sanitizeTextEvidenceForUpload } from "./raw-evidence-sanitize.js";
@@ -868,7 +869,10 @@ async function collectOneEvidenceFile(collection, options) {
868
869
  }));
869
870
  }
870
871
  const evidenceBytes = sanitized.bytes;
872
+ // Both branches carry a record now (BLI-3277), so "was anything replaced?" is
873
+ // the status, never the presence of `redaction`.
871
874
  const redaction = sanitized.redaction;
875
+ const wasRedacted = sanitized.status === "redacted";
872
876
  const contentHash = sha256(evidenceBytes);
873
877
  if (collection.skipContentHashes.has(contentHash)) {
874
878
  collection.reused.push({
@@ -900,7 +904,7 @@ async function collectOneEvidenceFile(collection, options) {
900
904
  localPath: staged.local_path,
901
905
  relativePath,
902
906
  mediaType: options.mediaType,
903
- redactedSummary: redaction
907
+ redactedSummary: wasRedacted
904
908
  ? `${options.redactedSummary} Secret-like values were deterministically redacted before upload.`
905
909
  : options.redactedSummary,
906
910
  redaction,
@@ -923,7 +927,16 @@ async function readEvidenceFileWithinCap(filePath, maxFileBytes) {
923
927
  try {
924
928
  stat = await fs.stat(filePath);
925
929
  }
926
- catch {
930
+ catch (error) {
931
+ // The caller turns this into the `file_read_failed` skip label, which is
932
+ // the one label an operator can do nothing with. The file was discovered
933
+ // moments ago, so a failure here is a rotated session, a permission
934
+ // problem or a dead symlink — three different answers (BLI-3238).
935
+ console.error("[raw-evidence] evidence file could not be stat'd", JSON.stringify({
936
+ reason: "file_read_failed",
937
+ stage: "stat",
938
+ ...describeError(error),
939
+ }));
927
940
  return { status: "read_failed" };
928
941
  }
929
942
  if (stat.size > maxFileBytes)
@@ -933,7 +946,12 @@ async function readEvidenceFileWithinCap(filePath, maxFileBytes) {
933
946
  try {
934
947
  bytes = await fs.readFile(filePath);
935
948
  }
936
- catch {
949
+ catch (error) {
950
+ console.error("[raw-evidence] evidence file could not be read", JSON.stringify({
951
+ reason: "file_read_failed",
952
+ stage: "read",
953
+ ...describeError(error),
954
+ }));
937
955
  return { status: "read_failed" };
938
956
  }
939
957
  if (maxFileBytes && bytes.byteLength > maxFileBytes) {
@@ -999,7 +1017,16 @@ async function collectGitDiffFiles(collection, repoRoot) {
999
1017
  try {
1000
1018
  diff = await runGitDiff(target.args, repoRoot);
1001
1019
  }
1002
- catch {
1020
+ catch (error) {
1021
+ // `git_diff_failed` is the skip label and stays. It covers git not being
1022
+ // installed, the folder not being a repo, a locked index and a diff that
1023
+ // exceeded the child-process buffer — and the diff is half the evidence
1024
+ // for what someone actually changed, so losing it quietly matters.
1025
+ console.error("[raw-evidence] git diff failed", JSON.stringify({
1026
+ reason: "git_diff_failed",
1027
+ diff_target: target.label,
1028
+ ...describeError(error),
1029
+ }));
1003
1030
  collection.skipped.push({
1004
1031
  kind: "git_diff",
1005
1032
  label: target.label,
@@ -1050,6 +1077,7 @@ async function stageOneGitDiff(collection, target) {
1050
1077
  }
1051
1078
  const raw = sanitized.bytes;
1052
1079
  const redaction = sanitized.redaction;
1080
+ const wasRedacted = sanitized.status === "redacted";
1053
1081
  const contentHash = sha256(raw);
1054
1082
  if (collection.skipContentHashes.has(contentHash)) {
1055
1083
  collection.reused.push({
@@ -1093,7 +1121,7 @@ async function stageOneGitDiff(collection, target) {
1093
1121
  sourceKey: diffSourceKey,
1094
1122
  mediaType: "text/x-diff",
1095
1123
  redactedSummary: gitDiffSummary(target.label, {
1096
- redacted: Boolean(redaction),
1124
+ redacted: wasRedacted,
1097
1125
  truncated: target.truncated,
1098
1126
  }),
1099
1127
  redaction,
@@ -1124,6 +1152,11 @@ async function walkJsonlFiles(dir, cutoffMs) {
1124
1152
  const out = [];
1125
1153
  const stack = Array.isArray(dir) ? [...dir] : [dir];
1126
1154
  const seen = new Set();
1155
+ // Counted rather than logged per directory: a wide walk can hit many, and
1156
+ // the useful signal is "N directories in the session store were skipped and
1157
+ // here is the first reason", not N near-identical lines (BLI-3238).
1158
+ let unreadableDirCount = 0;
1159
+ let firstUnreadableDir = null;
1127
1160
  while (stack.length > 0) {
1128
1161
  const current = stack.pop();
1129
1162
  if (!current || isSecretLikePath(current))
@@ -1132,7 +1165,11 @@ async function walkJsonlFiles(dir, cutoffMs) {
1132
1165
  try {
1133
1166
  entries = await fs.readdir(current, { withFileTypes: true });
1134
1167
  }
1135
- catch {
1168
+ catch (error) {
1169
+ // A directory that cannot be listed hides every session under it, and
1170
+ // the walk's only visible effect is a smaller file count.
1171
+ unreadableDirCount += 1;
1172
+ firstUnreadableDir ??= describeError(error);
1136
1173
  continue;
1137
1174
  }
1138
1175
  for (const entry of entries) {
@@ -1155,6 +1192,14 @@ async function walkJsonlFiles(dir, cutoffMs) {
1155
1192
  out.push({ file: full, mtimeMs: stat.mtimeMs });
1156
1193
  }
1157
1194
  }
1195
+ if (unreadableDirCount > 0) {
1196
+ console.error("[raw-evidence] session-store directories skipped during the walk", JSON.stringify({
1197
+ reason: "session_dir_unreadable",
1198
+ unreadable_dir_count: unreadableDirCount,
1199
+ found_file_count: out.length,
1200
+ ...firstUnreadableDir,
1201
+ }));
1202
+ }
1158
1203
  out.sort((a, b) => b.mtimeMs - a.mtimeMs);
1159
1204
  return out.map((entry) => entry.file);
1160
1205
  }
@@ -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
  }
@@ -4,7 +4,7 @@ export function shortSha(value) {
4
4
  return value ? value.slice(0, 12) : "unknown";
5
5
  }
6
6
  export function displayTicketId(ticketId) {
7
- return ticketId ?? "general ambient";
7
+ return ticketId ?? "none (general work)";
8
8
  }
9
9
  export function displayWorkLabel(status) {
10
10
  if (status.work_label && status.work_id)
@@ -12,13 +12,16 @@ export function displayWorkLabel(status) {
12
12
  return status.work_label ?? status.work_id ?? "no active work context";
13
13
  }
14
14
  function sourceFunnelLine(label, counts) {
15
- const readFailures = counts.read_failures > 0 ? `, read_failures ${counts.read_failures}` : "";
16
- return `${label} sessions: attributed ${counts.attributed}, fallback ${counts.attributed_fallback}, ambiguous ${counts.ambiguous}, unattributed ${counts.unattributed}, skipped ${counts.skipped}, stale ${counts.stale}${readFailures}`;
15
+ // Same counters, plain words. Every counter still prints, including the ones
16
+ // that are zero: a funnel that hides a bucket cannot show where sessions go.
17
+ const readFailures = counts.read_failures > 0 ? `, ${counts.read_failures} could not be read` : "";
18
+ return `${label} sessions: ${counts.attributed} matched to a repo, ${counts.attributed_fallback} by best guess, ${counts.ambiguous} unclear which repo, ${counts.unattributed} could not be matched, ${counts.stale} too old, ${counts.skipped} skipped${readFailures}`;
17
19
  }
18
20
  function attributionReportLine(summary) {
21
+ // The parenthesised value is the machine reason label and never changes.
19
22
  return summary.report_posted
20
- ? "Attribution report: recorded"
21
- : `Attribution report: skipped (${summary.report_reason})`;
23
+ ? "Session report: saved"
24
+ : `Session report: skipped (${summary.report_reason})`;
22
25
  }
23
26
  /**
24
27
  * One funnel line per source, an anomaly diagnostics line only when something
@@ -41,30 +44,31 @@ function claudeDiagnosticsLine(summary) {
41
44
  const claude = summary.claude;
42
45
  const parts = [];
43
46
  if (claude.sidecars_skipped)
44
- parts.push(`sidecars_skipped ${claude.sidecars_skipped}`);
47
+ parts.push(`${claude.sidecars_skipped} helper sessions skipped`);
45
48
  if (claude.sidecars_capped)
46
- parts.push(`sidecars_capped ${claude.sidecars_capped}`);
49
+ parts.push(`${claude.sidecars_capped} helper sessions cut short`);
47
50
  if (claude.sidecars_failed)
48
- parts.push(`sidecars_failed ${claude.sidecars_failed}`);
51
+ parts.push(`${claude.sidecars_failed} helper sessions could not be read`);
49
52
  if (claude.mains_oversized)
50
- parts.push(`mains_oversized ${claude.mains_oversized}`);
53
+ parts.push(`${claude.mains_oversized} sessions too big`);
51
54
  if (claude.oversized_lines_skipped)
52
- parts.push(`oversized_lines_skipped ${claude.oversized_lines_skipped}`);
55
+ parts.push(`${claude.oversized_lines_skipped} oversized lines skipped`);
53
56
  if (claude.project_dirs_skipped)
54
- parts.push(`project_dirs_skipped ${claude.project_dirs_skipped}`);
57
+ parts.push(`${claude.project_dirs_skipped} folders skipped`);
55
58
  if (claude.sessions_schema_drift)
56
- parts.push(`schema_drift ${claude.sessions_schema_drift}`);
59
+ parts.push(`${claude.sessions_schema_drift} sessions in an unexpected format`);
57
60
  if (claude.growth_damped)
58
- parts.push(`growth_damped ${claude.growth_damped}`);
61
+ parts.push(`${claude.growth_damped} fast-growing sessions slowed down`);
59
62
  if (claude.first_run_backfill)
60
- parts.push("first_run_backfill");
63
+ parts.push("first run, catching up on history");
61
64
  if (summary.files_deferred_byte_budget)
62
- parts.push(`deferred_byte_budget ${summary.files_deferred_byte_budget}`);
65
+ parts.push(`${summary.files_deferred_byte_budget} files held back, size limit`);
63
66
  if (summary.files_deferred_object_budget)
64
- parts.push(`deferred_object_budget ${summary.files_deferred_object_budget}`);
65
- return parts.length > 0 ? `Claude diagnostics: ${parts.join(", ")}` : null;
67
+ parts.push(`${summary.files_deferred_object_budget} files held back, file-count limit`);
68
+ return parts.length > 0 ? `Claude problems: ${parts.join(", ")}` : null;
66
69
  }
67
70
  export function rawEvidenceSyncLine(sync) {
71
+ // The reason lists themselves are machine labels and stay verbatim.
68
72
  const failures = sync.raw_evidence_failure_reasons.length > 0
69
73
  ? ` failures: ${sync.raw_evidence_failure_reasons.join(",")}`
70
74
  : "";
@@ -75,15 +79,15 @@ export function rawEvidenceSyncLine(sync) {
75
79
  // Neither used to appear anywhere, which is how a 1,030-attempt loop stayed
76
80
  // invisible (BLI-3066).
77
81
  const held = sync.raw_evidence_delivery_held_count > 0
78
- ? ` held: ${sync.raw_evidence_delivery_held_count}`
82
+ ? `, ${sync.raw_evidence_delivery_held_count} waiting`
79
83
  : "";
80
84
  const stuck = sync.raw_evidence_stuck_object_count > 0
81
- ? ` stuck: ${sync.raw_evidence_stuck_object_count} (worst ${sync.raw_evidence_max_delivery_attempts} attempt(s) since ${sync.raw_evidence_oldest_delivery_failure_at ?? "unknown"})`
85
+ ? `, ${sync.raw_evidence_stuck_object_count} stuck (${sync.raw_evidence_max_delivery_attempts} tries since ${sync.raw_evidence_oldest_delivery_failure_at ?? "unknown"})`
82
86
  : "";
83
- return `Raw evidence: uploaded ${sync.raw_evidence_uploaded_object_count} object(s) in ${sync.raw_evidence_uploaded_chunk_count} chunk(s), reused ${sync.raw_evidence_reused_count}, failed ${sync.raw_evidence_failed_count}${held}${stuck}${failures}${retries}`;
87
+ return `Files: ${sync.raw_evidence_uploaded_object_count} uploaded, ${sync.raw_evidence_reused_count} already there, ${sync.raw_evidence_failed_count} failed${held}${stuck}${failures}${retries}`;
84
88
  }
85
89
  export function cursorStatusLine(sync) {
86
- return `Cursor: ${sync.cursor_tracked_object_count} durable object(s) tracked`;
90
+ return `Tracked so far: ${sync.cursor_tracked_object_count} uploaded item(s)`;
87
91
  }
88
92
  /**
89
93
  * One line that cannot say "fine" while an object has never been accepted.
@@ -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
  }
@@ -175,7 +193,7 @@ async function readAuthState(context) {
175
193
  if (session?.session_state === "valid" &&
176
194
  typeof session.device_token === "string" &&
177
195
  session.device_token) {
178
- return ok("authed", "device_token_present", "device token present");
196
+ return ok("authed", "device_token_present", "this machine is signed in");
179
197
  }
180
198
  return hardStop("authed", "pairing_required", [
181
199
  "device is not signed in.",
@@ -216,12 +234,12 @@ async function checkAutostartState(context) {
216
234
  exec,
217
235
  });
218
236
  if (result.status === "loaded") {
219
- return ok("autostart-alive", "already_installed", "autostart scheduler loaded");
237
+ return ok("autostart-alive", "already_installed", "background sync is running");
220
238
  }
221
239
  if (result.status === "unsupported") {
222
240
  return skipped("autostart-alive", "unsupported", result.message ?? "unsupported");
223
241
  }
224
- return needsFix("autostart-alive", result.status === "not_loaded" ? "not_loaded" : "absent", "autostart is not loaded");
242
+ return needsFix("autostart-alive", result.status === "not_loaded" ? "not_loaded" : "absent", "background sync is not running");
225
243
  }
226
244
  async function fixAutostartState(context) {
227
245
  const exec = context.io.exec;
@@ -241,7 +259,7 @@ async function fixAutostartState(context) {
241
259
  if (result.loaded === false) {
242
260
  return fail("autostart-alive", "autostart_load_failed", result.message ?? "operating-system scheduler load failed");
243
261
  }
244
- return ok("autostart-alive", "installed", "autostart installed and loaded");
262
+ return ok("autostart-alive", "installed", "background sync installed and running");
245
263
  }
246
264
  /**
247
265
  * Pure so it can be unit-tested without touching the real machine's home
@@ -261,10 +279,10 @@ export function backfillCompletionStepState(marker, roots) {
261
279
  }
262
280
  const oversized = marker?.oversized_skips;
263
281
  if (oversized && oversized.count > 0) {
264
- return ok("backfill-complete", "complete_with_oversized_skips", `backfill completion covers the current saved roots and both session sources ` +
265
- `(complete_with_oversized_skips · ${oversized.count} file${oversized.count === 1 ? "" : "s"} over the upload cap)`);
282
+ return ok("backfill-complete", "complete_with_oversized_skips", `caught up on old Codex and Claude sessions in every saved folder ` +
283
+ `(complete_with_oversized_skips · ${oversized.count} file${oversized.count === 1 ? "" : "s"} too big to upload)`);
266
284
  }
267
- return ok("backfill-complete", "complete", "backfill completion covers the current saved roots and both session sources");
285
+ return ok("backfill-complete", "complete", "caught up on old Codex and Claude sessions in every saved folder");
268
286
  }
269
287
  async function checkBackfillState(context) {
270
288
  const paths = getCollectorRuntimePaths();
@@ -275,10 +293,10 @@ async function checkBackfillState(context) {
275
293
  return covered;
276
294
  const lock = await inspectBackfillLock(paths);
277
295
  if (lock.held) {
278
- return needsFix("backfill-complete", "backfill_already_running", `backfill completion is not yet proven; another run holds the lock since ${lock.held_since ?? "unknown"}`);
296
+ return needsFix("backfill-complete", "backfill_already_running", `another catch-up run is still going, and this one has not finished yet (running since ${lock.held_since ?? "unknown"})`);
279
297
  }
280
298
  const cursor = await readBackfillCursor(paths);
281
- return needsFix("backfill-complete", cursor.updated_at ? "partial" : "never_run", "backfill completion marker missing");
299
+ return needsFix("backfill-complete", cursor.updated_at ? "partial" : "never_run", "the catch-up over your old sessions has not finished");
282
300
  }
283
301
  async function fixBackfillState(context) {
284
302
  const capture = capturedIo(context.io, !context.command.json);
@@ -308,20 +326,20 @@ async function fixBackfillState(context) {
308
326
  }
309
327
  async function checkGcState(context) {
310
328
  if (context.io.env["COCKPIT_DISABLE_GC"] === "1") {
311
- return skipped("gc-checked", "skipped_disabled", "raw-evidence GC disabled");
329
+ return skipped("gc-checked", "skipped_disabled", "cleanup is switched off");
312
330
  }
313
331
  const paths = getCollectorRuntimePaths();
314
332
  const marker = path.join(paths.state_dir, ".last-raw-evidence-gc");
315
333
  const info = await fs.stat(marker).catch(() => null);
316
334
  if (info && Date.now() - info.mtimeMs < GC_MIN_INTERVAL_MS) {
317
- return skipped("gc-checked", "skipped_throttled", "raw-evidence GC ran within 24h");
335
+ return skipped("gc-checked", "skipped_throttled", "cleanup already ran today");
318
336
  }
319
- return needsFix("gc-checked", "due", "raw-evidence GC is due");
337
+ return needsFix("gc-checked", "due", "cleanup is due");
320
338
  }
321
339
  async function fixGcState(context) {
322
340
  const result = await runRawEvidenceLocalGc(getCollectorRuntimePaths(), context.io.env);
323
341
  if (result.skipped) {
324
- return skipped("gc-checked", "skipped_throttled", "raw-evidence GC skipped");
342
+ return skipped("gc-checked", "skipped_throttled", "cleanup already ran today");
325
343
  }
326
344
  if (result.removed_dirs === 0) {
327
345
  return ok("gc-checked", "nothing_eligible", rawEvidenceGcSummary(result));
@@ -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
  }
@@ -422,8 +449,8 @@ async function fixSyncState(context) {
422
449
  if (result.code !== 0) {
423
450
  const draining = syncBacklogDrainingVerdict(parsed);
424
451
  if (draining) {
425
- return needsFix("sync-fresh", "backlog_draining", `${repoRoot}: raw-evidence backlog is still draining (${draining.remainingObjects} ` +
426
- `object${draining.remainingObjects === 1 ? "" : "s"} deferred this tick); ` +
452
+ return needsFix("sync-fresh", "backlog_draining", `${repoRoot}: still catching up (${draining.remainingObjects} ` +
453
+ `file${draining.remainingObjects === 1 ? "" : "s"} left for later this run); ` +
427
454
  "rerun `cockpit sync` to continue");
428
455
  }
429
456
  return fail("sync-fresh", status ?? "sync_failed", `sync failed for ${repoRoot}`);
@@ -464,9 +491,11 @@ function writeDoctorOutput(command, io, rows) {
464
491
  return;
465
492
  }
466
493
  writeLine(io.stdout, command.dryRun ? "Cockpit doctor dry-run" : "Cockpit doctor");
467
- writeLine(io.stdout, "state step code result");
494
+ // The machine `code` stays in `--json`; a human reading the table wants the
495
+ // sentence, not the label (BLI-3194).
496
+ writeLine(io.stdout, "state step result");
468
497
  for (const row of rows) {
469
- writeLine(io.stdout, `${doctorMark(row)} ${row.id.padEnd(20)} ${row.code.padEnd(23)} ${oneLine(row.message)}`);
498
+ writeLine(io.stdout, `${doctorMark(row)} ${row.id.padEnd(20)} ${oneLine(row.message)}`);
470
499
  }
471
500
  const explanations = rows.filter((row) => (row.hardStop || row.status === "fail") && row.message.includes("\n"));
472
501
  for (const row of explanations) {
@@ -545,6 +574,10 @@ function parseNpmVersion(stdout) {
545
574
  return typeof parsed === "string" && parsed.trim() ? parsed.trim() : null;
546
575
  }
547
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.
548
581
  return trimmed.replace(/^"|"$/gu, "") || null;
549
582
  }
550
583
  }