@bli-cockpit/cli 0.2.55 → 0.2.57

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.
@@ -0,0 +1,156 @@
1
+ import { getCollectorRuntimePaths, LOCAL_COLLECTOR_VERSION, readLocalCollectorSessionFile, readLocalSessionReference, } from "./local-state.js";
2
+ import { readLocalUploadSpoolState, recordPendingSessionReport, recordSessionReportFailure, recordSessionReportSuccess, } from "./spool/local-spool.js";
3
+ import { safeRepoLabel } from "./upload-envelope.js";
4
+ import { normalizeDashboardUrl } from "./upload-http.js";
5
+ import { describeError } from "./health-detail.js";
6
+ import { emptyCodexSessionReportResult, reportCodexSessionAttributions, } from "./upload-session-reports-wire.js";
7
+ /**
8
+ * Queues the safe session-attribution rows before the network request. The
9
+ * queue is merged per work context, so an endpoint outage retains every
10
+ * observed session without growing one duplicate report per scheduler pass.
11
+ */
12
+ export async function queueCodexSessionReport(options) {
13
+ if (options.sessions.length === 0)
14
+ return null;
15
+ const paths = getCollectorRuntimePaths(options.homeDir);
16
+ return await recordPendingSessionReport(paths, {
17
+ attempted_at: options.generatedAt,
18
+ dashboard_url: normalizeDashboardUrl(options.dashboardUrl),
19
+ generated_at: options.generatedAt,
20
+ work_context_id: options.workContextId,
21
+ repo_label: safeRepoLabel(options.repoLabel),
22
+ branch: options.branch,
23
+ repo_fingerprint: options.repoFingerprint,
24
+ repo_origin_url: options.repoOriginUrl,
25
+ worktree_label: options.worktreeLabel,
26
+ worktree_fingerprint: options.worktreeFingerprint,
27
+ worktree_is_primary: options.worktreeIsPrimary,
28
+ sessions: options.sessions,
29
+ });
30
+ }
31
+ /**
32
+ * Flushes every durable session-attribution report using current credentials.
33
+ * Report payloads do not depend on the source files still being inside the live
34
+ * scan window, so a transient endpoint failure cannot silently age them out.
35
+ */
36
+ export async function flushPendingCodexSessionReports(options) {
37
+ const paths = getCollectorRuntimePaths(options.homeDir);
38
+ const state = await readLocalUploadSpoolState(paths);
39
+ if (state.pending_session_reports.length === 0) {
40
+ return emptyCodexSessionReportResult("no_pending_session_reports");
41
+ }
42
+ const attemptedAt = (options.now ?? new Date()).toISOString();
43
+ const fetchImpl = options.fetch ?? globalThis.fetch;
44
+ if (!fetchImpl) {
45
+ throw new Error("global fetch is unavailable; use Node.js 20 or newer.");
46
+ }
47
+ let sessionFile;
48
+ let session;
49
+ try {
50
+ [sessionFile, session] = await Promise.all([
51
+ readLocalCollectorSessionFile(paths),
52
+ readLocalSessionReference(paths),
53
+ ]);
54
+ }
55
+ catch (error) {
56
+ // This marks every queued report as failed. The count is what makes it
57
+ // worth a line: an unreadable session file here strands N reports at once
58
+ // and the only visible trace is a spool that stops draining (BLI-3238).
59
+ console.error("[session-reports] session file unreadable, failing the pending reports", JSON.stringify({
60
+ reason: "collector_not_ready",
61
+ pending_report_count: state.pending_session_reports.length,
62
+ ...describeError(error),
63
+ }));
64
+ return await failPendingSessionReports(paths, state.pending_session_reports, attemptedAt, "collector_not_ready");
65
+ }
66
+ if (session.session_state !== "valid") {
67
+ return await failPendingSessionReports(paths, state.pending_session_reports, attemptedAt, "collector_not_paired");
68
+ }
69
+ const results = [];
70
+ for (const pending of state.pending_session_reports) {
71
+ const result = await reportCodexSessionAttributions({
72
+ fetchImpl,
73
+ dashboardUrl: normalizeDashboardUrl(pending.dashboard_url || sessionFile.dashboard_url),
74
+ deviceToken: sessionFile.device_token,
75
+ provenance: provenanceForPendingReport(pending, session),
76
+ generatedAt: pending.generated_at,
77
+ sessions: pending.sessions,
78
+ maxAttemptsPerRequest: options.maxAttemptsPerRequest,
79
+ sleep: options.sleep,
80
+ });
81
+ results.push(result);
82
+ if (result.posted) {
83
+ await recordSessionReportSuccess(paths, {
84
+ reportId: pending.report_id,
85
+ attemptedAt,
86
+ });
87
+ }
88
+ else {
89
+ await recordSessionReportFailure(paths, {
90
+ reportId: pending.report_id,
91
+ attemptedAt,
92
+ reason: result.reason,
93
+ });
94
+ }
95
+ }
96
+ return combineCodexSessionReportResults(results);
97
+ }
98
+ /**
99
+ * A queued report carries its own repo and worktree identity, captured when the
100
+ * sessions were observed. Only the operator and device session come from the
101
+ * credentials in use now — the report may be days old and from another branch.
102
+ */
103
+ function provenanceForPendingReport(pending, session) {
104
+ return {
105
+ capture_source: "collector_runtime",
106
+ capture_adapter_version: LOCAL_COLLECTOR_VERSION,
107
+ collector_version: LOCAL_COLLECTOR_VERSION,
108
+ repo: pending.repo_label,
109
+ branch: pending.branch,
110
+ repo_label: pending.repo_label,
111
+ repo_fingerprint: pending.repo_fingerprint,
112
+ ...(pending.repo_origin_url
113
+ ? { repo_origin_url: pending.repo_origin_url }
114
+ : {}),
115
+ worktree_label: pending.worktree_label,
116
+ worktree_fingerprint: pending.worktree_fingerprint,
117
+ worktree_is_primary: pending.worktree_is_primary,
118
+ operator_id: session.operator_id,
119
+ session_id: session.session_id,
120
+ work_context_id: pending.work_context_id,
121
+ };
122
+ }
123
+ async function failPendingSessionReports(paths, pendingReports, attemptedAt, reason) {
124
+ for (const pending of pendingReports) {
125
+ await recordSessionReportFailure(paths, {
126
+ reportId: pending.report_id,
127
+ attemptedAt,
128
+ reason,
129
+ });
130
+ }
131
+ return {
132
+ posted: false,
133
+ reason,
134
+ chunk_count: 0,
135
+ recorded_count: 0,
136
+ failed_count: pendingReports.length,
137
+ chunks: [],
138
+ };
139
+ }
140
+ function combineCodexSessionReportResults(results) {
141
+ if (results.length === 0) {
142
+ return emptyCodexSessionReportResult("no_pending_session_reports");
143
+ }
144
+ const failed = results.filter((result) => !result.posted);
145
+ return {
146
+ posted: failed.length === 0,
147
+ reason: failed[0]?.reason ??
148
+ (results.every((result) => result.reason === "recorded")
149
+ ? "recorded"
150
+ : results[0]?.reason ?? "recorded"),
151
+ chunk_count: results.reduce((total, result) => total + result.chunk_count, 0),
152
+ recorded_count: results.reduce((total, result) => total + result.recorded_count, 0),
153
+ failed_count: results.reduce((total, result) => total + result.failed_count, 0),
154
+ chunks: results.flatMap((result) => result.chunks),
155
+ };
156
+ }
@@ -0,0 +1,275 @@
1
+ /**
2
+ * The wire: posting Codex session attribution rows and reading back a receipt.
3
+ *
4
+ * - `postCodexSessionReport` — the one-shot path, for a harvest that already
5
+ * has a repo in hand; builds its own provenance and posts immediately.
6
+ * - `reportCodexSessionAttributions` — chunk at the endpoint's session cap,
7
+ * then attempt each chunk under `postCodexSessionAttributionChunk`'s
8
+ * decision table of "final answer" versus "worth another attempt".
9
+ *
10
+ * `upload-session-reports-queue.ts` calls `reportCodexSessionAttributions`
11
+ * once per queued report; nothing here touches the durable queue.
12
+ */
13
+ import { CODEX_SESSION_REPORT_MAX_SESSIONS, CodexSessionAttributionReportResponseSchema, } from "@bli-cockpit/telemetry-core";
14
+ import path from "node:path";
15
+ import { getCollectorRuntimePaths, readLocalCollectorConfig, readLocalCollectorSessionFile, readLocalSessionReference, readLocalWorkContextForRepo, } from "./local-state.js";
16
+ import { makeCollectorProvenance, makeUploadWorkContext, safeRepoLabel, } from "./upload-envelope.js";
17
+ import { normalizeDashboardUrl, readResponseJson } from "./upload-http.js";
18
+ import { describeError } from "./health-detail.js";
19
+ /** Attempts per chunk, and the linear 250ms-per-attempt backoff between them. */
20
+ const DEFAULT_REPORT_ATTEMPTS = 3;
21
+ const REPORT_RETRY_BACKOFF_STEP_MS = 250;
22
+ /**
23
+ * Posts a Codex session attribution report using the paired collector
24
+ * credentials and the work context of a representative repo. Failures come
25
+ * back as reason labels so the harvest never hard-fails on reporting.
26
+ */
27
+ export async function postCodexSessionReport(options) {
28
+ if (options.sessions.length === 0) {
29
+ return emptyCodexSessionReportResult("no_sessions_observed");
30
+ }
31
+ const fetchImpl = options.fetch ?? globalThis.fetch;
32
+ if (!fetchImpl) {
33
+ throw new Error("global fetch is unavailable; use Node.js 20 or newer.");
34
+ }
35
+ try {
36
+ const paths = getCollectorRuntimePaths(options.homeDir);
37
+ const config = await readLocalCollectorConfig(paths);
38
+ const sessionFile = await readLocalCollectorSessionFile(paths);
39
+ const session = await readLocalSessionReference(paths);
40
+ if (session.session_state !== "valid") {
41
+ return emptyCodexSessionReportResult("collector_not_paired");
42
+ }
43
+ const repoRoot = path.resolve(options.repoRoot ?? process.cwd());
44
+ const activeContext = await readLocalWorkContextForRepo(paths, repoRoot);
45
+ const repoLabel = safeRepoLabel(activeContext.repo_label ?? repoRoot);
46
+ const provenance = makeCollectorProvenance({
47
+ context: makeUploadWorkContext({
48
+ activeContext,
49
+ session,
50
+ repoLabel,
51
+ now: options.now ?? new Date(),
52
+ }),
53
+ session,
54
+ repoLabel,
55
+ });
56
+ return await reportCodexSessionAttributions({
57
+ fetchImpl,
58
+ dashboardUrl: normalizeDashboardUrl(options.dashboardUrl ?? sessionFile.dashboard_url ?? config.dashboard_url),
59
+ deviceToken: sessionFile.device_token,
60
+ provenance,
61
+ generatedAt: (options.now ?? new Date()).toISOString(),
62
+ sessions: options.sessions,
63
+ });
64
+ }
65
+ catch (error) {
66
+ // `collector_not_ready` covers this whole block — config read, session
67
+ // read, work-context read AND the report request itself. The label is
68
+ // kept (it is what the receipt contract carries) but it is broader than
69
+ // its name suggests: a network failure inside `reportCodexSessionAttributions`
70
+ // also lands here and reads as "this machine is not set up". The detail is
71
+ // now the only way to tell those apart. Flagged, not reclassified —
72
+ // narrowing the label is a contract change (BLI-3238).
73
+ console.error("[session-reports] could not report session attributions", JSON.stringify({
74
+ reason: "collector_not_ready",
75
+ session_count: options.sessions.length,
76
+ ...describeError(error),
77
+ }));
78
+ return emptyCodexSessionReportResult("collector_not_ready");
79
+ }
80
+ }
81
+ /**
82
+ * Reports Codex session attribution outcomes after sync. Non-fatal by design:
83
+ * older dashboards without the endpoint must not fail the harvest, so the
84
+ * caller receives a posted/skipped label instead of an exception.
85
+ */
86
+ export async function reportCodexSessionAttributions(options) {
87
+ if (options.sessions.length === 0) {
88
+ return emptyCodexSessionReportResult("no_sessions_observed");
89
+ }
90
+ const chunks = [];
91
+ const sessionChunks = chunkArray(options.sessions, CODEX_SESSION_REPORT_MAX_SESSIONS);
92
+ for (const [index, sessions] of sessionChunks.entries()) {
93
+ const chunk = await postCodexSessionAttributionChunk({
94
+ ...options,
95
+ sessions,
96
+ batchIndex: index + 1,
97
+ });
98
+ chunks.push(chunk);
99
+ }
100
+ const failedCount = chunks.filter((chunk) => !chunk.posted).length;
101
+ const recordedCount = chunks.reduce((sum, chunk) => sum + chunk.recorded_count, 0);
102
+ return {
103
+ posted: failedCount === 0,
104
+ reason: failedCount === 0
105
+ ? "recorded"
106
+ : chunks.find((chunk) => !chunk.posted)?.reason ?? "report_failed",
107
+ chunk_count: chunks.length,
108
+ recorded_count: recordedCount,
109
+ failed_count: failedCount,
110
+ chunks,
111
+ };
112
+ }
113
+ /**
114
+ * One chunk, up to three attempts, as a decision table on what came back:
115
+ *
116
+ * | what came back | verdict |
117
+ * |-------------------------------------|----------------------------------------|
118
+ * | 404 | this dashboard has no endpoint; stop |
119
+ * | 2xx with a readable, full receipt | recorded; stop |
120
+ * | 2xx malformed or short of the count | keep the reason, try again |
121
+ * | 4xx other than 429 | the server's final answer; stop |
122
+ * | 429 or 5xx | keep the reason, try again |
123
+ * | transport error | keep the reason, try again, no status |
124
+ *
125
+ * The row that looks wrong is the third one, and it is the important one: a 2xx
126
+ * we cannot read is retried because the server may have died between persisting
127
+ * the rows and answering. Only a schema-valid receipt that accounts for every
128
+ * distinct session is safe to delete a durable pending report against.
129
+ */
130
+ async function postCodexSessionAttributionChunk(options) {
131
+ const maxAttempts = options.maxAttemptsPerRequest ?? DEFAULT_REPORT_ATTEMPTS;
132
+ const sleep = options.sleep ?? defaultReportSleep;
133
+ const requiredAcknowledgements = requiredCodexSessionAcknowledgementCount(options.sessions);
134
+ let lastStatus = null;
135
+ let lastFailureReason = null;
136
+ for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
137
+ try {
138
+ const response = await postSessionAttributionRequest(options);
139
+ lastStatus = response.status;
140
+ const body = await readResponseJson(response);
141
+ if (response.status === 404) {
142
+ return codexSessionReportChunkResult(options, {
143
+ posted: false,
144
+ reason: "codex_session_api_unavailable",
145
+ httpStatus: response.status,
146
+ recordedCount: 0,
147
+ });
148
+ }
149
+ if (response.ok) {
150
+ const receipt = readSessionReportReceipt(body, requiredAcknowledgements);
151
+ if (receipt.accepted) {
152
+ return codexSessionReportChunkResult(options, {
153
+ posted: true,
154
+ reason: "recorded",
155
+ httpStatus: response.status,
156
+ recordedCount: receipt.recorded_count,
157
+ });
158
+ }
159
+ lastFailureReason = receipt.reason;
160
+ }
161
+ else {
162
+ lastFailureReason = `report_failed_http_${response.status}`;
163
+ if (isFinalHttpFailure(response.status)) {
164
+ return codexSessionReportChunkResult(options, {
165
+ posted: false,
166
+ reason: lastFailureReason,
167
+ httpStatus: response.status,
168
+ recordedCount: 0,
169
+ });
170
+ }
171
+ }
172
+ }
173
+ catch (error) {
174
+ // Logged per attempt, on purpose: the interesting failure is the one
175
+ // that repeats. Three identical ECONNRESETs and one DNS failure followed
176
+ // by two resets are different stories, and `report_network_error` alone
177
+ // tells neither (BLI-3238).
178
+ console.error("[session-reports] report attempt failed before a status came back", JSON.stringify({
179
+ reason: "report_network_error",
180
+ attempt,
181
+ max_attempts: maxAttempts,
182
+ batch_index: options.batchIndex,
183
+ session_count: options.sessions.length,
184
+ ...describeError(error),
185
+ }));
186
+ lastStatus = null;
187
+ lastFailureReason = "report_network_error";
188
+ }
189
+ if (attempt < maxAttempts) {
190
+ await sleep(REPORT_RETRY_BACKOFF_STEP_MS * attempt);
191
+ }
192
+ }
193
+ return codexSessionReportChunkResult(options, {
194
+ posted: false,
195
+ reason: lastFailureReason ??
196
+ (lastStatus === null
197
+ ? "report_network_error"
198
+ : `report_failed_http_${lastStatus}`),
199
+ httpStatus: lastStatus,
200
+ recordedCount: 0,
201
+ });
202
+ }
203
+ function postSessionAttributionRequest(options) {
204
+ return options.fetchImpl(`${options.dashboardUrl}/api/ambient/codex-sessions`, {
205
+ method: "POST",
206
+ headers: {
207
+ "Authorization": `Bearer ${options.deviceToken}`,
208
+ "Content-Type": "application/json",
209
+ },
210
+ body: JSON.stringify({
211
+ schema_version: "ambient-codex-session-attributions.v1",
212
+ generated_at: options.generatedAt,
213
+ provenance: options.provenance,
214
+ sessions: options.sessions,
215
+ }),
216
+ });
217
+ }
218
+ /** Is this status the server's final answer, or worth another attempt? */
219
+ function isFinalHttpFailure(status) {
220
+ return status < 500 && status !== 429;
221
+ }
222
+ /**
223
+ * Can we take this 2xx body as proof the rows are durable?
224
+ *
225
+ * Only if it parses as the receipt schema AND accounts for every distinct
226
+ * session in the chunk. Anything less is treated as no receipt at all.
227
+ */
228
+ function readSessionReportReceipt(body, requiredAcknowledgements) {
229
+ const parsed = CodexSessionAttributionReportResponseSchema.safeParse(body);
230
+ if (!parsed.success) {
231
+ return { accepted: false, reason: "report_invalid_response" };
232
+ }
233
+ if (parsed.data.recorded_count < requiredAcknowledgements) {
234
+ return { accepted: false, reason: "report_incomplete_acknowledgement" };
235
+ }
236
+ return { accepted: true, recorded_count: parsed.data.recorded_count };
237
+ }
238
+ function codexSessionReportChunkResult(options, result) {
239
+ return {
240
+ batch_index: options.batchIndex,
241
+ session_count: options.sessions.length,
242
+ posted: result.posted,
243
+ reason: result.reason,
244
+ http_status: result.httpStatus,
245
+ recorded_count: result.recordedCount,
246
+ };
247
+ }
248
+ export function emptyCodexSessionReportResult(reason) {
249
+ return {
250
+ posted: false,
251
+ reason,
252
+ chunk_count: 0,
253
+ recorded_count: 0,
254
+ failed_count: 0,
255
+ chunks: [],
256
+ };
257
+ }
258
+ function chunkArray(items, size) {
259
+ const chunks = [];
260
+ for (let offset = 0; offset < items.length; offset += size) {
261
+ chunks.push(items.slice(offset, offset + size));
262
+ }
263
+ return chunks;
264
+ }
265
+ /**
266
+ * How many rows the server must acknowledge, counted per distinct session
267
+ * rather than per row — the same session observed twice in one chunk is still
268
+ * one row on the far side.
269
+ */
270
+ function requiredCodexSessionAcknowledgementCount(sessions) {
271
+ return new Set(sessions.map((session) => `${session.source ?? "codex"}:${session.codex_session_id}`)).size;
272
+ }
273
+ function defaultReportSleep(milliseconds) {
274
+ return new Promise((resolve) => setTimeout(resolve, milliseconds));
275
+ }