@bli-cockpit/cli 0.2.30 → 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.
- package/dist/adapters/agent-image-evidence.js +4 -0
- package/dist/adapters/attribution-core.js +12 -0
- package/dist/adapters/car-state.js +12 -1
- package/dist/adapters/claude-attribution.js +81 -7
- package/dist/adapters/codex-attribution.js +37 -3
- package/dist/adapters/raw-evidence-manifest.js +12 -1
- package/dist/adapters/raw-evidence-pack-store.js +28 -3
- package/dist/adapters/raw-evidence-sanitize.js +46 -2
- package/dist/adapters/raw-evidence.js +51 -6
- package/dist/agent-rules.js +34 -3
- package/dist/autostart.js +3 -0
- package/dist/backfill-lock.js +22 -1
- package/dist/commands/backfill.js +41 -7
- package/dist/commands/cli-io.js +3 -0
- package/dist/commands/doctor.js +35 -4
- package/dist/commands/install-receipts.js +43 -6
- package/dist/commands/install-update.js +3 -1
- package/dist/commands/local-args.js +4 -0
- package/dist/commands/local-auth.js +14 -0
- package/dist/commands/local.js +20 -1
- package/dist/commands/public-root.js +1 -1
- package/dist/commands/session-sync.js +35 -6
- package/dist/commands/status.js +17 -1
- package/dist/cursors/backfill-cursor.js +23 -2
- package/dist/cursors/raw-evidence-cursor.js +14 -1
- package/dist/discovery-limits.js +12 -1
- package/dist/evidence-upload-client.js +41 -4
- package/dist/health-detail.js +111 -2
- package/dist/local-state.js +98 -11
- package/dist/onboarding-roots.js +3 -0
- package/dist/raw-evidence-attribution-policy.js +7 -0
- package/dist/raw-evidence-gc.js +5 -0
- package/dist/raw-evidence-staging.js +12 -1
- package/dist/repo-identity.js +15 -1
- package/dist/scheduled-self-update.js +14 -1
- package/dist/spool/install-event-outbox.js +11 -1
- package/dist/spool/local-spool.js +3 -0
- package/dist/sync-lock.js +24 -1
- package/dist/upload-agent-artifacts.js +11 -1
- package/dist/upload-envelope.js +30 -3
- package/dist/upload-http.js +10 -0
- package/dist/upload-session-reports.js +36 -3
- package/dist/upload.js +16 -5
- package/package.json +2 -2
package/dist/agent-rules.js
CHANGED
|
@@ -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 };
|
package/dist/backfill-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
|
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
|
-
//
|
|
1132
|
-
//
|
|
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(() =>
|
|
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
|
-
|
|
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
|
}
|
package/dist/commands/cli-io.js
CHANGED
|
@@ -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
|
}
|
package/dist/commands/doctor.js
CHANGED
|
@@ -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(() =>
|
|
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(() =>
|
|
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
|
}
|
|
@@ -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
|
}
|
|
@@ -547,6 +574,10 @@ function parseNpmVersion(stdout) {
|
|
|
547
574
|
return typeof parsed === "string" && parsed.trim() ? parsed.trim() : null;
|
|
548
575
|
}
|
|
549
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.
|
|
550
581
|
return trimmed.replace(/^"|"$/gu, "") || null;
|
|
551
582
|
}
|
|
552
583
|
}
|
|
@@ -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
|
-
|
|
117
|
-
|
|
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(() =>
|
|
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
|
}
|
package/dist/commands/local.js
CHANGED
|
@@ -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";
|
|
@@ -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);
|
|
@@ -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.
|
|
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 {
|
|
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
|
}
|
package/dist/commands/status.js
CHANGED
|
@@ -16,6 +16,7 @@ import { writeLine } from "./cli-io.js";
|
|
|
16
16
|
import { displayTicketId, displayWorkLabel, shortSha, stuckEvidenceLine, } from "./collection-report.js";
|
|
17
17
|
import { discoverCommandWorktrees } from "./local-discovery.js";
|
|
18
18
|
import { defaultCodexSessionDirs } from "../adapters/codex-attribution.js";
|
|
19
|
+
import { describeError } from "../health-detail.js";
|
|
19
20
|
import { backfillCompletionCovers, emptyBackfillCursorState, prepareBackfillCursorForScope, readBackfillCompletionMarker, readBackfillCursor, } from "../cursors/backfill-cursor.js";
|
|
20
21
|
import { getCollectorRuntimePaths, inspectLocalCollectorStatus, readLocalCollectorConfig, } from "../local-state.js";
|
|
21
22
|
import { normalizeCollectionRoots } from "../root-normalization.js";
|
|
@@ -204,6 +205,12 @@ async function countClaudeMainFilesBeforeCursor(projectsDir, oldestProcessedMs)
|
|
|
204
205
|
/** Unreadable folders are skipped rather than failing the walk. */
|
|
205
206
|
async function walkFiles(roots, onFile, shouldStop = () => false) {
|
|
206
207
|
const stack = [...roots];
|
|
208
|
+
// Counted, then reported once at the end: `cockpit status` is what an
|
|
209
|
+
// operator reads to decide whether collection is healthy, and a walk that
|
|
210
|
+
// silently skipped half the store would answer that question wrong
|
|
211
|
+
// (BLI-3238).
|
|
212
|
+
let unreadableDirCount = 0;
|
|
213
|
+
let firstFailure = null;
|
|
207
214
|
while (stack.length > 0 && !shouldStop()) {
|
|
208
215
|
const current = stack.pop();
|
|
209
216
|
if (!current)
|
|
@@ -212,7 +219,9 @@ async function walkFiles(roots, onFile, shouldStop = () => false) {
|
|
|
212
219
|
try {
|
|
213
220
|
entries = await readdir(current, { withFileTypes: true });
|
|
214
221
|
}
|
|
215
|
-
catch {
|
|
222
|
+
catch (error) {
|
|
223
|
+
unreadableDirCount += 1;
|
|
224
|
+
firstFailure ??= describeError(error);
|
|
216
225
|
continue;
|
|
217
226
|
}
|
|
218
227
|
for (const entry of entries) {
|
|
@@ -227,4 +236,11 @@ async function walkFiles(roots, onFile, shouldStop = () => false) {
|
|
|
227
236
|
}
|
|
228
237
|
}
|
|
229
238
|
}
|
|
239
|
+
if (unreadableDirCount > 0) {
|
|
240
|
+
console.error("[cockpit-status] folders skipped while counting", JSON.stringify({
|
|
241
|
+
reason: "status_walk_dir_unreadable",
|
|
242
|
+
unreadable_dir_count: unreadableDirCount,
|
|
243
|
+
...firstFailure,
|
|
244
|
+
}));
|
|
245
|
+
}
|
|
230
246
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import crypto from "node:crypto";
|
|
2
2
|
import fs from "node:fs/promises";
|
|
3
3
|
import path from "node:path";
|
|
4
|
+
import { describeError, isMissingFileFailure } from "../health-detail.js";
|
|
4
5
|
export const BACKFILL_CURSOR_FILENAME = "backfill.json";
|
|
5
6
|
export const BACKFILL_COMPLETION_MARKER_FILENAME = "backfill-complete.json";
|
|
6
7
|
export const BACKFILL_COVERAGE_VERSION = "redacted-session-backfill.v3";
|
|
@@ -20,7 +21,17 @@ export async function readBackfillCursor(paths) {
|
|
|
20
21
|
const raw = JSON.parse(await fs.readFile(backfillCursorPath(paths), "utf8"));
|
|
21
22
|
return parseBackfillCursor(raw);
|
|
22
23
|
}
|
|
23
|
-
catch {
|
|
24
|
+
catch (error) {
|
|
25
|
+
// Missing is the normal pre-backfill state. Present-but-unreadable means
|
|
26
|
+
// the historical sweep is about to restart from scratch, which looks
|
|
27
|
+
// identical from the outside and costs a full re-walk (BLI-3238).
|
|
28
|
+
if (!isMissingFileFailure(error)) {
|
|
29
|
+
console.error("[backfill-cursor] cursor unreadable, restarting the sweep from empty", JSON.stringify({
|
|
30
|
+
reason: "backfill_cursor_unreadable",
|
|
31
|
+
cursor_file: BACKFILL_CURSOR_FILENAME,
|
|
32
|
+
...describeError(error),
|
|
33
|
+
}));
|
|
34
|
+
}
|
|
24
35
|
return emptyBackfillCursorState();
|
|
25
36
|
}
|
|
26
37
|
}
|
|
@@ -36,7 +47,17 @@ export async function readBackfillCompletionMarker(paths) {
|
|
|
36
47
|
const raw = JSON.parse(await fs.readFile(backfillCompletionMarkerPath(paths), "utf8"));
|
|
37
48
|
return parseBackfillCompletionMarker(raw);
|
|
38
49
|
}
|
|
39
|
-
catch {
|
|
50
|
+
catch (error) {
|
|
51
|
+
// No marker means backfill has not finished, which is the ordinary state.
|
|
52
|
+
// A marker that cannot be read means a finished backfill will be re-run,
|
|
53
|
+
// and the machine should say so rather than quietly redo a day of work.
|
|
54
|
+
if (!isMissingFileFailure(error)) {
|
|
55
|
+
console.error("[backfill-cursor] completion marker unreadable, treating backfill as unfinished", JSON.stringify({
|
|
56
|
+
reason: "backfill_marker_unreadable",
|
|
57
|
+
marker_file: BACKFILL_COMPLETION_MARKER_FILENAME,
|
|
58
|
+
...describeError(error),
|
|
59
|
+
}));
|
|
60
|
+
}
|
|
40
61
|
return null;
|
|
41
62
|
}
|
|
42
63
|
}
|