@bli-cockpit/cli 0.2.30 → 0.2.32

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/dist/adapters/agent-image-evidence.js +4 -0
  2. package/dist/adapters/attribution-core.js +12 -0
  3. package/dist/adapters/car-state.js +12 -1
  4. package/dist/adapters/claude-attribution.js +81 -7
  5. package/dist/adapters/codex-attribution.js +37 -3
  6. package/dist/adapters/raw-evidence-manifest.js +12 -1
  7. package/dist/adapters/raw-evidence-pack-store.js +28 -3
  8. package/dist/adapters/raw-evidence-sanitize.js +21 -3
  9. package/dist/adapters/raw-evidence.js +51 -6
  10. package/dist/agent-rules.js +34 -3
  11. package/dist/autostart.js +3 -0
  12. package/dist/backfill-lock.js +22 -1
  13. package/dist/commands/backfill.js +41 -7
  14. package/dist/commands/cli-io.js +3 -0
  15. package/dist/commands/doctor.js +35 -4
  16. package/dist/commands/install-receipts.js +43 -6
  17. package/dist/commands/install-update.js +3 -1
  18. package/dist/commands/jarvis.js +136 -0
  19. package/dist/commands/local-args.js +29 -0
  20. package/dist/commands/local-auth.js +14 -0
  21. package/dist/commands/local-help.js +15 -0
  22. package/dist/commands/local.js +24 -1
  23. package/dist/commands/public-root.js +1 -1
  24. package/dist/commands/session-sync.js +35 -6
  25. package/dist/commands/status.js +17 -1
  26. package/dist/cursors/backfill-cursor.js +23 -2
  27. package/dist/cursors/raw-evidence-cursor.js +14 -1
  28. package/dist/discovery-limits.js +12 -1
  29. package/dist/evidence-upload-client.js +41 -4
  30. package/dist/health-detail.js +111 -2
  31. package/dist/local-state.js +98 -11
  32. package/dist/onboarding-roots.js +3 -0
  33. package/dist/raw-evidence-attribution-policy.js +7 -0
  34. package/dist/raw-evidence-gc.js +5 -0
  35. package/dist/raw-evidence-staging.js +12 -1
  36. package/dist/repo-identity.js +15 -1
  37. package/dist/scheduled-self-update.js +14 -1
  38. package/dist/spool/install-event-outbox.js +11 -1
  39. package/dist/spool/local-spool.js +3 -0
  40. package/dist/sync-lock.js +24 -1
  41. package/dist/upload-agent-artifacts.js +11 -1
  42. package/dist/upload-envelope.js +30 -3
  43. package/dist/upload-http.js +10 -0
  44. package/dist/upload-session-reports.js +36 -3
  45. package/dist/upload.js +16 -5
  46. package/package.json +2 -2
@@ -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
  }
@@ -11,6 +11,7 @@
11
11
  * throwing.
12
12
  */
13
13
  import { AgentImageArtifactReportRequestSchema, } from "@bli-cockpit/telemetry-core";
14
+ import { describeError } from "./health-detail.js";
14
15
  import { readResponseJson } from "./upload-http.js";
15
16
  export async function reportAgentImageArtifacts(options) {
16
17
  const artifacts = agentArtifactsFromEvidence(options);
@@ -60,7 +61,16 @@ export async function reportAgentImageArtifacts(options) {
60
61
  recorded_count: Number.isFinite(recordedCount) ? recordedCount : 0,
61
62
  };
62
63
  }
63
- catch {
64
+ catch (error) {
65
+ // The label stays `report_network_error` — it is the wire contract. What
66
+ // it never carried is WHICH network error, and DNS failure, TLS refusal,
67
+ // a proxy reset and a body that would not serialize are four different
68
+ // repairs wearing one name (BLI-3238).
69
+ console.error("[agent-artifacts] report request failed before a status came back", JSON.stringify({
70
+ reason: "report_network_error",
71
+ artifact_count: artifacts.length,
72
+ ...describeError(error),
73
+ }));
64
74
  return { posted: false, reason: "report_network_error", recorded_count: 0 };
65
75
  }
66
76
  }
