@bli-cockpit/cli 0.2.56 → 0.2.58

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.
Files changed (43) hide show
  1. package/dist/commands/agent-door.js +85 -0
  2. package/dist/commands/docs.js +227 -0
  3. package/dist/commands/issue-contracts.js +99 -0
  4. package/dist/commands/issue-write.js +129 -0
  5. package/dist/commands/issue.js +189 -0
  6. package/dist/commands/local-args-tower-docs-msg.js +126 -0
  7. package/dist/commands/local-args-tower-work.js +178 -0
  8. package/dist/commands/local-args-tower.js +7 -1
  9. package/dist/commands/local-args.js +10 -2
  10. package/dist/commands/local-help.js +70 -0
  11. package/dist/commands/local.js +12 -0
  12. package/dist/commands/mcp-bin-resolve.js +102 -0
  13. package/dist/commands/memory-install-claude.js +13 -5
  14. package/dist/commands/memory-install-config.js +140 -0
  15. package/dist/commands/memory-install-report.js +89 -0
  16. package/dist/commands/memory-install.js +51 -362
  17. package/dist/commands/msg.js +188 -0
  18. package/dist/commands/notes-door.js +120 -0
  19. package/dist/commands/notes-reads.js +134 -0
  20. package/dist/commands/notes-writes.js +208 -0
  21. package/dist/commands/notes.js +16 -442
  22. package/dist/commands/ops-render.js +18 -2
  23. package/dist/commands/ops.js +9 -2
  24. package/dist/commands/project.js +38 -0
  25. package/dist/commands/public-root.js +1 -1
  26. package/dist/commands/tower-mcp-claude.js +30 -0
  27. package/dist/commands/tower-mcp-codex.js +100 -0
  28. package/dist/commands/tower-mcp-contract.js +39 -0
  29. package/dist/commands/tower-mcp-install.js +75 -0
  30. package/dist/repo-identity-fingerprint.js +88 -0
  31. package/dist/repo-identity-git.js +76 -0
  32. package/dist/repo-identity-linked-worktrees.js +81 -0
  33. package/dist/repo-identity.js +5 -222
  34. package/dist/upload-envelope-build.js +240 -0
  35. package/dist/upload-envelope-event.js +198 -0
  36. package/dist/upload-envelope.js +16 -427
  37. package/dist/upload-ingest-receipt.js +121 -0
  38. package/dist/upload-session-reports-queue.js +156 -0
  39. package/dist/upload-session-reports-wire.js +275 -0
  40. package/dist/upload-session-reports.js +14 -425
  41. package/dist/upload-sync.js +291 -0
  42. package/dist/upload.js +24 -396
  43. package/package.json +6 -5
