@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
|
@@ -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
|
}
|
|
@@ -64,7 +64,7 @@ function localSubcommandHelp(command) {
|
|
|
64
64
|
"Usage: cockpit onboard [--ticket <id>] [--email <owner@email>] [--device-name <name>] [--dashboard-url <url>] [--workspace <path>] [--allow-home-root] [--branch <name>] [--no-auth] [--max-depth <n>] [--max-repos <n>] [--json]",
|
|
65
65
|
"",
|
|
66
66
|
"Installs, pairs, starts work context(s), syncs once, completes all-history",
|
|
67
|
-
"Codex and Claude backfill for the saved roots, and then
|
|
67
|
+
"Codex and Claude backfill for the saved roots, and then tells you it is working.",
|
|
68
68
|
"If --workspace is a parent folder, scans child git repos/worktrees and rolls them up by repo.",
|
|
69
69
|
"`--repo <path>` remains supported as a backward-compatible alias.",
|
|
70
70
|
`Omit --dashboard-url for normal production setup (${DEFAULT_DASHBOARD_URL}).`,
|
|
@@ -107,10 +107,10 @@ function localSubcommandHelp(command) {
|
|
|
107
107
|
[
|
|
108
108
|
"Usage: cockpit do-everything [--workspace <path>] [--dashboard-url <url>] [--update-tag <tag>] [--dry-run] [--json]",
|
|
109
109
|
"",
|
|
110
|
-
"
|
|
110
|
+
"Gets this machine fully set up, whether it is brand new, already set up, or handed down: latest CLI, sign-in and collection-root recovery when needed, saved folders, background sync, catching up on old sessions, cleaning up old files, and one fresh upload.",
|
|
111
111
|
"`cockpit fix` is an alias.",
|
|
112
|
-
"
|
|
113
|
-
"--dry-run
|
|
112
|
+
"Maintainers only: use `--update-tag next` so self-update and re-exec stay on the prerelease candidate.",
|
|
113
|
+
"--dry-run shows what it would do without changing anything.",
|
|
114
114
|
],
|
|
115
115
|
],
|
|
116
116
|
[
|
|
@@ -144,9 +144,9 @@ function localSubcommandHelp(command) {
|
|
|
144
144
|
[
|
|
145
145
|
"Usage: cockpit start [--ticket <id>|--clear-ticket] [--topic <label>] [--topic-summary <summary>] [--intent <intent>] [--phase <phase>] [--intent-confidence <0..1>] [--workspace <path>] [--branch <name>] [--json]",
|
|
146
146
|
"",
|
|
147
|
-
"Starts
|
|
147
|
+
"Starts collecting your work in the background. If you point it at a parent folder it covers every repo inside.",
|
|
148
148
|
"Add --ticket only when the work already has a visible ticket; omit it to preserve an existing binding.",
|
|
149
|
-
"Use --clear-ticket to
|
|
149
|
+
"Use --clear-ticket to go back to collecting general work with no ticket attached.",
|
|
150
150
|
"Use --topic/--intent/--phase for planning, discovery, and learning work that has no ticket yet.",
|
|
151
151
|
"Supported intents: implementation, bug_fix, root_cause_analysis, planning, discovery, review, testing, documentation, release, learning, coordination, maintenance, analysis, unknown, other.",
|
|
152
152
|
"Supported phases: planning, discovery, implementation, debugging, review, testing, documentation, release, handoff, analysis, unknown, other.",
|
|
@@ -158,7 +158,7 @@ function localSubcommandHelp(command) {
|
|
|
158
158
|
[
|
|
159
159
|
"Usage: cockpit sync [--workspace <path>] [--dashboard-url <url>] [--json]",
|
|
160
160
|
"",
|
|
161
|
-
"Uploads latest
|
|
161
|
+
"Uploads your latest collected work. If it cannot reach Cockpit it saves a retry and tries again later.",
|
|
162
162
|
"Omit --dashboard-url for normal production sync; pass it only for staging/custom dashboards or forced re-pairing.",
|
|
163
163
|
"`--repo <path>` remains supported as a backward-compatible alias.",
|
|
164
164
|
"Parent folders sync each child git worktree; Codex AND Claude Code JSONL",
|
|
@@ -166,7 +166,7 @@ function localSubcommandHelp(command) {
|
|
|
166
166
|
"deterministically and ambiguous transcripts are retained as unattributed",
|
|
167
167
|
"instead of being duplicated across repos. Use `cockpit sessions` to see why",
|
|
168
168
|
"a session is or is not collected.",
|
|
169
|
-
"
|
|
169
|
+
"New repos start being collected automatically, with no ticket attached.",
|
|
170
170
|
"Discovery scans 3 folder levels and up to 50 repos by default; tune with",
|
|
171
171
|
"--max-depth and --max-repos.",
|
|
172
172
|
"Also self-updates the CLI from npm latest once per day, strictly after",
|
|
@@ -179,7 +179,7 @@ function localSubcommandHelp(command) {
|
|
|
179
179
|
[
|
|
180
180
|
"Usage: cockpit analyze [--workspace <path>] [--dashboard-url <url>] [--json]",
|
|
181
181
|
"",
|
|
182
|
-
"Uploads
|
|
182
|
+
"Uploads your latest collected work, then asks Cockpit to analyse it.",
|
|
183
183
|
"The command returns after the batch is queued; view status and results in My Work.",
|
|
184
184
|
"Omit --dashboard-url for production; pass it only for staging/custom dashboards.",
|
|
185
185
|
"`--repo <path>` remains supported as a backward-compatible alias.",
|
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";
|
|
@@ -306,9 +307,9 @@ function backgroundSyncLine(result) {
|
|
|
306
307
|
return result.status;
|
|
307
308
|
}
|
|
308
309
|
function writeOnboardBanner(command, io) {
|
|
309
|
-
writeLine(io.stdout, "
|
|
310
|
+
writeLine(io.stdout, "Setting up Cockpit");
|
|
310
311
|
writeLine(io.stdout, `Dashboard: ${command.dashboardUrl}`);
|
|
311
|
-
writeLine(io.stdout, `Ticket: ${command.activeTicketId
|
|
312
|
+
writeLine(io.stdout, `Ticket: ${displayTicketId(command.activeTicketId)}`);
|
|
312
313
|
}
|
|
313
314
|
/**
|
|
314
315
|
* Step 2 of onboarding: reuse the device session when it already belongs to
|
|
@@ -525,7 +526,7 @@ async function runOnboard(command, io) {
|
|
|
525
526
|
}, null, 2));
|
|
526
527
|
}
|
|
527
528
|
if (onboardOk && !command.json) {
|
|
528
|
-
writeLine(io.stdout, "PASS: Cockpit
|
|
529
|
+
writeLine(io.stdout, "PASS: Cockpit is set up and collecting.");
|
|
529
530
|
writeOnboardLiveStatus(io, resolvedCommand, collectionRoots, {
|
|
530
531
|
pair,
|
|
531
532
|
status,
|
|
@@ -552,7 +553,7 @@ async function runOnboard(command, io) {
|
|
|
552
553
|
writeLine(io.stdout, "3/5 Work context active.");
|
|
553
554
|
writeLine(io.stdout, `Repo: ${context.repo}`);
|
|
554
555
|
writeLine(io.stdout, `Branch: ${context.branch}`);
|
|
555
|
-
writeLine(io.stdout, `Ticket: ${context.active_ticket_id
|
|
556
|
+
writeLine(io.stdout, `Ticket: ${displayTicketId(context.active_ticket_id)}`);
|
|
556
557
|
writeLine(io.stdout, `Context: ${context.work_context_id}`);
|
|
557
558
|
}
|
|
558
559
|
addInstallEvent(installEvents, "work_context", "ok");
|
|
@@ -638,9 +639,9 @@ async function runOnboard(command, io) {
|
|
|
638
639
|
}, null, 2));
|
|
639
640
|
return finish(onboardOk ? 0 : 1);
|
|
640
641
|
}
|
|
641
|
-
writeLine(io.stdout, "4/5
|
|
642
|
+
writeLine(io.stdout, "4/5 Uploaded what you worked on.");
|
|
642
643
|
writeLine(io.stdout, `HTTP: ${sync.http_status}`);
|
|
643
|
-
writeLine(io.stdout, `
|
|
644
|
+
writeLine(io.stdout, `Things recorded: ${sync.event_count}`);
|
|
644
645
|
writeLine(io.stdout, `Sources: ${sync.source_scan_count}`);
|
|
645
646
|
writeLine(io.stdout, `Risk flags: ${sync.risk_flag_count}`);
|
|
646
647
|
writeLine(io.stdout, `Raw evidence files: ${sync.raw_evidence_file_count}`);
|
|
@@ -656,7 +657,7 @@ async function runOnboard(command, io) {
|
|
|
656
657
|
writeOnboardAutostartBlocker(io, autostart);
|
|
657
658
|
return finish(1);
|
|
658
659
|
}
|
|
659
|
-
writeLine(io.stdout, "PASS: Cockpit
|
|
660
|
+
writeLine(io.stdout, "PASS: Cockpit is set up and collecting.");
|
|
660
661
|
writeOnboardLiveStatus(io, resolvedCommand, collectionRoots, {
|
|
661
662
|
pair,
|
|
662
663
|
status,
|
|
@@ -839,7 +840,7 @@ function nextStepForOnboardBlocker(blocker, options = {}) {
|
|
|
839
840
|
case "ticket_binding":
|
|
840
841
|
return "Run `cockpit start --ticket <id>` when actual ticket work begins, then run `cockpit sync`.";
|
|
841
842
|
case "device_pairing":
|
|
842
|
-
return "
|
|
843
|
+
return "Ask Edward to approve this machine in the dashboard under Ambient -> Collector approvals, then run `cockpit onboard` again.";
|
|
843
844
|
case "network_or_ingest":
|
|
844
845
|
return "Check dashboard URL/network, then run `cockpit sync --json` or rerun `cockpit onboard`.";
|
|
845
846
|
case "install":
|
|
@@ -1125,7 +1126,7 @@ async function runSyncWithHealthReceipt(command, io) {
|
|
|
1125
1126
|
}, null, 2));
|
|
1126
1127
|
}
|
|
1127
1128
|
else {
|
|
1128
|
-
writeLine(io.stdout, "
|
|
1129
|
+
writeLine(io.stdout, "Pausing normal sync while it catches up on old sessions.");
|
|
1129
1130
|
}
|
|
1130
1131
|
return {
|
|
1131
1132
|
exitCode: 0,
|
|
@@ -1300,11 +1301,11 @@ async function runSyncLocked(command, io) {
|
|
|
1300
1301
|
return syncResult(run);
|
|
1301
1302
|
}
|
|
1302
1303
|
if (run.ok) {
|
|
1303
|
-
writeLine(io.stdout, "Cockpit
|
|
1304
|
+
writeLine(io.stdout, "Cockpit uploaded this session.");
|
|
1304
1305
|
writeLine(io.stdout, `Ticket: ${displayTicketId(result.ticket_id)}`);
|
|
1305
1306
|
writeLine(io.stdout, `Context: ${result.work_context_id}`);
|
|
1306
1307
|
writeLine(io.stdout, `Head: ${shortSha(result.head_sha)}`);
|
|
1307
|
-
writeLine(io.stdout, `
|
|
1308
|
+
writeLine(io.stdout, `Things recorded: ${result.event_count}`);
|
|
1308
1309
|
writeLine(io.stdout, `Risk flags: ${result.risk_flag_count}`);
|
|
1309
1310
|
writeLine(io.stdout, `Raw evidence files: ${result.raw_evidence_file_count}`);
|
|
1310
1311
|
writeLine(io.stdout, rawEvidenceSyncLine(result));
|
|
@@ -1315,11 +1316,11 @@ async function runSyncLocked(command, io) {
|
|
|
1315
1316
|
return syncResult(run);
|
|
1316
1317
|
}
|
|
1317
1318
|
if (result.status === "uploaded") {
|
|
1318
|
-
writeLine(io.stderr, "Cockpit
|
|
1319
|
+
writeLine(io.stderr, "Cockpit uploaded, but some sessions did not make it. Run `cockpit sync` again.");
|
|
1319
1320
|
writeAgentSessionSummary(io, run.summary);
|
|
1320
1321
|
return syncResult(run);
|
|
1321
1322
|
}
|
|
1322
|
-
writeLine(io.stderr, "Cockpit
|
|
1323
|
+
writeLine(io.stderr, "Cockpit could not upload. It saved a note to retry and will try again on the next sync.");
|
|
1323
1324
|
writeLine(io.stderr, `Ticket: ${displayTicketId(result.ticket_id)}`);
|
|
1324
1325
|
writeLine(io.stderr, `Failure: ${result.failure_reason}`);
|
|
1325
1326
|
writeLine(io.stderr, `Retry: ${result.retry_command}`);
|
|
@@ -1372,7 +1373,16 @@ async function runAnalyze(command, io) {
|
|
|
1372
1373
|
return syncExitCode === 0 ? 1 : syncExitCode;
|
|
1373
1374
|
}
|
|
1374
1375
|
const paths = getCollectorRuntimePaths(command.homeDir);
|
|
1375
|
-
const session = await readLocalCollectorSessionFile(paths).catch(() => {
|
|
1376
|
+
const session = await readLocalCollectorSessionFile(paths).catch((error) => {
|
|
1377
|
+
// "Not signed in" is the right sentence for an absent session file and the
|
|
1378
|
+
// wrong one for a corrupt one, which `cockpit login` will not repair
|
|
1379
|
+
// (BLI-3238).
|
|
1380
|
+
if (!isMissingFileFailure(error)) {
|
|
1381
|
+
console.error("[cockpit-analyze] session file present but unreadable, reporting as not signed in", JSON.stringify({
|
|
1382
|
+
reason: "session_file_unusable",
|
|
1383
|
+
...describeError(error),
|
|
1384
|
+
}));
|
|
1385
|
+
}
|
|
1376
1386
|
throw new Error("Cockpit is not signed in. Run `cockpit onboard` or `cockpit login` first.");
|
|
1377
1387
|
});
|
|
1378
1388
|
const dashboardUrl = normalizeUrl(command.dashboardUrl ?? session.dashboard_url ?? DEFAULT_DASHBOARD_URL);
|
|
@@ -1419,7 +1429,7 @@ async function runAnalyze(command, io) {
|
|
|
1419
1429
|
return 0;
|
|
1420
1430
|
}
|
|
1421
1431
|
replayCaptured(io.stderr, syncStderr);
|
|
1422
|
-
writeLine(io.stdout, "Cockpit latest
|
|
1432
|
+
writeLine(io.stdout, "Cockpit uploaded your latest work.");
|
|
1423
1433
|
writeLine(io.stdout, "Cockpit analysis queued.");
|
|
1424
1434
|
if (jobId)
|
|
1425
1435
|
writeLine(io.stdout, `Job: ${jobId}`);
|
|
@@ -1436,6 +1446,15 @@ async function readAnalyzeApiResponse(response) {
|
|
|
1436
1446
|
return value && typeof value === "object" ? value : {};
|
|
1437
1447
|
}
|
|
1438
1448
|
catch {
|
|
1449
|
+
// `{}` erases whatever the analyze endpoint said, including its error, and
|
|
1450
|
+
// the operator is left with a job id of `undefined` and no reason. The
|
|
1451
|
+
// body is never logged; its shape is what identifies a proxy reply.
|
|
1452
|
+
console.error("[cockpit-analyze] reply was not JSON", JSON.stringify({
|
|
1453
|
+
reason: "response_body_not_json",
|
|
1454
|
+
http_status: response.status,
|
|
1455
|
+
byte_size: text.length,
|
|
1456
|
+
content_type: response.headers.get("content-type") ?? "none",
|
|
1457
|
+
}));
|
|
1439
1458
|
return {};
|
|
1440
1459
|
}
|
|
1441
1460
|
}
|
|
@@ -15,7 +15,7 @@ export async function runCockpitCli(argv, io) {
|
|
|
15
15
|
}
|
|
16
16
|
|
|
17
17
|
if (command === "--version" || command === "-V" || command === "version") {
|
|
18
|
-
writeLine(io?.stdout ?? process.stdout, "0.2.
|
|
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
|
@@ -3,9 +3,11 @@
|
|
|
3
3
|
* active work, upload state, retry backlog, and how far historical backfill
|
|
4
4
|
* has got.
|
|
5
5
|
*
|
|
6
|
-
* Read-only. Split out of commands/local.ts (BLI-3104)
|
|
7
|
-
*
|
|
8
|
-
* wrong.
|
|
6
|
+
* Read-only. Split out of commands/local.ts (BLI-3104). BLI-3194 rewrote the
|
|
7
|
+
* human block into plain English, because these lines are what an intern
|
|
8
|
+
* pastes into Slack when something looks wrong. The `--json` payload is the
|
|
9
|
+
* machine contract and is byte-for-byte unchanged — `status-json-shape.test.ts`
|
|
10
|
+
* holds it to that.
|
|
9
11
|
*/
|
|
10
12
|
import { readdir, stat } from "node:fs/promises";
|
|
11
13
|
import os from "node:os";
|
|
@@ -14,6 +16,7 @@ import { writeLine } from "./cli-io.js";
|
|
|
14
16
|
import { displayTicketId, displayWorkLabel, shortSha, stuckEvidenceLine, } from "./collection-report.js";
|
|
15
17
|
import { discoverCommandWorktrees } from "./local-discovery.js";
|
|
16
18
|
import { defaultCodexSessionDirs } from "../adapters/codex-attribution.js";
|
|
19
|
+
import { describeError } from "../health-detail.js";
|
|
17
20
|
import { backfillCompletionCovers, emptyBackfillCursorState, prepareBackfillCursorForScope, readBackfillCompletionMarker, readBackfillCursor, } from "../cursors/backfill-cursor.js";
|
|
18
21
|
import { getCollectorRuntimePaths, inspectLocalCollectorStatus, readLocalCollectorConfig, } from "../local-state.js";
|
|
19
22
|
import { normalizeCollectionRoots } from "../root-normalization.js";
|
|
@@ -32,8 +35,8 @@ export async function runStatus(command, io) {
|
|
|
32
35
|
writeLine(io.stdout, JSON.stringify({ mode: "multi_repo", statuses, backfill_cursor: backfillCursor }, null, 2));
|
|
33
36
|
return 0;
|
|
34
37
|
}
|
|
35
|
-
writeLine(io.stdout, "Cockpit
|
|
36
|
-
writeLine(io.stdout, `
|
|
38
|
+
writeLine(io.stdout, "Cockpit status — every repo below this folder");
|
|
39
|
+
writeLine(io.stdout, `Old sessions: ${backfillCursorLine(backfillCursor)}`);
|
|
37
40
|
for (const status of statuses) {
|
|
38
41
|
writeLine(io.stdout, `- ${status.repo_label ?? status.repo}/${status.worktree_label ?? "worktree"} · ${status.branch} · head:${shortSha(status.head_sha)} · ${status.upload_state}`);
|
|
39
42
|
}
|
|
@@ -44,24 +47,26 @@ export async function runStatus(command, io) {
|
|
|
44
47
|
writeLine(io.stdout, JSON.stringify({ ...status, backfill_cursor: backfillCursor }, null, 2));
|
|
45
48
|
return 0;
|
|
46
49
|
}
|
|
47
|
-
writeLine(io.stdout, "Cockpit
|
|
48
|
-
writeLine(io.stdout, `
|
|
49
|
-
writeLine(io.stdout, `
|
|
50
|
-
writeLine(io.stdout, `
|
|
51
|
-
writeLine(io.stdout, `
|
|
52
|
-
writeLine(io.stdout, `
|
|
53
|
-
writeLine(io.stdout, `
|
|
54
|
-
writeLine(io.stdout, `
|
|
55
|
-
writeLine(io.stdout, `
|
|
56
|
-
writeLine(io.stdout, `
|
|
57
|
-
writeLine(io.stdout, `
|
|
58
|
-
writeLine(io.stdout, `
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
writeLine(io.stdout, `
|
|
62
|
-
writeLine(io.stdout, `
|
|
63
|
-
writeLine(io.stdout, `
|
|
64
|
-
writeLine(io.stdout, `
|
|
50
|
+
writeLine(io.stdout, "Cockpit status");
|
|
51
|
+
writeLine(io.stdout, `Installed: ${status.installed ? "yes" : "no"}`);
|
|
52
|
+
writeLine(io.stdout, `Signed in: ${signedInLine(status.session_state)}`);
|
|
53
|
+
writeLine(io.stdout, `Repo: ${status.repo}`);
|
|
54
|
+
writeLine(io.stdout, `Branch: ${status.branch}`);
|
|
55
|
+
writeLine(io.stdout, `Ticket: ${displayTicketId(status.active_ticket_id)}`);
|
|
56
|
+
writeLine(io.stdout, `Work: ${displayWorkLabel(status)}`);
|
|
57
|
+
writeLine(io.stdout, `Last collected: ${lastCollectedLine(status.collector_freshness)}`);
|
|
58
|
+
writeLine(io.stdout, `Version: ${status.collector_version}`);
|
|
59
|
+
writeLine(io.stdout, `Uploads: ${uploadsLine(status.upload_state)}`);
|
|
60
|
+
writeLine(io.stdout, `Last upload tried: ${status.last_upload_attempt_at ?? "never"}`);
|
|
61
|
+
writeLine(io.stdout, `Last upload worked: ${status.last_upload_success_at ?? "never"}`);
|
|
62
|
+
// The failure reason itself is a machine label and prints verbatim: a status
|
|
63
|
+
// that hides why the last upload failed is the silent-success failure mode.
|
|
64
|
+
writeLine(io.stdout, `Last upload problem: ${status.last_upload_failure_reason ?? "none"}`);
|
|
65
|
+
writeLine(io.stdout, `Waiting to upload: ${status.pending_upload_count}`);
|
|
66
|
+
writeLine(io.stdout, `Health notes waiting to send: ${status.pending_health_receipt_count}`);
|
|
67
|
+
writeLine(io.stdout, `Last health note problem: ${status.last_health_receipt_failure_reason ?? "none"}`);
|
|
68
|
+
writeLine(io.stdout, `Old sessions: ${backfillCursorLine(backfillCursor)}`);
|
|
69
|
+
writeLine(io.stdout, `Stuck files: ${stuckEvidenceLine(status)}`);
|
|
65
70
|
for (const detail of status.details)
|
|
66
71
|
writeLine(io.stdout, `- ${detail}`);
|
|
67
72
|
return 0;
|
|
@@ -122,11 +127,46 @@ function summarizeBackfillSource(source) {
|
|
|
122
127
|
function backfillCursorLine(status) {
|
|
123
128
|
switch (status.state) {
|
|
124
129
|
case "done":
|
|
125
|
-
return `done (${status.completed_at ?? "
|
|
130
|
+
return `done (${status.completed_at ?? "finished, no date recorded"})`;
|
|
126
131
|
case "never_run":
|
|
127
|
-
return "
|
|
132
|
+
return "not started yet";
|
|
128
133
|
case "remaining":
|
|
129
|
-
return `${status.remaining_count}
|
|
134
|
+
return `${status.remaining_count} still to catch up on`;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
/** Plain words for the four session states; `--json` still gets the raw value. */
|
|
138
|
+
function signedInLine(sessionState) {
|
|
139
|
+
switch (sessionState) {
|
|
140
|
+
case "valid":
|
|
141
|
+
return "yes";
|
|
142
|
+
case "expired":
|
|
143
|
+
return "no — the sign-in expired, run `cockpit login`";
|
|
144
|
+
case "missing":
|
|
145
|
+
return "no — run `cockpit login`";
|
|
146
|
+
case "unknown":
|
|
147
|
+
return "cannot tell";
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
function lastCollectedLine(freshness) {
|
|
151
|
+
switch (freshness) {
|
|
152
|
+
case "fresh":
|
|
153
|
+
return "recently";
|
|
154
|
+
case "stale":
|
|
155
|
+
return "a while ago";
|
|
156
|
+
case "missing":
|
|
157
|
+
return "never";
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
function uploadsLine(uploadState) {
|
|
161
|
+
switch (uploadState) {
|
|
162
|
+
case "ready":
|
|
163
|
+
return "ready";
|
|
164
|
+
case "retry_pending":
|
|
165
|
+
return "waiting to retry";
|
|
166
|
+
case "local_only_missing_auth":
|
|
167
|
+
return "staying on this machine — not signed in";
|
|
168
|
+
case "not_installed":
|
|
169
|
+
return "not set up yet — run `cockpit onboard`";
|
|
130
170
|
}
|
|
131
171
|
}
|
|
132
172
|
async function countRemainingBackfillSessionFiles(homeDir, cursor) {
|
|
@@ -165,6 +205,12 @@ async function countClaudeMainFilesBeforeCursor(projectsDir, oldestProcessedMs)
|
|
|
165
205
|
/** Unreadable folders are skipped rather than failing the walk. */
|
|
166
206
|
async function walkFiles(roots, onFile, shouldStop = () => false) {
|
|
167
207
|
const stack = [...roots];
|
|
208
|
+
// Counted, then reported once at the end: `cockpit status` is what an
|
|
209
|
+
// operator reads to decide whether collection is healthy, and a walk that
|
|
210
|
+
// silently skipped half the store would answer that question wrong
|
|
211
|
+
// (BLI-3238).
|
|
212
|
+
let unreadableDirCount = 0;
|
|
213
|
+
let firstFailure = null;
|
|
168
214
|
while (stack.length > 0 && !shouldStop()) {
|
|
169
215
|
const current = stack.pop();
|
|
170
216
|
if (!current)
|
|
@@ -173,7 +219,9 @@ async function walkFiles(roots, onFile, shouldStop = () => false) {
|
|
|
173
219
|
try {
|
|
174
220
|
entries = await readdir(current, { withFileTypes: true });
|
|
175
221
|
}
|
|
176
|
-
catch {
|
|
222
|
+
catch (error) {
|
|
223
|
+
unreadableDirCount += 1;
|
|
224
|
+
firstFailure ??= describeError(error);
|
|
177
225
|
continue;
|
|
178
226
|
}
|
|
179
227
|
for (const entry of entries) {
|
|
@@ -188,4 +236,11 @@ async function walkFiles(roots, onFile, shouldStop = () => false) {
|
|
|
188
236
|
}
|
|
189
237
|
}
|
|
190
238
|
}
|
|
239
|
+
if (unreadableDirCount > 0) {
|
|
240
|
+
console.error("[cockpit-status] folders skipped while counting", JSON.stringify({
|
|
241
|
+
reason: "status_walk_dir_unreadable",
|
|
242
|
+
unreadable_dir_count: unreadableDirCount,
|
|
243
|
+
...firstFailure,
|
|
244
|
+
}));
|
|
245
|
+
}
|
|
191
246
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import crypto from "node:crypto";
|
|
2
2
|
import fs from "node:fs/promises";
|
|
3
3
|
import path from "node:path";
|
|
4
|
+
import { describeError, isMissingFileFailure } from "../health-detail.js";
|
|
4
5
|
export const BACKFILL_CURSOR_FILENAME = "backfill.json";
|
|
5
6
|
export const BACKFILL_COMPLETION_MARKER_FILENAME = "backfill-complete.json";
|
|
6
7
|
export const BACKFILL_COVERAGE_VERSION = "redacted-session-backfill.v3";
|
|
@@ -20,7 +21,17 @@ export async function readBackfillCursor(paths) {
|
|
|
20
21
|
const raw = JSON.parse(await fs.readFile(backfillCursorPath(paths), "utf8"));
|
|
21
22
|
return parseBackfillCursor(raw);
|
|
22
23
|
}
|
|
23
|
-
catch {
|
|
24
|
+
catch (error) {
|
|
25
|
+
// Missing is the normal pre-backfill state. Present-but-unreadable means
|
|
26
|
+
// the historical sweep is about to restart from scratch, which looks
|
|
27
|
+
// identical from the outside and costs a full re-walk (BLI-3238).
|
|
28
|
+
if (!isMissingFileFailure(error)) {
|
|
29
|
+
console.error("[backfill-cursor] cursor unreadable, restarting the sweep from empty", JSON.stringify({
|
|
30
|
+
reason: "backfill_cursor_unreadable",
|
|
31
|
+
cursor_file: BACKFILL_CURSOR_FILENAME,
|
|
32
|
+
...describeError(error),
|
|
33
|
+
}));
|
|
34
|
+
}
|
|
24
35
|
return emptyBackfillCursorState();
|
|
25
36
|
}
|
|
26
37
|
}
|
|
@@ -36,7 +47,17 @@ export async function readBackfillCompletionMarker(paths) {
|
|
|
36
47
|
const raw = JSON.parse(await fs.readFile(backfillCompletionMarkerPath(paths), "utf8"));
|
|
37
48
|
return parseBackfillCompletionMarker(raw);
|
|
38
49
|
}
|
|
39
|
-
catch {
|
|
50
|
+
catch (error) {
|
|
51
|
+
// No marker means backfill has not finished, which is the ordinary state.
|
|
52
|
+
// A marker that cannot be read means a finished backfill will be re-run,
|
|
53
|
+
// and the machine should say so rather than quietly redo a day of work.
|
|
54
|
+
if (!isMissingFileFailure(error)) {
|
|
55
|
+
console.error("[backfill-cursor] completion marker unreadable, treating backfill as unfinished", JSON.stringify({
|
|
56
|
+
reason: "backfill_marker_unreadable",
|
|
57
|
+
marker_file: BACKFILL_COMPLETION_MARKER_FILENAME,
|
|
58
|
+
...describeError(error),
|
|
59
|
+
}));
|
|
60
|
+
}
|
|
40
61
|
return null;
|
|
41
62
|
}
|
|
42
63
|
}
|