@@ -24,6 +24,7 @@ import { getCollectorRuntimePaths, LOCAL_COLLECTOR_VERSION, readLocalCollectorCo
24
24
  import { runLocalSourceCollectors } from "./adapters/local-sources.js";
25
25
  import { defaultCodexSessionDirs, } from "./adapters/codex-attribution.js";
26
26
  import { normalizeDashboardUrl } from "./upload-http.js";
27
+ import { describeError, isMissingFileFailure } from "./health-detail.js";
27
28
  export class LocalUploadBlockedError extends Error {
28
29
  blocker;
29
30
  retry_hint;
@@ -40,7 +41,16 @@ export async function buildLocalAmbientEnvelope(options = {}) {
40
41
  const collector = await readPairedCollector(paths);
41
42
  const { config, sessionFile, session } = collector;
42
43
  const repoRoot = path.resolve(options.repoRoot ?? process.cwd());
43
- const activeContext = await readLocalWorkContextForRepo(paths, repoRoot).catch(() => {
44
+ const activeContext = await readLocalWorkContextForRepo(paths, repoRoot).catch((error) => {
45
+ // The operator is told "run `cockpit start`", which is right when the file
46
+ // is simply absent and wrong when it exists and will not parse — the same
47
+ // advice, forever, on a machine that has already run it (BLI-3238).
48
+ if (!isMissingFileFailure(error)) {
49
+ console.error("[upload-envelope] work context present but unreadable, reporting it as missing", JSON.stringify({
50
+ reason: "missing_context",
51
+ ...describeError(error),
52
+ }));
53
+ }
44
54
  throw new LocalUploadBlockedError("missing_context", "Active work context missing. Run `cockpit start --workspace \"$PWD\"` before `cockpit sync`.", "cockpit start --workspace \"$PWD\"");
45
55
  });
46
56
  const repoLabel = safeRepoLabel(activeContext.repo_label ?? repoRoot);
@@ -132,10 +142,27 @@ export async function buildLocalAmbientEnvelope(options = {}) {
132
142
  * with no next step is what turns a five-second fix into a support thread.
133
143
  */
134
144
  async function readPairedCollector(paths) {
135
- const config = await readLocalCollectorConfig(paths).catch(() => {
145
+ const config = await readLocalCollectorConfig(paths).catch((error) => {
146
+ // `not_installed` tells the operator to reinstall the CLI. That is the
147
+ // wrong instruction for a config that exists and is corrupt, and there was
148
+ // no way to tell which one this machine hit.
149
+ if (!isMissingFileFailure(error)) {
150
+ console.error("[upload-envelope] collector config present but unreadable, reporting it as not installed", JSON.stringify({
151
+ reason: "not_installed",
152
+ ...describeError(error),
153
+ }));
154
+ }
136
155
  throw new LocalUploadBlockedError("not_installed", "Local collector config missing. Install/update the CLI, then run `cockpit do-everything` before `cockpit sync`.", "npm install -g @bli-cockpit/cli@latest && cockpit do-everything");
137
156
  });
138
- const sessionFile = await readLocalCollectorSessionFile(paths).catch(() => {
157
+ const sessionFile = await readLocalCollectorSessionFile(paths).catch((error) => {
158
+ // Same trap on the pairing half: `unpaired` sends the operator to
159
+ // `cockpit login`, which does not fix an unreadable session file.
160
+ if (!isMissingFileFailure(error)) {
161
+ console.error("[upload-envelope] session file present but unreadable, reporting the machine as unpaired", JSON.stringify({
162
+ reason: "unpaired",
163
+ ...describeError(error),
164
+ }));
165
+ }
139
166
  throw new LocalUploadBlockedError("unpaired", "No paired collector session found. Run `cockpit login` or `cockpit pair` before `cockpit sync`.", "cockpit login");
140
167
  });
141
168
  const session = await readLocalSessionReference(paths);
@@ -21,6 +21,16 @@ export async function readResponseJson(response) {
21
21
  return JSON.parse(text);
22
22
  }
23
23
  catch {
24
+ // The text itself is never logged: a proxy error page is unbounded and can
25
+ // carry anything. The shape is the useful part — an HTML body under a 200
26
+ // means something in front of the dashboard answered instead of it, which
27
+ // reads downstream as an ordinary upload failure (BLI-3238).
28
+ console.error("[cockpit-http] reply was not JSON", JSON.stringify({
29
+ reason: "response_body_not_json",
30
+ http_status: response.status,
31
+ byte_size: text.length,
32
+ content_type: response.headers.get("content-type") ?? "none",
33
+ }));
24
34
  return { message: text };
25
35
  }
26
36
  }
@@ -27,6 +27,7 @@ import { getCollectorRuntimePaths, LOCAL_COLLECTOR_VERSION, readLocalCollectorCo
27
27
  import { readLocalUploadSpoolState, recordPendingSessionReport, recordSessionReportFailure, recordSessionReportSuccess, } from "./spool/local-spool.js";
28
28
  import { makeCollectorProvenance, makeUploadWorkContext, safeRepoLabel, } from "./upload-envelope.js";
29
29
  import { normalizeDashboardUrl, readResponseJson } from "./upload-http.js";
30
+ import { describeError } from "./health-detail.js";
30
31
  /** Attempts per chunk, and the linear 250ms-per-attempt backoff between them. */
31
32
  const DEFAULT_REPORT_ATTEMPTS = 3;
32
33
  const REPORT_RETRY_BACKOFF_STEP_MS = 250;
@@ -73,7 +74,19 @@ export async function postCodexSessionReport(options) {
73
74
  sessions: options.sessions,
74
75
  });
75
76
  }
76
- catch {
77
+ catch (error) {
78
+ // `collector_not_ready` covers this whole block — config read, session
79
+ // read, work-context read AND the report request itself. The label is
80
+ // kept (it is what the receipt contract carries) but it is broader than
81
+ // its name suggests: a network failure inside `reportCodexSessionAttributions`
82
+ // also lands here and reads as "this machine is not set up". The detail is
83
+ // now the only way to tell those apart. Flagged, not reclassified —
84
+ // narrowing the label is a contract change (BLI-3238).
85
+ console.error("[session-reports] could not report session attributions", JSON.stringify({
86
+ reason: "collector_not_ready",
87
+ session_count: options.sessions.length,
88
+ ...describeError(error),
89
+ }));
77
90
  return emptyCodexSessionReportResult("collector_not_ready");
78
91
  }
79
92
  }
@@ -125,7 +138,15 @@ export async function flushPendingCodexSessionReports(options) {
125
138
  readLocalSessionReference(paths),
126
139
  ]);
127
140
  }
128
- catch {
141
+ catch (error) {
142
+ // This marks every queued report as failed. The count is what makes it
143
+ // worth a line: an unreadable session file here strands N reports at once
144
+ // and the only visible trace is a spool that stops draining (BLI-3238).
145
+ console.error("[session-reports] session file unreadable, failing the pending reports", JSON.stringify({
146
+ reason: "collector_not_ready",
147
+ pending_report_count: state.pending_session_reports.length,
148
+ ...describeError(error),
149
+ }));
129
150
  return await failPendingSessionReports(paths, state.pending_session_reports, attemptedAt, "collector_not_ready");
130
151
  }
131
152
  if (session.session_state !== "valid") {
@@ -311,7 +332,19 @@ async function postCodexSessionAttributionChunk(options) {
311
332
  }
312
333
  }
313
334
  }
314
- catch {
335
+ catch (error) {
336
+ // Logged per attempt, on purpose: the interesting failure is the one
337
+ // that repeats. Three identical ECONNRESETs and one DNS failure followed
338
+ // by two resets are different stories, and `report_network_error` alone
339
+ // tells neither (BLI-3238).
340
+ console.error("[session-reports] report attempt failed before a status came back", JSON.stringify({
341
+ reason: "report_network_error",
342
+ attempt,
343
+ max_attempts: maxAttempts,
344
+ batch_index: options.batchIndex,
345
+ session_count: options.sessions.length,
346
+ ...describeError(error),
347
+ }));
315
348
  lastStatus = null;
316
349
  lastFailureReason = "report_network_error";
317
350
  }
package/dist/upload.js CHANGED
@@ -6,6 +6,7 @@ import { recordUploadBlocked, recordUploadFailure, recordUploadSuccess, } from "
6
6
  import { buildLocalAmbientEnvelope, LocalUploadBlockedError, } from "./upload-envelope.js";
7
7
  import { applyRawEvidenceUploadOutcomes, hasRetryableEvidenceGap, logEvidenceBackoffBypassed, logEvidenceHeldByBackoff, logPermanentlyRejectedEvidence, partitionHeldEvidenceFiles, permanentEvidenceFailureReason, persistDeliveryAttempts, retrySourcesForFailedSync, retryableEvidenceGapReason, summarizeRawEvidenceDelivery, } from "./upload-evidence-delivery.js";
8
8
  import { reportAgentImageArtifacts } from "./upload-agent-artifacts.js";
9
+ import { describeError } from "./health-detail.js";
9
10
  import { isNonEmptyString, readResponseJson, responseErrorMessage, } from "./upload-http.js";
10
11
  export { LocalUploadBlockedError, buildLocalAmbientEnvelope, } from "./upload-envelope.js";
11
12
  export { partitionHeldEvidenceFiles } from "./upload-evidence-delivery.js";
@@ -76,11 +77,21 @@ export async function syncLocalAmbientEnvelope(options = {}) {
76
77
  outcomes: uploadOutcomes,
77
78
  reused: built.raw_evidence_facts?.reused ?? [],
78
79
  cursorObjects: cursor.objects,
79
- }).catch(() => ({
80
- posted: false,
81
- reason: "agent_artifact_report_local_error",
82
- recorded_count: 0,
83
- }));
80
+ }).catch((error) => {
81
+ // The whole point of `agent_artifact_report_local_error` is that it is
82
+ // NOT the network error the reporter already labels itself — it is a
83
+ // throw from our own code on the way in. That distinction is only
84
+ // useful if the throw says what it was (BLI-3238).
85
+ console.error("[cockpit-sync] agent image artifact report threw locally", JSON.stringify({
86
+ reason: "agent_artifact_report_local_error",
87
+ ...describeError(error),
88
+ }));
89
+ return {
90
+ posted: false,
91
+ reason: "agent_artifact_report_local_error",
92
+ recorded_count: 0,
93
+ };
94
+ });
84
95
  await recordIngestedSyncOutcome({
85
96
  paths,
86
97
  options,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bli-cockpit/cli",
3
- "version": "0.2.30",
3
+ "version": "0.2.32",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "bin": {
@@ -26,6 +26,6 @@
26
26
  "test": "node dist/cli.js --help && node ../../scripts/assert-public-cli-routing.mjs && node ../../scripts/assert-public-package-pack.mjs --workspace=@bli-cockpit/cli"
27
27
  },
28
28
  "dependencies": {
29
- "@bli-cockpit/telemetry-core": "0.1.22"
29
+ "@bli-cockpit/telemetry-core": "0.1.24"
30
30
  }
31
31
  }