@@ -0,0 +1,121 @@
1
+ import { describeError } from "./health-detail.js";
2
+ import { isNonEmptyString, readResponseJson, serverFailureDetail, } from "./upload-http.js";
3
+ import { SyncDeliveryError } from "./upload-failure-reason.js";
4
+ export async function postEnvelopeToIngest(options) {
5
+ let response;
6
+ try {
7
+ response = await options.fetchImpl(`${options.built.dashboard_url}/api/ambient/ingest`, {
8
+ method: "POST",
9
+ headers: {
10
+ "Authorization": `Bearer ${options.built.device_token}`,
11
+ "Content-Type": "application/json",
12
+ },
13
+ body: JSON.stringify(options.envelope),
14
+ });
15
+ }
16
+ catch (error) {
17
+ // No status came back at all, so no server-side reason exists to read. The
18
+ // error's own name and code (`TypeError` / `ENOTFOUND` / `ECONNREFUSED`)
19
+ // are the whole answer, and they are what separates "this machine is
20
+ // offline" from "the dashboard refused us" — two words that used to be the
21
+ // same spool row.
22
+ const described = describeError(error);
23
+ console.error("[cockpit-sync] ingest request failed before a status came back", JSON.stringify({ reason: "ingest_transport_error", ...described }));
24
+ throw new SyncDeliveryError("ingest_transport_error", {
25
+ detail: [
26
+ described.error_name,
27
+ described.error_code,
28
+ described.cause_code ?? described.cause_name,
29
+ described.error_detail,
30
+ ]
31
+ .filter(Boolean)
32
+ .join(" "),
33
+ cause: error,
34
+ });
35
+ }
36
+ const responseBody = await readResponseJson(response);
37
+ if (!response.ok) {
38
+ const serverReason = serverFailureDetail(responseBody);
39
+ console.error("[cockpit-sync] ingest refused the envelope", JSON.stringify({
40
+ reason: "ingest_rejected",
41
+ http_status: response.status,
42
+ server_reason: serverReason ?? "none",
43
+ event_count: options.envelope.events.length,
44
+ }));
45
+ throw new SyncDeliveryError("ingest_rejected", {
46
+ httpStatus: response.status,
47
+ detail: `http ${response.status}; server reason ${serverReason ?? "none"}`,
48
+ });
49
+ }
50
+ assertAmbientIngestReceipt(response, responseBody, options.envelope);
51
+ return response;
52
+ }
53
+ /**
54
+ * The receipt that proves ingest persisted exactly what was submitted.
55
+ *
56
+ * Anything short of a 202 whose counts match the envelope's own totals is
57
+ * treated as no receipt at all — a proxy's 200, a truncated body, or a
58
+ * dashboard that accepted fewer rows than were sent all land here. They used to
59
+ * land here under the SAME sentence, which is the BLI-3483 complaint: a
60
+ * network appliance answering on the dashboard's behalf and a dashboard that
61
+ * genuinely persisted three of four rows are opposite repairs. They are now two
62
+ * reasons, and the second one names the field that disagreed and by how much.
63
+ */
64
+ function assertAmbientIngestReceipt(response, value, envelope) {
65
+ if (response.status !== 202 || !value || typeof value !== "object") {
66
+ const detail = response.status !== 202
67
+ ? `http ${response.status}; the ingest route answers 202, so something in front of it replied`
68
+ : "202 with a body that is not an object";
69
+ console.error("[cockpit-sync] ingest answered without a durable receipt", JSON.stringify({
70
+ reason: "ingest_receipt_not_202",
71
+ http_status: response.status,
72
+ body_is_object: Boolean(value) && typeof value === "object",
73
+ }));
74
+ throw new SyncDeliveryError("ingest_receipt_not_202", {
75
+ httpStatus: response.status,
76
+ detail,
77
+ });
78
+ }
79
+ const record = value;
80
+ const ingest = record["ingest"] && typeof record["ingest"] === "object"
81
+ ? record["ingest"]
82
+ : null;
83
+ const expectedFactCount = envelope.events.length;
84
+ const expectedRiskFlagCount = envelope.events.reduce((total, event) => total + event.risk_flags.length, 0);
85
+ const expectedEvidenceRefCount = envelope.events.reduce((total, event) => total + event.raw_evidence_pointers.length, 0);
86
+ const mismatches = [];
87
+ if (record["ok"] !== true)
88
+ mismatches.push("ok not true");
89
+ if (!ingest)
90
+ mismatches.push("no ingest block");
91
+ if (ingest && !isNonEmptyString(ingest["work_session_id"])) {
92
+ mismatches.push("blank work_session_id");
93
+ }
94
+ if (ingest) {
95
+ mismatches.push(...countMismatch("fact_count", ingest["fact_count"], expectedFactCount), ...countMismatch("risk_flag_count", ingest["risk_flag_count"], expectedRiskFlagCount), ...countMismatch("evidence_ref_count", ingest["evidence_ref_count"], expectedEvidenceRefCount));
96
+ }
97
+ if (mismatches.length === 0)
98
+ return;
99
+ console.error("[cockpit-sync] ingest receipt does not match what was submitted", JSON.stringify({
100
+ reason: "ingest_receipt_incomplete",
101
+ http_status: response.status,
102
+ mismatch_count: mismatches.length,
103
+ mismatches,
104
+ }));
105
+ throw new SyncDeliveryError("ingest_receipt_incomplete", {
106
+ httpStatus: response.status,
107
+ detail: mismatches.join("; "),
108
+ });
109
+ }
110
+ /**
111
+ * `submitted 4, receipt 3` — the sentence that tells partial persistence from a
112
+ * receipt that never carried the field at all. Counts are numbers this
113
+ * collector computed and numbers the server echoed; neither is content.
114
+ */
115
+ function countMismatch(field, received, expected) {
116
+ if (received === expected)
117
+ return [];
118
+ return [
119
+ `${field} submitted ${expected}, receipt ${typeof received === "number" ? received : "absent"}`,
120
+ ];
121
+ }
@@ -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
+ }