@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.
- 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 +21 -3
- 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/jarvis.js +136 -0
- package/dist/commands/local-args.js +29 -0
- package/dist/commands/local-auth.js +14 -0
- package/dist/commands/local-help.js +15 -0
- package/dist/commands/local.js +24 -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
|
@@ -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
|
}
|
|
@@ -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
|
/**
|
|
5
6
|
* Durable local cursor for raw-evidence harvesting.
|
|
6
7
|
*
|
|
@@ -41,7 +42,19 @@ export async function readRawEvidenceCursor(paths, options = {}) {
|
|
|
41
42
|
const raw = JSON.parse(await fs.readFile(rawEvidenceCursorPath(paths, options.filename), "utf8"));
|
|
42
43
|
return parseCursorState(raw);
|
|
43
44
|
}
|
|
44
|
-
catch {
|
|
45
|
+
catch (error) {
|
|
46
|
+
// An empty cursor is the correct answer before the first sync, and saying
|
|
47
|
+
// so every 15 minutes would bury the case that matters. A cursor that
|
|
48
|
+
// exists and cannot be read is a different event entirely: the collector
|
|
49
|
+
// is about to re-offer every object it already delivered and no line
|
|
50
|
+
// anywhere says why (BLI-3238).
|
|
51
|
+
if (!isMissingFileFailure(error)) {
|
|
52
|
+
console.error("[raw-evidence-cursor] cursor unreadable, starting from empty", JSON.stringify({
|
|
53
|
+
reason: "cursor_unreadable",
|
|
54
|
+
cursor_file: options.filename ?? PRIMARY_CURSOR_FILENAME,
|
|
55
|
+
...describeError(error),
|
|
56
|
+
}));
|
|
57
|
+
}
|
|
45
58
|
return emptyRawEvidenceCursorState();
|
|
46
59
|
}
|
|
47
60
|
}
|
package/dist/discovery-limits.js
CHANGED
|
@@ -3,6 +3,7 @@ import os from "node:os";
|
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { DEFAULT_DISCOVERY_MAX_DEPTH, DEFAULT_DISCOVERY_MAX_REPOS, } from "./repo-identity.js";
|
|
5
5
|
import { getCollectorRuntimePaths } from "./local-state.js";
|
|
6
|
+
import { describeError, isMissingFileFailure } from "./health-detail.js";
|
|
6
7
|
const SCHEMA_VERSION = "cockpit-discovery-limits.v1";
|
|
7
8
|
// A depth beyond this is a typo rather than a workspace, and an unbounded walk
|
|
8
9
|
// on a huge tree is its own outage. Same for the repo cap.
|
|
@@ -32,7 +33,17 @@ export async function readSavedDiscoveryLimits(homeDir = os.homedir()) {
|
|
|
32
33
|
max_repos: sanitizeLimit(parsed.max_repos, MAX_ALLOWED_REPOS),
|
|
33
34
|
};
|
|
34
35
|
}
|
|
35
|
-
catch {
|
|
36
|
+
catch (error) {
|
|
37
|
+
// No saved limits is the normal state and stays quiet. A file that exists
|
|
38
|
+
// and will not parse silently reinstates the shipped defaults, which is
|
|
39
|
+
// exactly the shape of the outage where depth 3 / 50 repos quietly
|
|
40
|
+
// undercollected for days — so it gets a line (BLI-3238).
|
|
41
|
+
if (!isMissingFileFailure(error)) {
|
|
42
|
+
console.error("[discovery-limits] saved limits unreadable, falling back to defaults", JSON.stringify({
|
|
43
|
+
reason: "discovery_limits_unreadable",
|
|
44
|
+
...describeError(error),
|
|
45
|
+
}));
|
|
46
|
+
}
|
|
36
47
|
return {};
|
|
37
48
|
}
|
|
38
49
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { COMMIT_CRASHED_PLATFORM, RAW_EVIDENCE_UPLOAD_DEFAULT_CHUNK_BYTES, RAW_EVIDENCE_UPLOAD_MAX_FILE_BYTES, RAW_EVIDENCE_UPLOAD_MAX_OBJECTS_PER_BEGIN, RawEvidenceLegacyUploadResponseSchema, RawEvidenceRedactionMetadataSchema, RawEvidenceUploadBeginResponseSchema, RawEvidenceUploadCommitResponseSchema, isPermanentUploadFailure, } from "@bli-cockpit/telemetry-core";
|
|
2
2
|
import crypto from "node:crypto";
|
|
3
3
|
import fs from "node:fs/promises";
|
|
4
|
+
import { describeError } from "./health-detail.js";
|
|
4
5
|
const DEFAULT_MAX_ATTEMPTS = 3;
|
|
5
6
|
const RETRY_DELAY_MS = 250;
|
|
6
7
|
export async function uploadRawEvidenceFilesChunked(options) {
|
|
@@ -16,7 +17,16 @@ export async function uploadRawEvidenceFilesChunked(options) {
|
|
|
16
17
|
try {
|
|
17
18
|
stat = await fs.stat(file.local_path);
|
|
18
19
|
}
|
|
19
|
-
catch {
|
|
20
|
+
catch (error) {
|
|
21
|
+
// `file_read_failed` is a durable ledger label and stays. Beside it: a
|
|
22
|
+
// staged object that vanished (the GC won a race) and one the process
|
|
23
|
+
// has no permission to open are the same word and opposite repairs
|
|
24
|
+
// (BLI-3238).
|
|
25
|
+
console.error("[evidence-upload] staged object could not be stat'd", JSON.stringify({
|
|
26
|
+
reason: "file_read_failed",
|
|
27
|
+
stage: "stat",
|
|
28
|
+
...describeError(error),
|
|
29
|
+
}));
|
|
20
30
|
outcomes.push(failedOutcome(file, "file_read_failed"));
|
|
21
31
|
continue;
|
|
22
32
|
}
|
|
@@ -32,7 +42,15 @@ export async function uploadRawEvidenceFilesChunked(options) {
|
|
|
32
42
|
try {
|
|
33
43
|
bytes = await fs.readFile(file.local_path);
|
|
34
44
|
}
|
|
35
|
-
catch {
|
|
45
|
+
catch (error) {
|
|
46
|
+
// Stat succeeded and the read did not — narrower than the stat failure
|
|
47
|
+
// above and worth telling apart, so it carries its own stage label.
|
|
48
|
+
console.error("[evidence-upload] staged object could not be read after a successful stat", JSON.stringify({
|
|
49
|
+
reason: "file_read_failed",
|
|
50
|
+
stage: "read",
|
|
51
|
+
byte_size: stat.size,
|
|
52
|
+
...describeError(error),
|
|
53
|
+
}));
|
|
36
54
|
outcomes.push(failedOutcome(file, "file_read_failed"));
|
|
37
55
|
continue;
|
|
38
56
|
}
|
|
@@ -437,7 +455,18 @@ async function requestJson(options, routePath, body) {
|
|
|
437
455
|
return { ok: response.ok, status: response.status, body: lastBody };
|
|
438
456
|
}
|
|
439
457
|
}
|
|
440
|
-
catch {
|
|
458
|
+
catch (error) {
|
|
459
|
+
// `status: 0` is this function's word for "no answer came back", and it
|
|
460
|
+
// travels all the way to the upload ledger without ever saying whether
|
|
461
|
+
// the request left the machine. This is the exact silence BLI-2528 sat
|
|
462
|
+
// behind for 57 days; the route is named, the body never is.
|
|
463
|
+
console.error("[evidence-upload] request failed before a status came back", JSON.stringify({
|
|
464
|
+
reason: "upload_request_transport_error",
|
|
465
|
+
route: routePath,
|
|
466
|
+
attempt,
|
|
467
|
+
max_attempts: maxAttempts,
|
|
468
|
+
...describeError(error),
|
|
469
|
+
}));
|
|
441
470
|
lastStatus = 0;
|
|
442
471
|
lastBody = null;
|
|
443
472
|
}
|
|
@@ -578,7 +607,15 @@ async function readResponseJson(response) {
|
|
|
578
607
|
}
|
|
579
608
|
catch {
|
|
580
609
|
// The raw text is kept for the caller that wants to show it, never for a
|
|
581
|
-
// label or a log — it is an unbounded HTML page.
|
|
610
|
+
// label or a log — it is an unbounded HTML page. Its SHAPE is safe and is
|
|
611
|
+
// the part that matters: a non-JSON body means something in front of the
|
|
612
|
+
// dashboard answered instead of it (BLI-3067, BLI-3238).
|
|
613
|
+
console.error("[evidence-upload] reply was not JSON", JSON.stringify({
|
|
614
|
+
reason: "response_body_not_json",
|
|
615
|
+
http_status: response.status,
|
|
616
|
+
byte_size: text.length,
|
|
617
|
+
content_type: response.headers.get("content-type") ?? "none",
|
|
618
|
+
}));
|
|
582
619
|
return { message: text, [NON_JSON_RESPONSE_BODY_KEY]: "non_json" };
|
|
583
620
|
}
|
|
584
621
|
}
|
package/dist/health-detail.js
CHANGED
|
@@ -87,17 +87,126 @@ function localIdentifiers() {
|
|
|
87
87
|
identifiers.push([os.hostname(), "[host]"]);
|
|
88
88
|
}
|
|
89
89
|
catch {
|
|
90
|
-
// A host with no resolvable name has nothing to leak.
|
|
90
|
+
// A host with no resolvable name has nothing to leak. Deliberately silent
|
|
91
|
+
// (BLI-3238): this runs inside the redactor, so reporting it here would
|
|
92
|
+
// recurse into the very function that is failing.
|
|
91
93
|
}
|
|
92
94
|
try {
|
|
93
95
|
identifiers.push([os.userInfo().username, "[user]"]);
|
|
94
96
|
}
|
|
95
97
|
catch {
|
|
96
|
-
// Same: no account name available means none can travel
|
|
98
|
+
// Same: no account name available means none can travel, and the same
|
|
99
|
+
// recursion argument applies.
|
|
97
100
|
}
|
|
98
101
|
// A one- or two-character name would match far too much ordinary text.
|
|
99
102
|
return identifiers.filter(([value]) => value && value.length > 2);
|
|
100
103
|
}
|
|
101
104
|
function literalPattern(value) {
|
|
102
105
|
return new RegExp(value.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"), "giu");
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Describes a caught error in fields that are safe to log, and never throws
|
|
109
|
+
* doing it.
|
|
110
|
+
*
|
|
111
|
+
* BLI-3238. A bare `catch {}` in the harvester is how collection dies for days
|
|
112
|
+
* without anyone noticing (BLI-2528: 57 days, one probe away the whole time).
|
|
113
|
+
* The counter-pressure is that an error message on this machine is not neutral
|
|
114
|
+
* text — an fs error carries the operator's absolute path, a fetch error
|
|
115
|
+
* carries a host, and a parse error can carry a fragment of the transcript
|
|
116
|
+
* being parsed. So:
|
|
117
|
+
*
|
|
118
|
+
* - A syscall error (`code` + `syscall`) reports its code and syscall and
|
|
119
|
+
* drops the message entirely. `ENOENT: no such file or directory, open
|
|
120
|
+
* '/Users/<person>/…'` says nothing `ENOENT open` does not, and the tail is
|
|
121
|
+
* pure local identifier.
|
|
122
|
+
* - Every other message goes through `redactedHealthDetail`, the same
|
|
123
|
+
* secret-and-path scrubber the health receipts use before leaving the
|
|
124
|
+
* machine, and is capped at `HEALTH_DETAIL_MAX_CHARS`.
|
|
125
|
+
* - One level of `cause` is unwrapped, because `TypeError: fetch failed` on
|
|
126
|
+
* its own names nothing and its cause is the whole answer.
|
|
127
|
+
*/
|
|
128
|
+
export function describeError(error) {
|
|
129
|
+
if (!(error instanceof Error)) {
|
|
130
|
+
// `null` and `undefined` are fully described by their name; repeating them
|
|
131
|
+
// as a detail says nothing twice.
|
|
132
|
+
const named = error === null || error === undefined;
|
|
133
|
+
return {
|
|
134
|
+
error_name: error === null ? "null" : typeof error,
|
|
135
|
+
...(named ? {} : detailField(safeString(error))),
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
const fields = {
|
|
139
|
+
error_name: error.name || "Error",
|
|
140
|
+
...codeField(error),
|
|
141
|
+
...syscallField(error),
|
|
142
|
+
...messageField(error),
|
|
143
|
+
};
|
|
144
|
+
const cause = error.cause;
|
|
145
|
+
if (cause instanceof Error) {
|
|
146
|
+
const causeFields = describeError(cause);
|
|
147
|
+
if (causeFields.error_name)
|
|
148
|
+
fields.cause_name = causeFields.error_name;
|
|
149
|
+
if (causeFields.error_code)
|
|
150
|
+
fields.cause_code = causeFields.error_code;
|
|
151
|
+
if (causeFields.error_detail)
|
|
152
|
+
fields.cause_detail = causeFields.error_detail;
|
|
153
|
+
}
|
|
154
|
+
return fields;
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Is this the ordinary "there is nothing there yet" failure?
|
|
158
|
+
*
|
|
159
|
+
* Nearly every collector read has a legitimate empty state — no cursor before
|
|
160
|
+
* the first sync, no config before onboarding, no session before login — and
|
|
161
|
+
* logging that on every 15-minute tick would bury the failures that matter.
|
|
162
|
+
* A read that fails for any OTHER reason is the interesting one: a corrupt
|
|
163
|
+
* cursor and a permission-denied config both read as "first run" today, and
|
|
164
|
+
* that is the exact shape of BLI-2528.
|
|
165
|
+
*/
|
|
166
|
+
export function isMissingFileFailure(error) {
|
|
167
|
+
const code = errorCode(error);
|
|
168
|
+
return code === "ENOENT" || code === "ENOTDIR";
|
|
169
|
+
}
|
|
170
|
+
function codeField(error) {
|
|
171
|
+
const code = errorCode(error);
|
|
172
|
+
return code ? { error_code: code } : {};
|
|
173
|
+
}
|
|
174
|
+
function errorCode(error) {
|
|
175
|
+
if (!error || typeof error !== "object")
|
|
176
|
+
return undefined;
|
|
177
|
+
const code = error.code;
|
|
178
|
+
if (typeof code === "string" && code)
|
|
179
|
+
return code;
|
|
180
|
+
if (typeof code === "number")
|
|
181
|
+
return String(code);
|
|
182
|
+
return undefined;
|
|
183
|
+
}
|
|
184
|
+
function syscallField(error) {
|
|
185
|
+
const syscall = error.syscall;
|
|
186
|
+
return typeof syscall === "string" && syscall ? { error_syscall: syscall } : {};
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* The message, or nothing at all when the code and syscall already carry the
|
|
190
|
+
* whole meaning. Dropping it is not lost information: it is a path.
|
|
191
|
+
*/
|
|
192
|
+
function messageField(error) {
|
|
193
|
+
const syscall = error.syscall;
|
|
194
|
+
if (errorCode(error) && typeof syscall === "string" && syscall)
|
|
195
|
+
return {};
|
|
196
|
+
return detailField(error.message);
|
|
197
|
+
}
|
|
198
|
+
function detailField(message) {
|
|
199
|
+
const detail = redactedHealthDetail(message);
|
|
200
|
+
return detail ? { error_detail: detail } : {};
|
|
201
|
+
}
|
|
202
|
+
/** `String(value)` can itself throw on an exotic object; a describer must not. */
|
|
203
|
+
function safeString(value) {
|
|
204
|
+
try {
|
|
205
|
+
return String(value);
|
|
206
|
+
}
|
|
207
|
+
catch {
|
|
208
|
+
// Deliberately silent, and the only silence in this module that cannot be
|
|
209
|
+
// reported: the failure happened inside the reporter.
|
|
210
|
+
return "";
|
|
211
|
+
}
|
|
103
212
|
}
|
package/dist/local-state.js
CHANGED
|
@@ -9,6 +9,7 @@ import { isSamePath, normalizeCollectionRoots, } from "./root-normalization.js";
|
|
|
9
9
|
import { summarizeLocalUploadSpool } from "./spool/local-spool.js";
|
|
10
10
|
import { summarizeInstallEventOutbox } from "./spool/install-event-outbox.js";
|
|
11
11
|
import { readRawEvidenceStagingState, summarizeStuckEvidence, } from "./raw-evidence-staging.js";
|
|
12
|
+
import { describeError, isMissingFileFailure } from "./health-detail.js";
|
|
12
13
|
const localCollectorPackage = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
|
|
13
14
|
export const LOCAL_COLLECTOR_VERSION = typeof localCollectorPackage.version === "string"
|
|
14
15
|
? localCollectorPackage.version
|
|
@@ -115,7 +116,16 @@ export async function pairLocalCollector(options = {}) {
|
|
|
115
116
|
const homeDir = options.homeDir ?? os.homedir();
|
|
116
117
|
const paths = getCollectorRuntimePaths(homeDir);
|
|
117
118
|
await ensureRuntimeDirectories(paths);
|
|
118
|
-
const config = await readLocalCollectorConfig(paths).catch(() => {
|
|
119
|
+
const config = await readLocalCollectorConfig(paths).catch((error) => {
|
|
120
|
+
// "Local config missing" is correct when it is absent. A config that
|
|
121
|
+
// exists and will not parse gets the same sentence and the same useless
|
|
122
|
+
// advice — run onboard again, which will not fix it (BLI-3238).
|
|
123
|
+
if (!isMissingFileFailure(error)) {
|
|
124
|
+
console.error("[local-state] collector config present but unreadable, reporting it as missing", JSON.stringify({
|
|
125
|
+
reason: "config_unreadable",
|
|
126
|
+
...describeError(error),
|
|
127
|
+
}));
|
|
128
|
+
}
|
|
119
129
|
throw new Error("Local config missing. Run `cockpit onboard` (or `cockpit install`) first, then retry `cockpit login`.");
|
|
120
130
|
});
|
|
121
131
|
const dashboardUrl = normalizeDashboardUrl(options.dashboardUrl ?? config.dashboard_url);
|
|
@@ -454,7 +464,18 @@ export async function readLocalSessionReference(paths, fallback = {}) {
|
|
|
454
464
|
}
|
|
455
465
|
return LocalUserSessionReferenceSchema.parse(rawSession);
|
|
456
466
|
}
|
|
457
|
-
catch {
|
|
467
|
+
catch (error) {
|
|
468
|
+
// `session_state: "missing"` is correct before login and says so loudly
|
|
469
|
+
// enough on its own. It is also what a session file that EXISTS but is
|
|
470
|
+
// corrupt, truncated or unreadable collapses to — a paired machine that
|
|
471
|
+
// silently reads as never-signed-in, which is indistinguishable in every
|
|
472
|
+
// downstream receipt (BLI-3238).
|
|
473
|
+
if (!isMissingFileFailure(error)) {
|
|
474
|
+
console.error("[local-state] session file present but unusable, reading as missing", JSON.stringify({
|
|
475
|
+
reason: "session_file_unusable",
|
|
476
|
+
...describeError(error),
|
|
477
|
+
}));
|
|
478
|
+
}
|
|
458
479
|
return LocalUserSessionReferenceSchema.parse({
|
|
459
480
|
operator_id: fallback.operatorId ?? "unknown",
|
|
460
481
|
auth_subject_id: "unknown",
|
|
@@ -480,7 +501,17 @@ export async function resolveGitBranch(repoRoot) {
|
|
|
480
501
|
}
|
|
481
502
|
return head ? `detached:${head.slice(0, 12)}` : "unknown";
|
|
482
503
|
}
|
|
483
|
-
catch {
|
|
504
|
+
catch (error) {
|
|
505
|
+
// A folder that is not a git repo is a legitimate workspace under the
|
|
506
|
+
// session-first commandment, so a missing `.git` stays quiet. A `.git`
|
|
507
|
+
// that exists and cannot be read is a different thing: every session
|
|
508
|
+
// collected from this repo gets branch `unknown` and nothing says why.
|
|
509
|
+
if (!isMissingFileFailure(error)) {
|
|
510
|
+
console.error("[local-state] could not read HEAD, branch recorded as unknown", JSON.stringify({
|
|
511
|
+
reason: "git_head_unreadable",
|
|
512
|
+
...describeError(error),
|
|
513
|
+
}));
|
|
514
|
+
}
|
|
484
515
|
return "unknown";
|
|
485
516
|
}
|
|
486
517
|
}
|
|
@@ -499,13 +530,30 @@ async function ensureRuntimeDirectories(paths) {
|
|
|
499
530
|
await fs.mkdir(paths.cursors_dir, { recursive: true, mode: 0o700 });
|
|
500
531
|
await fs.mkdir(paths.work_contexts_dir, { recursive: true, mode: 0o700 });
|
|
501
532
|
if (process.platform !== "win32") {
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
533
|
+
// The `mode` above only applies to directories this call CREATES, so these
|
|
534
|
+
// chmods are what actually tightens a directory that already existed with
|
|
535
|
+
// looser bits. Failing means the operator's device token and cursors stay
|
|
536
|
+
// world-readable — non-fatal, deliberately, but not something to find out
|
|
537
|
+
// about never (BLI-3238). Reported by directory name only, never a path.
|
|
538
|
+
const tightened = [
|
|
539
|
+
["config_dir", fs.chmod(paths.config_dir, 0o700)],
|
|
540
|
+
["state_dir", fs.chmod(paths.state_dir, 0o700)],
|
|
541
|
+
["spool_dir", fs.chmod(paths.spool_dir, 0o700)],
|
|
542
|
+
["cursors_dir", fs.chmod(paths.cursors_dir, 0o700)],
|
|
543
|
+
["work_contexts_dir", fs.chmod(paths.work_contexts_dir, 0o700)],
|
|
544
|
+
];
|
|
545
|
+
await Promise.all(tightened.map(async ([name, work]) => {
|
|
546
|
+
try {
|
|
547
|
+
await work;
|
|
548
|
+
}
|
|
549
|
+
catch (error) {
|
|
550
|
+
console.error("[local-state] could not restrict a runtime directory to owner-only", JSON.stringify({
|
|
551
|
+
reason: "runtime_dir_chmod_failed",
|
|
552
|
+
directory: name,
|
|
553
|
+
...describeError(error),
|
|
554
|
+
}));
|
|
555
|
+
}
|
|
556
|
+
}));
|
|
509
557
|
}
|
|
510
558
|
}
|
|
511
559
|
function workContextFile(paths, worktreeFingerprint) {
|
|
@@ -545,8 +593,38 @@ function stableWorkContextId(input) {
|
|
|
545
593
|
function sha256(value) {
|
|
546
594
|
return crypto.createHash("sha256").update(value, "utf8").digest("hex");
|
|
547
595
|
}
|
|
596
|
+
/**
|
|
597
|
+
* The one reader behind the config file, the session file and every work
|
|
598
|
+
* context — and therefore the one place worth reporting from.
|
|
599
|
+
*
|
|
600
|
+
* Roughly twenty call sites swallow this to `null` or a default with
|
|
601
|
+
* `.catch(() => null)`, each of them asking a reasonable question ("is this
|
|
602
|
+
* machine set up?") to which "no" is a legitimate answer. What none of them
|
|
603
|
+
* could distinguish is "no, nothing is there" from "yes, and it is corrupt or
|
|
604
|
+
* unreadable" — so the distinction is drawn HERE, once, rather than in twenty
|
|
605
|
+
* places where it would be twenty chances to forget (BLI-3238).
|
|
606
|
+
*
|
|
607
|
+
* The error still propagates unchanged; callers keep whatever they decided.
|
|
608
|
+
*/
|
|
548
609
|
async function readJsonFile(filePath) {
|
|
549
|
-
|
|
610
|
+
try {
|
|
611
|
+
return JSON.parse(await fs.readFile(filePath, "utf8"));
|
|
612
|
+
}
|
|
613
|
+
catch (error) {
|
|
614
|
+
// Absent is the ordinary pre-onboarding state on every one of these files
|
|
615
|
+
// and stays quiet; anything else means state exists and cannot be used.
|
|
616
|
+
if (!isMissingFileFailure(error)) {
|
|
617
|
+
console.error("[local-state] a collector state file exists but could not be read", JSON.stringify({
|
|
618
|
+
reason: "state_file_unreadable",
|
|
619
|
+
// Which file, without the path: the basename of these is a fixed
|
|
620
|
+
// vocabulary (`config.json`, `session.json`, a work-context
|
|
621
|
+
// fingerprint) and carries no repo or operator name.
|
|
622
|
+
state_file: path.basename(filePath),
|
|
623
|
+
...describeError(error),
|
|
624
|
+
}));
|
|
625
|
+
}
|
|
626
|
+
throw error;
|
|
627
|
+
}
|
|
550
628
|
}
|
|
551
629
|
async function writeJsonFile(filePath, value) {
|
|
552
630
|
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
@@ -643,6 +721,15 @@ async function readResponseJson(response) {
|
|
|
643
721
|
return JSON.parse(text);
|
|
644
722
|
}
|
|
645
723
|
catch {
|
|
724
|
+
// Pairing path. A captive portal or a proxy answering with HTML is the
|
|
725
|
+
// classic reason `cockpit login` fails on a new machine and the operator
|
|
726
|
+
// sees only "pair request failed". The body is never logged; its shape is.
|
|
727
|
+
console.error("[local-state] pairing reply was not JSON", JSON.stringify({
|
|
728
|
+
reason: "response_body_not_json",
|
|
729
|
+
http_status: response.status,
|
|
730
|
+
byte_size: text.length,
|
|
731
|
+
content_type: response.headers.get("content-type") ?? "none",
|
|
732
|
+
}));
|
|
646
733
|
return { message: text };
|
|
647
734
|
}
|
|
648
735
|
}
|
package/dist/onboarding-roots.js
CHANGED
|
@@ -294,6 +294,9 @@ function canonicalPath(input, pathApi = path, realpath = realpathSync) {
|
|
|
294
294
|
return pathApi.resolve(realpath(resolved));
|
|
295
295
|
}
|
|
296
296
|
catch {
|
|
297
|
+
// Deliberately silent (BLI-3238), per the comment above: a path that does
|
|
298
|
+
// not exist yet is the ordinary input here, and the lexical resolve is the
|
|
299
|
+
// documented behavior for it, not a fallback after a failure.
|
|
297
300
|
return resolved;
|
|
298
301
|
}
|
|
299
302
|
}
|
|
@@ -3,6 +3,13 @@
|
|
|
3
3
|
* fallback that produced a worktree identity. Session-first terminal labels
|
|
4
4
|
* are uploadable only when attribution attached an approved-root synthetic
|
|
5
5
|
* workspace; the label stays terminal and no repository provenance is invented.
|
|
6
|
+
*
|
|
7
|
+
* A `false` answer is a decision, and every caller that builds a session row
|
|
8
|
+
* MUST record it as one — `upload_state: "not_uploaded"` plus
|
|
9
|
+
* `notUploadableAttributionStateReason(<attribution reason>)` from
|
|
10
|
+
* `@bli-cockpit/telemetry-core`. Spreading `{}` on this branch is what put 1,256
|
|
11
|
+
* production sessions in `ambient_codex_sessions` with a NULL upload_state and a
|
|
12
|
+
* NULL upload_reason (BLI-3272): withheld, and silent about it.
|
|
6
13
|
*/
|
|
7
14
|
export function isRawEvidenceUploadableAttributionState(state, hasApprovedWorkspace = false) {
|
|
8
15
|
return (state === "attributed" ||
|
package/dist/raw-evidence-gc.js
CHANGED
|
@@ -227,6 +227,11 @@ async function packContentFingerprint(dir) {
|
|
|
227
227
|
.digest("hex");
|
|
228
228
|
}
|
|
229
229
|
catch {
|
|
230
|
+
// Deliberately silent (BLI-3238) — and it is the one silence here that is
|
|
231
|
+
// already reported. `null` means "not fingerprintable", the dedup sweep
|
|
232
|
+
// counts every one of them as `unfingerprintable_dirs`, and that count is
|
|
233
|
+
// logged on EVERY sweep including the clean one. A line per pack would
|
|
234
|
+
// add nothing the sweep does not already say.
|
|
230
235
|
return null;
|
|
231
236
|
}
|
|
232
237
|
}
|
|
@@ -2,6 +2,7 @@ import { DELIVERY_BACKOFF_HOLDING } from "@bli-cockpit/telemetry-core";
|
|
|
2
2
|
import crypto from "node:crypto";
|
|
3
3
|
import fs from "node:fs/promises";
|
|
4
4
|
import path from "node:path";
|
|
5
|
+
import { describeError, isMissingFileFailure } from "./health-detail.js";
|
|
5
6
|
/**
|
|
6
7
|
* Durable local state for raw-evidence STAGING and DELIVERY ATTEMPTS.
|
|
7
8
|
*
|
|
@@ -67,7 +68,17 @@ export async function readRawEvidenceStagingState(stateDir) {
|
|
|
67
68
|
const raw = JSON.parse(await fs.readFile(rawEvidenceStagingStatePath(stateDir), "utf8"));
|
|
68
69
|
return parseStagingState(raw);
|
|
69
70
|
}
|
|
70
|
-
catch {
|
|
71
|
+
catch (error) {
|
|
72
|
+
// Empty is right before the first staged pack. Unreadable is not: this
|
|
73
|
+
// file carries the delivery-attempt history, so losing it silently resets
|
|
74
|
+
// every backoff window and re-offers objects the server already refused
|
|
75
|
+
// (BLI-3066 is what that costs; BLI-3238 is why it now says so).
|
|
76
|
+
if (!isMissingFileFailure(error)) {
|
|
77
|
+
console.error("[raw-evidence-staging] staging state unreadable, delivery history reset", JSON.stringify({
|
|
78
|
+
reason: "staging_state_unreadable",
|
|
79
|
+
...describeError(error),
|
|
80
|
+
}));
|
|
81
|
+
}
|
|
71
82
|
return emptyRawEvidenceStagingState();
|
|
72
83
|
}
|
|
73
84
|
}
|