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