@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
@@ -11,7 +11,7 @@
11
11
  */
12
12
  import os from "node:os";
13
13
  import { errorMessage, writeLine } from "./cli-io.js";
14
- import { maskLocalIdentifiers, redactedHealthDetail, } from "../health-detail.js";
14
+ import { describeError, maskLocalIdentifiers, redactedHealthDetail, } from "../health-detail.js";
15
15
  import { getCollectorRuntimePaths, readLocalCollectorSessionFile, LOCAL_COLLECTOR_VERSION, } from "../local-state.js";
16
16
  import { redactSecretLikeContent } from "@bli-cockpit/telemetry-core";
17
17
  import { COLLECTION_ROOT_REQUIRED } from "../onboarding-roots.js";
@@ -74,7 +74,17 @@ export async function reportInstallEventsBestEffort(options) {
74
74
  })),
75
75
  });
76
76
  }
77
- catch {
77
+ catch (error) {
78
+ // The existing operator line survives, and is `--json`-only. This one is
79
+ // unconditional and carries the reason: the machine has just dropped the
80
+ // health receipts for a whole command, and a `--json` gate meant the
81
+ // normal interactive run said nothing at all (BLI-3238).
82
+ console.error("[install-receipts] could not queue install events; the receipts are lost", JSON.stringify({
83
+ reason: "local_write_failed",
84
+ command: options.command,
85
+ event_count: options.events.length,
86
+ ...describeError(error),
87
+ }));
78
88
  if (options.json) {
79
89
  writeLine(options.io.stderr, "Install event outbox unavailable: local_write_failed");
80
90
  }
@@ -112,9 +122,18 @@ export async function reportInstallEventsBestEffort(options) {
112
122
  if (!response.ok) {
113
123
  throw new Error(`http_${response.status}`);
114
124
  }
115
- const receipt = (await response
116
- .json()
117
- .catch(() => null));
125
+ const receipt = (await response.json().catch((error) => {
126
+ // This reply carries the server-published `min_cli_version` floor
127
+ // (BLI-2678). A body that will not parse means the floor is not
128
+ // observed on this tick and the forced-update path silently does
129
+ // nothing — while the 2xx above says the receipt landed fine.
130
+ console.error("[install-receipts] receipt body unreadable; no min_cli_version observed", JSON.stringify({
131
+ reason: "receipt_body_unreadable",
132
+ http_status: response.status,
133
+ ...describeError(error),
134
+ }));
135
+ return null;
136
+ }));
118
137
  if (typeof receipt?.min_cli_version === "string" &&
119
138
  receipt.min_cli_version.trim()) {
120
139
  observedMinCliVersion = receipt.min_cli_version.trim();
@@ -124,10 +143,28 @@ export async function reportInstallEventsBestEffort(options) {
124
143
  catch (error) {
125
144
  const failureReason = classifyInstallTelemetryError(error);
126
145
  failures.push(failureReason);
146
+ // The classified reason is the coarse bucket the outbox row keeps;
147
+ // beside it, what actually happened. `network_error` covers DNS,
148
+ // TLS, timeout and abort, and only one of those is worth waking up
149
+ // for (BLI-3238).
150
+ console.error("[install-receipts] install event delivery failed, entry kept for retry", JSON.stringify({
151
+ reason: failureReason,
152
+ outbox_id: entry.outbox_id,
153
+ ...describeError(error),
154
+ }));
127
155
  await recordInstallEventAttemptFailure(paths, entry, {
128
156
  attemptedAt: new Date().toISOString(),
129
157
  failureReason,
130
- }).catch(() => undefined);
158
+ }).catch((writeError) => {
159
+ // Double failure: delivery failed AND the retry bookkeeping did.
160
+ // The entry stays queued, so nothing is lost, but the attempt
161
+ // count stops advancing and the outbox looks stuck for no reason.
162
+ console.error("[install-receipts] could not record the delivery failure against the entry", JSON.stringify({
163
+ reason: "attempt_bookkeeping_failed",
164
+ outbox_id: entry.outbox_id,
165
+ ...describeError(writeError),
166
+ }));
167
+ });
131
168
  }
132
169
  finally {
133
170
  clearTimeout(timeout);
@@ -295,7 +295,9 @@ async function findPublicReleaseRoot(startDir) {
295
295
  }
296
296
  catch {
297
297
  // Keep walking: nested packages may be missing package.json or have one
298
- // without the release script.
298
+ // without the release script. Deliberately silent (BLI-3238) — this is
299
+ // a search, every level that is not the answer fails here, and the
300
+ // caller reports `null` when the walk finds nothing.
299
301
  }
300
302
  const parent = path.dirname(current);
301
303
  if (parent === current)
@@ -739,6 +739,10 @@ function looksLikeServiceRoleSecret(value) {
739
739
  return serviceCredentialPayloadPattern().test(payload);
740
740
  }
741
741
  catch {
742
+ // Deliberately silent (BLI-3238), and it must stay silent: this is the
743
+ // "is this argument a service-role JWT?" test, so a value that will not
744
+ // decode is simply not one. Anything logged here would be a fragment of a
745
+ // credential.
742
746
  return false;
743
747
  }
744
748
  }
@@ -185,6 +185,15 @@ async function readJsonResponse(response) {
185
185
  return JSON.parse(text);
186
186
  }
187
187
  catch {
188
+ // OTP path. The text is handed to the caller for the human-facing message
189
+ // but never logged — it is an unbounded page from whatever answered. The
190
+ // shape is what says "a proxy replied, not the dashboard" (BLI-3238).
191
+ console.error("[local-auth] auth reply was not JSON", JSON.stringify({
192
+ reason: "response_body_not_json",
193
+ http_status: response.status,
194
+ byte_size: text.length,
195
+ content_type: response.headers.get("content-type") ?? "none",
196
+ }));
188
197
  return text;
189
198
  }
190
199
  }
@@ -260,6 +269,11 @@ export async function readOnboardSessionReuseCandidate(homeDir) {
260
269
  return await readLocalCollectorSessionFile(paths);
261
270
  }
262
271
  catch {
272
+ // Deliberately silent (BLI-3238). This is the "can we reuse an existing
273
+ // login?" probe and the fallback below reads the same file through the
274
+ // looser schema — which reports its own reason when it also fails
275
+ // (`session_file_unusable` in local-state). Logging here would double
276
+ // every line for one read.
263
277
  return readLocalSessionReference(paths);
264
278
  }
265
279
  }
@@ -64,7 +64,7 @@ function localSubcommandHelp(command) {
64
64
  "Usage: cockpit onboard [--ticket <id>] [--email <owner@email>] [--device-name <name>] [--dashboard-url <url>] [--workspace <path>] [--allow-home-root] [--branch <name>] [--no-auth] [--max-depth <n>] [--max-repos <n>] [--json]",
65
65
  "",
66
66
  "Installs, pairs, starts work context(s), syncs once, completes all-history",
67
- "Codex and Claude backfill for the saved roots, and then prints readiness proof.",
67
+ "Codex and Claude backfill for the saved roots, and then tells you it is working.",
68
68
  "If --workspace is a parent folder, scans child git repos/worktrees and rolls them up by repo.",
69
69
  "`--repo <path>` remains supported as a backward-compatible alias.",
70
70
  `Omit --dashboard-url for normal production setup (${DEFAULT_DASHBOARD_URL}).`,
@@ -107,10 +107,10 @@ function localSubcommandHelp(command) {
107
107
  [
108
108
  "Usage: cockpit do-everything [--workspace <path>] [--dashboard-url <url>] [--update-tag <tag>] [--dry-run] [--json]",
109
109
  "",
110
- "Converges a blank, existing, or reused intern machine: latest CLI, interactive auth and collection-root recovery when needed, saved roots, autostart, all-history backfill, raw-evidence GC, and sync freshness.",
110
+ "Gets this machine fully set up, whether it is brand new, already set up, or handed down: latest CLI, sign-in and collection-root recovery when needed, saved folders, background sync, catching up on old sessions, cleaning up old files, and one fresh upload.",
111
111
  "`cockpit fix` is an alias.",
112
- "Maintainer canary: use `--update-tag next` so self-update and re-exec stay on the prerelease candidate.",
113
- "--dry-run prints the checks and would-fix steps without writing config, plists, cursors, or install telemetry.",
112
+ "Maintainers only: use `--update-tag next` so self-update and re-exec stay on the prerelease candidate.",
113
+ "--dry-run shows what it would do without changing anything.",
114
114
  ],
115
115
  ],
116
116
  [
@@ -144,9 +144,9 @@ function localSubcommandHelp(command) {
144
144
  [
145
145
  "Usage: cockpit start [--ticket <id>|--clear-ticket] [--topic <label>] [--topic-summary <summary>] [--intent <intent>] [--phase <phase>] [--intent-confidence <0..1>] [--workspace <path>] [--branch <name>] [--json]",
146
146
  "",
147
- "Starts local ambient capture. Parent folders start each child git worktree.",
147
+ "Starts collecting your work in the background. If you point it at a parent folder it covers every repo inside.",
148
148
  "Add --ticket only when the work already has a visible ticket; omit it to preserve an existing binding.",
149
- "Use --clear-ticket to intentionally return the context to general ambient capture.",
149
+ "Use --clear-ticket to go back to collecting general work with no ticket attached.",
150
150
  "Use --topic/--intent/--phase for planning, discovery, and learning work that has no ticket yet.",
151
151
  "Supported intents: implementation, bug_fix, root_cause_analysis, planning, discovery, review, testing, documentation, release, learning, coordination, maintenance, analysis, unknown, other.",
152
152
  "Supported phases: planning, discovery, implementation, debugging, review, testing, documentation, release, handoff, analysis, unknown, other.",
@@ -158,7 +158,7 @@ function localSubcommandHelp(command) {
158
158
  [
159
159
  "Usage: cockpit sync [--workspace <path>] [--dashboard-url <url>] [--json]",
160
160
  "",
161
- "Uploads latest local ambient envelope(s), or spools safe retries if blocked.",
161
+ "Uploads your latest collected work. If it cannot reach Cockpit it saves a retry and tries again later.",
162
162
  "Omit --dashboard-url for normal production sync; pass it only for staging/custom dashboards or forced re-pairing.",
163
163
  "`--repo <path>` remains supported as a backward-compatible alias.",
164
164
  "Parent folders sync each child git worktree; Codex AND Claude Code JSONL",
@@ -166,7 +166,7 @@ function localSubcommandHelp(command) {
166
166
  "deterministically and ambiguous transcripts are retained as unattributed",
167
167
  "instead of being duplicated across repos. Use `cockpit sessions` to see why",
168
168
  "a session is or is not collected.",
169
- "Newly discovered repos get a general ambient work context automatically.",
169
+ "New repos start being collected automatically, with no ticket attached.",
170
170
  "Discovery scans 3 folder levels and up to 50 repos by default; tune with",
171
171
  "--max-depth and --max-repos.",
172
172
  "Also self-updates the CLI from npm latest once per day, strictly after",
@@ -179,7 +179,7 @@ function localSubcommandHelp(command) {
179
179
  [
180
180
  "Usage: cockpit analyze [--workspace <path>] [--dashboard-url <url>] [--json]",
181
181
  "",
182
- "Uploads the latest local ambient evidence, then queues one analysis job.",
182
+ "Uploads your latest collected work, then asks Cockpit to analyse it.",
183
183
  "The command returns after the batch is queued; view status and results in My Work.",
184
184
  "Omit --dashboard-url for production; pass it only for staging/custom dashboards.",
185
185
  "`--repo <path>` remains supported as a backward-compatible alias.",
@@ -23,6 +23,7 @@
23
23
  import path from "node:path";
24
24
  import { bufferedWritable, defaultExec, defaultIo, errorMessage, parseCapturedJson, replayCaptured, writeLine, } from "./cli-io.js";
25
25
  import { isLocalHelpRequest, localCommandHelp } from "./local-help.js";
26
+ import { describeError, isMissingFileFailure } from "../health-detail.js";
26
27
  import { addInstallEvent, classifySyncHealthError, redactedSyncErrorDetail, reportInstallEventsBestEffort, } from "./install-receipts.js";
27
28
  import { canReuseOnboardSession, pairLocalCollectorWithAuthFallback, readOnboardSessionReuseCandidate, requestPairingAccessToken, requestPairingAccessTokenDetailed, resolveInteractiveLoginEmail, resolveOnboardEmail, } from "./local-auth.js";
28
29
  import { collectionRootConsentAliases, persistOnboardingRootConfig, resolveOnboardingRootsForCommand, } from "./collection-roots.js";
@@ -306,9 +307,9 @@ function backgroundSyncLine(result) {
306
307
  return result.status;
307
308
  }
308
309
  function writeOnboardBanner(command, io) {
309
- writeLine(io.stdout, "Cockpit harvest onboarding");
310
+ writeLine(io.stdout, "Setting up Cockpit");
310
311
  writeLine(io.stdout, `Dashboard: ${command.dashboardUrl}`);
311
- writeLine(io.stdout, `Ticket: ${command.activeTicketId ?? "general ambient"}`);
312
+ writeLine(io.stdout, `Ticket: ${displayTicketId(command.activeTicketId)}`);
312
313
  }
313
314
  /**
314
315
  * Step 2 of onboarding: reuse the device session when it already belongs to
@@ -525,7 +526,7 @@ async function runOnboard(command, io) {
525
526
  }, null, 2));
526
527
  }
527
528
  if (onboardOk && !command.json) {
528
- writeLine(io.stdout, "PASS: Cockpit collector is ready for harvest.");
529
+ writeLine(io.stdout, "PASS: Cockpit is set up and collecting.");
529
530
  writeOnboardLiveStatus(io, resolvedCommand, collectionRoots, {
530
531
  pair,
531
532
  status,
@@ -552,7 +553,7 @@ async function runOnboard(command, io) {
552
553
  writeLine(io.stdout, "3/5 Work context active.");
553
554
  writeLine(io.stdout, `Repo: ${context.repo}`);
554
555
  writeLine(io.stdout, `Branch: ${context.branch}`);
555
- writeLine(io.stdout, `Ticket: ${context.active_ticket_id ?? "general ambient"}`);
556
+ writeLine(io.stdout, `Ticket: ${displayTicketId(context.active_ticket_id)}`);
556
557
  writeLine(io.stdout, `Context: ${context.work_context_id}`);
557
558
  }
558
559
  addInstallEvent(installEvents, "work_context", "ok");
@@ -638,9 +639,9 @@ async function runOnboard(command, io) {
638
639
  }, null, 2));
639
640
  return finish(onboardOk ? 0 : 1);
640
641
  }
641
- writeLine(io.stdout, "4/5 Ambient metadata uploaded.");
642
+ writeLine(io.stdout, "4/5 Uploaded what you worked on.");
642
643
  writeLine(io.stdout, `HTTP: ${sync.http_status}`);
643
- writeLine(io.stdout, `Facts: ${sync.event_count}`);
644
+ writeLine(io.stdout, `Things recorded: ${sync.event_count}`);
644
645
  writeLine(io.stdout, `Sources: ${sync.source_scan_count}`);
645
646
  writeLine(io.stdout, `Risk flags: ${sync.risk_flag_count}`);
646
647
  writeLine(io.stdout, `Raw evidence files: ${sync.raw_evidence_file_count}`);
@@ -656,7 +657,7 @@ async function runOnboard(command, io) {
656
657
  writeOnboardAutostartBlocker(io, autostart);
657
658
  return finish(1);
658
659
  }
659
- writeLine(io.stdout, "PASS: Cockpit collector is ready for harvest.");
660
+ writeLine(io.stdout, "PASS: Cockpit is set up and collecting.");
660
661
  writeOnboardLiveStatus(io, resolvedCommand, collectionRoots, {
661
662
  pair,
662
663
  status,
@@ -839,7 +840,7 @@ function nextStepForOnboardBlocker(blocker, options = {}) {
839
840
  case "ticket_binding":
840
841
  return "Run `cockpit start --ticket <id>` when actual ticket work begins, then run `cockpit sync`.";
841
842
  case "device_pairing":
842
- return "Approve from Ambient -> Collector approvals, or paste the pairing code there, then rerun `cockpit onboard`.";
843
+ return "Ask Edward to approve this machine in the dashboard under Ambient -> Collector approvals, then run `cockpit onboard` again.";
843
844
  case "network_or_ingest":
844
845
  return "Check dashboard URL/network, then run `cockpit sync --json` or rerun `cockpit onboard`.";
845
846
  case "install":
@@ -1125,7 +1126,7 @@ async function runSyncWithHealthReceipt(command, io) {
1125
1126
  }, null, 2));
1126
1127
  }
1127
1128
  else {
1128
- writeLine(io.stdout, "live sync paused during backfill");
1129
+ writeLine(io.stdout, "Pausing normal sync while it catches up on old sessions.");
1129
1130
  }
1130
1131
  return {
1131
1132
  exitCode: 0,
@@ -1300,11 +1301,11 @@ async function runSyncLocked(command, io) {
1300
1301
  return syncResult(run);
1301
1302
  }
1302
1303
  if (run.ok) {
1303
- writeLine(io.stdout, "Cockpit ambient envelope uploaded.");
1304
+ writeLine(io.stdout, "Cockpit uploaded this session.");
1304
1305
  writeLine(io.stdout, `Ticket: ${displayTicketId(result.ticket_id)}`);
1305
1306
  writeLine(io.stdout, `Context: ${result.work_context_id}`);
1306
1307
  writeLine(io.stdout, `Head: ${shortSha(result.head_sha)}`);
1307
- writeLine(io.stdout, `Facts: ${result.event_count}`);
1308
+ writeLine(io.stdout, `Things recorded: ${result.event_count}`);
1308
1309
  writeLine(io.stdout, `Risk flags: ${result.risk_flag_count}`);
1309
1310
  writeLine(io.stdout, `Raw evidence files: ${result.raw_evidence_file_count}`);
1310
1311
  writeLine(io.stdout, rawEvidenceSyncLine(result));
@@ -1315,11 +1316,11 @@ async function runSyncLocked(command, io) {
1315
1316
  return syncResult(run);
1316
1317
  }
1317
1318
  if (result.status === "uploaded") {
1318
- writeLine(io.stderr, "Cockpit ambient upload was accepted, but session collection is partial; retry `cockpit sync`.");
1319
+ writeLine(io.stderr, "Cockpit uploaded, but some sessions did not make it. Run `cockpit sync` again.");
1319
1320
  writeAgentSessionSummary(io, run.summary);
1320
1321
  return syncResult(run);
1321
1322
  }
1322
- writeLine(io.stderr, "Cockpit ambient upload failed; safe retry metadata was spooled.");
1323
+ writeLine(io.stderr, "Cockpit could not upload. It saved a note to retry and will try again on the next sync.");
1323
1324
  writeLine(io.stderr, `Ticket: ${displayTicketId(result.ticket_id)}`);
1324
1325
  writeLine(io.stderr, `Failure: ${result.failure_reason}`);
1325
1326
  writeLine(io.stderr, `Retry: ${result.retry_command}`);
@@ -1372,7 +1373,16 @@ async function runAnalyze(command, io) {
1372
1373
  return syncExitCode === 0 ? 1 : syncExitCode;
1373
1374
  }
1374
1375
  const paths = getCollectorRuntimePaths(command.homeDir);
1375
- const session = await readLocalCollectorSessionFile(paths).catch(() => {
1376
+ const session = await readLocalCollectorSessionFile(paths).catch((error) => {
1377
+ // "Not signed in" is the right sentence for an absent session file and the
1378
+ // wrong one for a corrupt one, which `cockpit login` will not repair
1379
+ // (BLI-3238).
1380
+ if (!isMissingFileFailure(error)) {
1381
+ console.error("[cockpit-analyze] session file present but unreadable, reporting as not signed in", JSON.stringify({
1382
+ reason: "session_file_unusable",
1383
+ ...describeError(error),
1384
+ }));
1385
+ }
1376
1386
  throw new Error("Cockpit is not signed in. Run `cockpit onboard` or `cockpit login` first.");
1377
1387
  });
1378
1388
  const dashboardUrl = normalizeUrl(command.dashboardUrl ?? session.dashboard_url ?? DEFAULT_DASHBOARD_URL);
@@ -1419,7 +1429,7 @@ async function runAnalyze(command, io) {
1419
1429
  return 0;
1420
1430
  }
1421
1431
  replayCaptured(io.stderr, syncStderr);
1422
- writeLine(io.stdout, "Cockpit latest evidence uploaded.");
1432
+ writeLine(io.stdout, "Cockpit uploaded your latest work.");
1423
1433
  writeLine(io.stdout, "Cockpit analysis queued.");
1424
1434
  if (jobId)
1425
1435
  writeLine(io.stdout, `Job: ${jobId}`);
@@ -1436,6 +1446,15 @@ async function readAnalyzeApiResponse(response) {
1436
1446
  return value && typeof value === "object" ? value : {};
1437
1447
  }
1438
1448
  catch {
1449
+ // `{}` erases whatever the analyze endpoint said, including its error, and
1450
+ // the operator is left with a job id of `undefined` and no reason. The
1451
+ // body is never logged; its shape is what identifies a proxy reply.
1452
+ console.error("[cockpit-analyze] reply was not JSON", JSON.stringify({
1453
+ reason: "response_body_not_json",
1454
+ http_status: response.status,
1455
+ byte_size: text.length,
1456
+ content_type: response.headers.get("content-type") ?? "none",
1457
+ }));
1439
1458
  return {};
1440
1459
  }
1441
1460
  }
@@ -15,7 +15,7 @@ export async function runCockpitCli(argv, io) {
15
15
  }
16
16
 
17
17
  if (command === "--version" || command === "-V" || command === "version") {
18
- writeLine(io?.stdout ?? process.stdout, "0.2.29");
18
+ writeLine(io?.stdout ?? process.stdout, "0.2.31");
19
19
  return 0;
20
20
  }
21
21
 
@@ -6,7 +6,8 @@
6
6
  import os from "node:os";
7
7
  import path from "node:path";
8
8
  import { getCollectorRuntimePaths, readLocalCollectorConfig, startLocalWorkContext, startLocalWorkContextForAttributedTarget, } from "../local-state.js";
9
- import { CODEX_SESSION_ATTRIBUTION_STATE_RANK, NO_UPLOAD_ATTEMPT_RECORDED, } from "@bli-cockpit/telemetry-core";
9
+ import { describeError } from "../health-detail.js";
10
+ import { CODEX_SESSION_ATTRIBUTION_STATE_RANK, NO_UPLOAD_ATTEMPT_RECORDED, notUploadableAttributionStateReason, } from "@bli-cockpit/telemetry-core";
10
11
  import { flushPendingCodexSessionReports, LocalUploadBlockedError, queueCodexSessionReport, syncLocalAmbientEnvelope, } from "../upload.js";
11
12
  import { CODEX_ATTRIBUTION_SCAN_WINDOW_SESSION_LIMIT, CODEX_ATTRIBUTION_SCAN_WINDOW_MINUTES, defaultCodexSessionDirs, scanAndAttributeCodexSessions, } from "../adapters/codex-attribution.js";
12
13
  import { scanAndAttributeClaudeSessions } from "../adapters/claude-attribution.js";
@@ -376,8 +377,17 @@ export async function runAttributedWorktreeSync(options) {
376
377
  codexCursor.updated_at = now.toISOString();
377
378
  await writeRawEvidenceCursor(paths, codexCursor);
378
379
  }
379
- catch {
380
- // Best-effort: stale counts read 0 and observations re-record next sync.
380
+ catch (error) {
381
+ // Best-effort: stale counts read 0 and observations re-record next sync
382
+ // which is fine ONCE. A cursor write that keeps failing means the sessions
383
+ // cursor never advances, every sync re-does the same work, and the only
384
+ // symptom is a stale count that is permanently zero (BLI-3238).
385
+ console.error("[session-sync] Codex session observations were not recorded", JSON.stringify({
386
+ reason: "session_cursor_update_failed",
387
+ source: "codex",
388
+ session_count: sessions.length,
389
+ ...describeError(error),
390
+ }));
381
391
  }
382
392
  if (claudeEnabled) {
383
393
  try {
@@ -398,8 +408,16 @@ export async function runAttributedWorktreeSync(options) {
398
408
  sessionsOnly: true,
399
409
  });
400
410
  }
401
- catch {
402
- // Best-effort: a broken Claude cursor must not fail the sync.
411
+ catch (error) {
412
+ // Best-effort: a broken Claude cursor must not fail the sync. It must
413
+ // still say it is broken — otherwise the Claude half of collection
414
+ // quietly repeats itself forever.
415
+ console.error("[session-sync] Claude session observations were not recorded", JSON.stringify({
416
+ reason: "session_cursor_update_failed",
417
+ source: "claude_code",
418
+ session_count: sessions.length,
419
+ ...describeError(error),
420
+ }));
403
421
  }
404
422
  }
405
423
  const report = hadPendingSessionReports || queuedCurrentSessionReport
@@ -639,7 +657,18 @@ export function buildAgentSessionReport(options) {
639
657
  ? "sync_incomplete_this_pass"
640
658
  : NO_UPLOAD_ATTEMPT_RECORDED),
641
659
  }
642
- : {}),
660
+ : {
661
+ // BLI-3272: the upload policy refused this session's
662
+ // attribution state. That refusal used to spread `{}` here, so
663
+ // the row landed with upload_state NULL and upload_reason NULL
664
+ // — a withhold that named nothing, on 1,256 production
665
+ // sessions. It is a decision like any other and it says so.
666
+ // Any reason the pipeline did record still wins: it is the more
667
+ // specific answer.
668
+ upload_state: "not_uploaded",
669
+ upload_reason: noUploadReasonByKey.get(key) ??
670
+ notUploadableAttributionStateReason(result.reason),
671
+ }),
643
672
  };
644
673
  });
645
674
  }
@@ -3,9 +3,11 @@
3
3
  * active work, upload state, retry backlog, and how far historical backfill
4
4
  * has got.
5
5
  *
6
- * Read-only. Split out of commands/local.ts (BLI-3104); moved verbatim, since
7
- * every line here is what an intern pastes into Slack when something looks
8
- * wrong.
6
+ * Read-only. Split out of commands/local.ts (BLI-3104). BLI-3194 rewrote the
7
+ * human block into plain English, because these lines are what an intern
8
+ * pastes into Slack when something looks wrong. The `--json` payload is the
9
+ * machine contract and is byte-for-byte unchanged — `status-json-shape.test.ts`
10
+ * holds it to that.
9
11
  */
10
12
  import { readdir, stat } from "node:fs/promises";
11
13
  import os from "node:os";
@@ -14,6 +16,7 @@ import { writeLine } from "./cli-io.js";
14
16
  import { displayTicketId, displayWorkLabel, shortSha, stuckEvidenceLine, } from "./collection-report.js";
15
17
  import { discoverCommandWorktrees } from "./local-discovery.js";
16
18
  import { defaultCodexSessionDirs } from "../adapters/codex-attribution.js";
19
+ import { describeError } from "../health-detail.js";
17
20
  import { backfillCompletionCovers, emptyBackfillCursorState, prepareBackfillCursorForScope, readBackfillCompletionMarker, readBackfillCursor, } from "../cursors/backfill-cursor.js";
18
21
  import { getCollectorRuntimePaths, inspectLocalCollectorStatus, readLocalCollectorConfig, } from "../local-state.js";
19
22
  import { normalizeCollectionRoots } from "../root-normalization.js";
@@ -32,8 +35,8 @@ export async function runStatus(command, io) {
32
35
  writeLine(io.stdout, JSON.stringify({ mode: "multi_repo", statuses, backfill_cursor: backfillCursor }, null, 2));
33
36
  return 0;
34
37
  }
35
- writeLine(io.stdout, "Cockpit parent status");
36
- writeLine(io.stdout, `backfill: ${backfillCursorLine(backfillCursor)}`);
38
+ writeLine(io.stdout, "Cockpit status — every repo below this folder");
39
+ writeLine(io.stdout, `Old sessions: ${backfillCursorLine(backfillCursor)}`);
37
40
  for (const status of statuses) {
38
41
  writeLine(io.stdout, `- ${status.repo_label ?? status.repo}/${status.worktree_label ?? "worktree"} · ${status.branch} · head:${shortSha(status.head_sha)} · ${status.upload_state}`);
39
42
  }
@@ -44,24 +47,26 @@ export async function runStatus(command, io) {
44
47
  writeLine(io.stdout, JSON.stringify({ ...status, backfill_cursor: backfillCursor }, null, 2));
45
48
  return 0;
46
49
  }
47
- writeLine(io.stdout, "Cockpit local status");
48
- writeLine(io.stdout, `installed: ${status.installed}`);
49
- writeLine(io.stdout, `session_state: ${status.session_state}`);
50
- writeLine(io.stdout, `repo: ${status.repo}`);
51
- writeLine(io.stdout, `branch: ${status.branch}`);
52
- writeLine(io.stdout, `ticket: ${displayTicketId(status.active_ticket_id)}`);
53
- writeLine(io.stdout, `work: ${displayWorkLabel(status)}`);
54
- writeLine(io.stdout, `collector_freshness: ${status.collector_freshness}`);
55
- writeLine(io.stdout, `collector_version: ${status.collector_version}`);
56
- writeLine(io.stdout, `upload_state: ${status.upload_state}`);
57
- writeLine(io.stdout, `last_upload_attempt: ${status.last_upload_attempt_at ?? "never"}`);
58
- writeLine(io.stdout, `last_upload_success: ${status.last_upload_success_at ?? "never"}`);
59
- writeLine(io.stdout, `last_upload_failure: ${status.last_upload_failure_reason ?? "none"}`);
60
- writeLine(io.stdout, `pending_uploads: ${status.pending_upload_count}`);
61
- writeLine(io.stdout, `pending_health_receipts: ${status.pending_health_receipt_count}`);
62
- writeLine(io.stdout, `last_health_receipt_failure: ${status.last_health_receipt_failure_reason ?? "none"}`);
63
- writeLine(io.stdout, `backfill: ${backfillCursorLine(backfillCursor)}`);
64
- writeLine(io.stdout, `stuck_evidence: ${stuckEvidenceLine(status)}`);
50
+ writeLine(io.stdout, "Cockpit status");
51
+ writeLine(io.stdout, `Installed: ${status.installed ? "yes" : "no"}`);
52
+ writeLine(io.stdout, `Signed in: ${signedInLine(status.session_state)}`);
53
+ writeLine(io.stdout, `Repo: ${status.repo}`);
54
+ writeLine(io.stdout, `Branch: ${status.branch}`);
55
+ writeLine(io.stdout, `Ticket: ${displayTicketId(status.active_ticket_id)}`);
56
+ writeLine(io.stdout, `Work: ${displayWorkLabel(status)}`);
57
+ writeLine(io.stdout, `Last collected: ${lastCollectedLine(status.collector_freshness)}`);
58
+ writeLine(io.stdout, `Version: ${status.collector_version}`);
59
+ writeLine(io.stdout, `Uploads: ${uploadsLine(status.upload_state)}`);
60
+ writeLine(io.stdout, `Last upload tried: ${status.last_upload_attempt_at ?? "never"}`);
61
+ writeLine(io.stdout, `Last upload worked: ${status.last_upload_success_at ?? "never"}`);
62
+ // The failure reason itself is a machine label and prints verbatim: a status
63
+ // that hides why the last upload failed is the silent-success failure mode.
64
+ writeLine(io.stdout, `Last upload problem: ${status.last_upload_failure_reason ?? "none"}`);
65
+ writeLine(io.stdout, `Waiting to upload: ${status.pending_upload_count}`);
66
+ writeLine(io.stdout, `Health notes waiting to send: ${status.pending_health_receipt_count}`);
67
+ writeLine(io.stdout, `Last health note problem: ${status.last_health_receipt_failure_reason ?? "none"}`);
68
+ writeLine(io.stdout, `Old sessions: ${backfillCursorLine(backfillCursor)}`);
69
+ writeLine(io.stdout, `Stuck files: ${stuckEvidenceLine(status)}`);
65
70
  for (const detail of status.details)
66
71
  writeLine(io.stdout, `- ${detail}`);
67
72
  return 0;
@@ -122,11 +127,46 @@ function summarizeBackfillSource(source) {
122
127
  function backfillCursorLine(status) {
123
128
  switch (status.state) {
124
129
  case "done":
125
- return `done (${status.completed_at ?? "completion marker present"})`;
130
+ return `done (${status.completed_at ?? "finished, no date recorded"})`;
126
131
  case "never_run":
127
- return "never run";
132
+ return "not started yet";
128
133
  case "remaining":
129
- return `${status.remaining_count} remaining`;
134
+ return `${status.remaining_count} still to catch up on`;
135
+ }
136
+ }
137
+ /** Plain words for the four session states; `--json` still gets the raw value. */
138
+ function signedInLine(sessionState) {
139
+ switch (sessionState) {
140
+ case "valid":
141
+ return "yes";
142
+ case "expired":
143
+ return "no — the sign-in expired, run `cockpit login`";
144
+ case "missing":
145
+ return "no — run `cockpit login`";
146
+ case "unknown":
147
+ return "cannot tell";
148
+ }
149
+ }
150
+ function lastCollectedLine(freshness) {
151
+ switch (freshness) {
152
+ case "fresh":
153
+ return "recently";
154
+ case "stale":
155
+ return "a while ago";
156
+ case "missing":
157
+ return "never";
158
+ }
159
+ }
160
+ function uploadsLine(uploadState) {
161
+ switch (uploadState) {
162
+ case "ready":
163
+ return "ready";
164
+ case "retry_pending":
165
+ return "waiting to retry";
166
+ case "local_only_missing_auth":
167
+ return "staying on this machine — not signed in";
168
+ case "not_installed":
169
+ return "not set up yet — run `cockpit onboard`";
130
170
  }
131
171
  }
132
172
  async function countRemainingBackfillSessionFiles(homeDir, cursor) {
@@ -165,6 +205,12 @@ async function countClaudeMainFilesBeforeCursor(projectsDir, oldestProcessedMs)
165
205
  /** Unreadable folders are skipped rather than failing the walk. */
166
206
  async function walkFiles(roots, onFile, shouldStop = () => false) {
167
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;
168
214
  while (stack.length > 0 && !shouldStop()) {
169
215
  const current = stack.pop();
170
216
  if (!current)
@@ -173,7 +219,9 @@ async function walkFiles(roots, onFile, shouldStop = () => false) {
173
219
  try {
174
220
  entries = await readdir(current, { withFileTypes: true });
175
221
  }
176
- catch {
222
+ catch (error) {
223
+ unreadableDirCount += 1;
224
+ firstFailure ??= describeError(error);
177
225
  continue;
178
226
  }
179
227
  for (const entry of entries) {
@@ -188,4 +236,11 @@ async function walkFiles(roots, onFile, shouldStop = () => false) {
188
236
  }
189
237
  }
190
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
+ }
191
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
  }