@bli-cockpit/cli 0.2.28 → 0.2.30

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 (33) hide show
  1. package/README.md +22 -15
  2. package/dist/adapters/local-sources.js +1 -0
  3. package/dist/adapters/raw-evidence-completeness.js +226 -0
  4. package/dist/adapters/raw-evidence-git-diff.js +90 -0
  5. package/dist/adapters/raw-evidence-keys.js +92 -0
  6. package/dist/adapters/raw-evidence-manifest.js +132 -0
  7. package/dist/adapters/raw-evidence-pack-store.js +136 -0
  8. package/dist/adapters/raw-evidence-sanitize.js +190 -0
  9. package/dist/adapters/raw-evidence.js +656 -1257
  10. package/dist/commands/backfill.js +7 -0
  11. package/dist/commands/cli-io.js +92 -0
  12. package/dist/commands/collection-report.js +139 -0
  13. package/dist/commands/collection-roots.js +153 -0
  14. package/dist/commands/doctor.js +19 -17
  15. package/dist/commands/install-receipts.js +193 -0
  16. package/dist/commands/install-update.js +305 -0
  17. package/dist/commands/local-auth.js +268 -0
  18. package/dist/commands/local-discovery.js +100 -0
  19. package/dist/commands/local-help.js +281 -0
  20. package/dist/commands/local.js +182 -1872
  21. package/dist/commands/public-root.js +1 -1
  22. package/dist/commands/sessions.js +162 -0
  23. package/dist/commands/status.js +230 -0
  24. package/dist/evidence-upload-client.js +43 -2
  25. package/dist/raw-evidence-gc.js +1 -1
  26. package/dist/raw-evidence-staging.js +15 -2
  27. package/dist/upload-agent-artifacts.js +153 -0
  28. package/dist/upload-envelope.js +407 -0
  29. package/dist/upload-evidence-delivery.js +505 -0
  30. package/dist/upload-http.js +46 -0
  31. package/dist/upload-session-reports.js +404 -0
  32. package/dist/upload.js +132 -1264
  33. package/package.json +2 -2
package/dist/upload.js CHANGED
@@ -1,146 +1,21 @@
1
- import { AgentImageArtifactReportRequestSchema, CODEX_SESSION_REPORT_MAX_SESSIONS, CodexSessionAttributionReportResponseSchema, EvidenceCompletenessPayloadSchema, TelemetryIngestEnvelopeSchema, TelemetryIngestEventDtoSchema, isPermanentUploadFailure, } from "@bli-cockpit/telemetry-core";
2
- import path from "node:path";
3
- import { getCollectorRuntimePaths, LOCAL_COLLECTOR_VERSION, readLocalCollectorConfig, readLocalCollectorSessionFile, readLocalSessionReference, readLocalWorkContextForRepo, } from "./local-state.js";
4
- import { runLocalSourceCollectors } from "./adapters/local-sources.js";
5
- import { defaultCodexSessionDirs, } from "./adapters/codex-attribution.js";
1
+ import { getCollectorRuntimePaths } from "./local-state.js";
6
2
  import { uploadRawEvidenceFilesChunked, } from "./evidence-upload-client.js";
7
3
  import { markObjectCommitted, readRawEvidenceCursor, writeRawEvidenceCursor, } from "./cursors/raw-evidence-cursor.js";
8
- import { clearDeliveryAttempt, DELIVERY_BACKOFF_HOLDING_REASON, deliveryHold, readRawEvidenceStagingState, recordDeliveryFailure, summarizeStuckEvidence, writeRawEvidenceStagingState, } from "./raw-evidence-staging.js";
9
- import { readLocalUploadSpoolState, recordPendingSessionReport, recordSessionReportFailure, recordSessionReportSuccess, recordUploadBlocked, recordUploadFailure, recordUploadSuccess, } from "./spool/local-spool.js";
10
- export class LocalUploadBlockedError extends Error {
11
- blocker;
12
- retry_hint;
13
- constructor(blocker, message, retryHint) {
14
- super(message);
15
- this.name = "LocalUploadBlockedError";
16
- this.blocker = blocker;
17
- this.retry_hint = retryHint;
18
- }
19
- }
20
- export async function buildLocalAmbientEnvelope(options = {}) {
21
- const now = options.now ?? new Date();
22
- const paths = getCollectorRuntimePaths(options.homeDir);
23
- const config = await readLocalCollectorConfig(paths).catch(() => {
24
- throw new LocalUploadBlockedError("not_installed", "Local collector config missing. Install/update the CLI, then run `cockpit do-everything` before `cockpit sync`.", "npm install -g @bli-cockpit/cli@latest && cockpit do-everything");
25
- });
26
- const sessionFile = await readLocalCollectorSessionFile(paths).catch(() => {
27
- throw new LocalUploadBlockedError("unpaired", "No paired collector session found. Run `cockpit login` or `cockpit pair` before `cockpit sync`.", "cockpit login");
28
- });
29
- const session = await readLocalSessionReference(paths);
30
- if (session.session_state !== "valid") {
31
- throw new LocalUploadBlockedError("unpaired", session.session_state === "expired"
32
- ? "Collector session expired. Run `cockpit login` or `cockpit pair` again before `cockpit sync`."
33
- : "Collector is not paired. Run `cockpit login` or `cockpit pair` before `cockpit sync`.", "cockpit login");
34
- }
35
- const repoRoot = path.resolve(options.repoRoot ?? process.cwd());
36
- const activeContext = await readLocalWorkContextForRepo(paths, repoRoot).catch(() => {
37
- throw new LocalUploadBlockedError("missing_context", "Active work context missing. Run `cockpit start --workspace \"$PWD\"` before `cockpit sync`.", "cockpit start --workspace \"$PWD\"");
38
- });
39
- const repoLabel = safeRepoLabel(activeContext.repo_label ?? repoRoot);
40
- const uploadContext = makeUploadWorkContext({
41
- activeContext,
42
- session,
43
- repoLabel,
44
- now,
45
- });
46
- const skipContentHashes = options.skipContentHashes ??
47
- new Set(Object.entries(options.cursorObjects ?? {})
48
- .filter(([, entry]) => rawEvidenceObjectKeyBelongsToWorkContext(entry.object_key, {
49
- operatorId: session.operator_id,
50
- workContextId: uploadContext.work_context_id,
51
- }))
52
- .map(([hash]) => hash));
53
- const sourceCollection = await runLocalSourceCollectors({
54
- repoRoot,
55
- branch: uploadContext.branch,
56
- operatorId: session.operator_id,
57
- operatorLabel: session.email ?? session.auth_subject_id,
58
- sessionId: session.session_id,
59
- workContextId: uploadContext.work_context_id,
60
- activeWorkContext: activeContext,
61
- rawEvidenceStateDir: paths.state_dir,
62
- rawEvidenceSessionsDirs: defaultCodexSessionDirs(paths.home_dir),
63
- claudeProjectsDir: path.join(paths.home_dir, ".claude", "projects"),
64
- rawEvidenceIncludeCodexJsonl: options.rawEvidenceIncludeCodexJsonl,
65
- rawEvidenceIncludeClaudeJsonl: options.rawEvidenceIncludeClaudeJsonl,
66
- rawEvidenceCodexSessionFiles: options.codexSessionFiles,
67
- rawEvidenceCodexAttributionScan: options.codexAttributionScan,
68
- rawEvidenceClaudeSessionFiles: options.claudeSessionFiles,
69
- rawEvidenceClaudeAttributionScan: options.claudeAttributionScan,
70
- rawEvidenceSkipContentHashes: skipContentHashes,
71
- rawEvidenceByteBudget: options.rawEvidenceByteBudget,
72
- rawEvidenceObjectBudget: options.rawEvidenceObjectBudget,
73
- rawEvidenceBudget: options.rawEvidenceBudget,
74
- now,
75
- });
76
- const binding = sourceCollection.binding;
77
- const ticketBinding = selectedTicketBindingCandidate(binding);
78
- const uploadWorkContext = {
79
- ...uploadContext,
80
- active_ticket_id: binding.selected_ticket_id ?? undefined,
81
- ticket_binding_candidates: ticketBinding ? [ticketBinding] : binding.candidates,
82
- };
83
- const safeRiskFlags = sourceCollection.risk_flags.map((flag) => sanitizeRiskFlag(flag, repoLabel));
84
- const events = [
85
- makeSourceScanCompletedEvent({
86
- context: uploadWorkContext,
87
- generatedAt: now.toISOString(),
88
- binding,
89
- ticketBinding,
90
- scans: sourceCollection.scans,
91
- gitChangedFileCount: sourceCollection.facts.git?.changed_file_count ?? 0,
92
- gitAddedLines: sourceCollection.facts.git?.added_lines ?? 0,
93
- gitDeletedLines: sourceCollection.facts.git?.deleted_lines ?? 0,
94
- carOpenTicketCount: sourceCollection.facts.car?.open_ticket_count ?? 0,
95
- rawEvidenceFacts: sourceCollection.facts.raw_evidence,
96
- riskFlags: safeRiskFlags,
97
- }),
98
- ];
99
- const envelope = TelemetryIngestEnvelopeSchema.parse({
100
- envelope_version: "telemetry-ingest.v1",
101
- generated_at: now.toISOString(),
102
- collector_version: LOCAL_COLLECTOR_VERSION,
103
- session_reference: sanitizeSessionReference(session),
104
- work_context: uploadWorkContext,
105
- worktree_inventory: options.worktreeInventory ?? [],
106
- source_scan_results: sanitizeSourceScanResults(sourceCollection.scans, repoLabel),
107
- events,
108
- });
109
- return {
110
- envelope,
111
- dashboard_url: normalizeDashboardUrl(options.dashboardUrl ?? sessionFile.dashboard_url ?? config.dashboard_url),
112
- device_token: sessionFile.device_token,
113
- ticket_id: binding.selected_ticket_id ?? null,
114
- binding,
115
- event_count: envelope.events.length,
116
- source_scan_count: envelope.source_scan_results.length,
117
- risk_flag_count: safeRiskFlags.length,
118
- repo_label: repoLabel,
119
- head_sha: uploadContext.head_sha ?? null,
120
- raw_evidence_upload_files: sourceCollection.facts.raw_evidence?.upload_files ?? [],
121
- raw_evidence_facts: sourceCollection.facts.raw_evidence,
122
- };
123
- }
4
+ import { readRawEvidenceStagingState } from "./raw-evidence-staging.js";
5
+ import { recordUploadBlocked, recordUploadFailure, recordUploadSuccess, } from "./spool/local-spool.js";
6
+ import { buildLocalAmbientEnvelope, LocalUploadBlockedError, } from "./upload-envelope.js";
7
+ import { applyRawEvidenceUploadOutcomes, hasRetryableEvidenceGap, logEvidenceBackoffBypassed, logEvidenceHeldByBackoff, logPermanentlyRejectedEvidence, partitionHeldEvidenceFiles, permanentEvidenceFailureReason, persistDeliveryAttempts, retrySourcesForFailedSync, retryableEvidenceGapReason, summarizeRawEvidenceDelivery, } from "./upload-evidence-delivery.js";
8
+ import { reportAgentImageArtifacts } from "./upload-agent-artifacts.js";
9
+ import { isNonEmptyString, readResponseJson, responseErrorMessage, } from "./upload-http.js";
10
+ export { LocalUploadBlockedError, buildLocalAmbientEnvelope, } from "./upload-envelope.js";
11
+ export { partitionHeldEvidenceFiles } from "./upload-evidence-delivery.js";
12
+ export { reportAgentImageArtifacts } from "./upload-agent-artifacts.js";
13
+ export { flushPendingCodexSessionReports, postCodexSessionReport, queueCodexSessionReport, reportCodexSessionAttributions, } from "./upload-session-reports.js";
124
14
  export async function syncLocalAmbientEnvelope(options = {}) {
125
15
  const attemptedAt = (options.now ?? new Date()).toISOString();
126
16
  const paths = getCollectorRuntimePaths(options.homeDir);
127
17
  const cursor = await readRawEvidenceCursor(paths);
128
- let built;
129
- try {
130
- built = await buildLocalAmbientEnvelope({
131
- ...options,
132
- cursorObjects: options.cursorObjects ?? cursor.objects,
133
- });
134
- }
135
- catch (error) {
136
- if (error instanceof LocalUploadBlockedError) {
137
- await recordUploadBlocked(paths, {
138
- attemptedAt,
139
- reason: error.message,
140
- });
141
- }
142
- throw error;
143
- }
18
+ const built = await buildEnvelopeOrRecordBlocker(paths, options, cursor.objects, attemptedAt);
144
19
  const fetchImpl = options.fetch ?? globalThis.fetch;
145
20
  if (!fetchImpl) {
146
21
  throw new Error("global fetch is unavailable; use Node.js 20 or newer.");
@@ -149,25 +24,24 @@ export async function syncLocalAmbientEnvelope(options = {}) {
149
24
  if (!provenance) {
150
25
  throw new Error("Ambient upload requires collector provenance.");
151
26
  }
27
+ // Assigned progressively so the spool path below can still report what did
28
+ // land if the upload pass throws part-way through.
152
29
  let uploadOutcomes = [];
153
30
  let uploadedChunkCount = 0;
154
31
  const attemptedDate = options.now ?? new Date(attemptedAt);
155
32
  const staging = await readRawEvidenceStagingState(paths.state_dir);
156
33
  try {
157
- if (built.raw_evidence_upload_files.length > 0 && provenance) {
34
+ if (built.raw_evidence_upload_files.length > 0) {
158
35
  // Objects still inside their backoff window are not offered at all. They
159
36
  // become held outcomes below, which keeps them out of the envelope, keeps
160
37
  // the sync in retry_pending, and names the reason — the 559th identical
161
- // attempt is what BLI-3066 made impossible.
162
- const { deliverable, held } = partitionHeldEvidenceFiles(built.raw_evidence_upload_files, staging, attemptedDate);
163
- if (held.length > 0) {
164
- console.error("[cockpit-sync] raw evidence held by delivery backoff", JSON.stringify({
165
- attempted_at: attemptedAt,
166
- reason: DELIVERY_BACKOFF_HOLDING_REASON,
167
- object_count: held.length,
168
- byte_size: held.reduce((sum, outcome) => sum + (outcome.pointer.byte_size ?? 0), 0),
169
- }));
170
- }
38
+ // attempt is what BLI-3066 made impossible. An operator-invoked retry
39
+ // (`cockpit backfill`, doctor) offers them anyway and says so: the window
40
+ // is the scheduler's cadence, not an answer to a person asking now
41
+ // (BLI-3118).
42
+ const { deliverable, held, bypassed } = partitionHeldEvidenceFiles(built.raw_evidence_upload_files, staging, attemptedDate, options.evidenceDeliveryMode);
43
+ logEvidenceHeldByBackoff(held, attemptedAt);
44
+ logEvidenceBackoffBypassed(bypassed, attemptedAt);
171
45
  uploadOutcomes = held;
172
46
  if (deliverable.length > 0) {
173
47
  const upload = await uploadRawEvidenceFilesChunked({
@@ -183,37 +57,12 @@ export async function syncLocalAmbientEnvelope(options = {}) {
183
57
  }
184
58
  await persistDeliveryAttempts(paths.state_dir, staging, uploadOutcomes, attemptedDate);
185
59
  }
186
- const envelope = applyRawEvidenceUploadOutcomes(built.envelope, uploadOutcomes);
187
- const response = await fetchImpl(`${built.dashboard_url}/api/ambient/ingest`, {
188
- method: "POST",
189
- headers: {
190
- "Authorization": `Bearer ${built.device_token}`,
191
- "Content-Type": "application/json",
192
- },
193
- body: JSON.stringify(envelope),
60
+ const response = await postEnvelopeToIngest({
61
+ fetchImpl,
62
+ built,
63
+ envelope: applyRawEvidenceUploadOutcomes(built.envelope, uploadOutcomes),
194
64
  });
195
- const responseBody = await readResponseJson(response);
196
- if (!response.ok) {
197
- throw new Error(responseErrorMessage(responseBody, `Ambient ingest failed with HTTP ${response.status}`));
198
- }
199
- // A 2xx response may have persisted the envelope even when a proxy or
200
- // incompatible dashboard mangles the receipt. Keep uploaded objects in
201
- // that ambiguous case, but do not advance cursors or clear retry state
202
- // until the first-party route proves the exact submitted row counts.
203
- assertAmbientIngestReceipt(response, responseBody, envelope);
204
- for (const outcome of uploadOutcomes) {
205
- if (outcome.upload_state === "upload_failed")
206
- continue;
207
- const contentHash = outcome.pointer.content_hash_sha256;
208
- if (!contentHash)
209
- continue;
210
- markObjectCommitted(cursor, contentHash, {
211
- object_key: outcome.object_key,
212
- byte_size: outcome.pointer.byte_size ?? 0,
213
- committed_at: attemptedAt,
214
- });
215
- }
216
- cursor.updated_at = attemptedAt;
65
+ advanceRawEvidenceCursor(cursor, uploadOutcomes, attemptedAt);
217
66
  await writeRawEvidenceCursor(paths, cursor);
218
67
  await reportAgentImageArtifacts({
219
68
  fetchImpl,
@@ -232,51 +81,13 @@ export async function syncLocalAmbientEnvelope(options = {}) {
232
81
  reason: "agent_artifact_report_local_error",
233
82
  recorded_count: 0,
234
83
  }));
235
- const retryableEvidenceGap = hasRetryableEvidenceGap(built.raw_evidence_facts, uploadOutcomes);
236
- const retrySources = retrySourcesForFailedSync(options, built.raw_evidence_facts);
237
- await recordUploadSuccess(paths, {
84
+ await recordIngestedSyncOutcome({
85
+ paths,
86
+ options,
87
+ built,
88
+ uploadOutcomes,
238
89
  attemptedAt,
239
- workContextId: built.envelope.work_context.work_context_id,
240
- clearPendingForContext: !retryableEvidenceGap,
241
90
  });
242
- if (retryableEvidenceGap) {
243
- await recordUploadFailure(paths, {
244
- last_attempt_at: attemptedAt,
245
- dashboard_url: built.dashboard_url,
246
- work_context_id: built.envelope.work_context.work_context_id,
247
- ticket_id: built.ticket_id,
248
- repo_label: built.repo_label,
249
- branch: built.envelope.work_context.branch,
250
- event_count: built.event_count,
251
- source_scan_count: built.source_scan_count,
252
- risk_flag_count: built.risk_flag_count,
253
- raw_evidence_file_count: built.raw_evidence_upload_files.length,
254
- retry_sources: retrySources,
255
- failure_reason: retryableEvidenceGapReason(built.raw_evidence_facts, uploadOutcomes),
256
- retry_command: "cockpit sync",
257
- });
258
- }
259
- else {
260
- // No retry to queue, but a permanently rejected object still has to say
261
- // so. `recordUploadBlocked` names the reason without spooling a retry —
262
- // the machine reports the failure instead of promising a fix it cannot
263
- // deliver, and `cockpit status` stops reading clean.
264
- const permanentReason = permanentEvidenceFailureReason(uploadOutcomes);
265
- if (permanentReason) {
266
- await recordUploadBlocked(paths, {
267
- attemptedAt,
268
- reason: permanentReason,
269
- });
270
- // stderr, which launchd captures to `sync.err.log`, so an unattended
271
- // machine leaves a dated record of the objects it gave up on. Reason
272
- // labels and counts only — never a path or a byte of content.
273
- console.error("[cockpit-sync] raw evidence permanently rejected", JSON.stringify({
274
- attempted_at: attemptedAt,
275
- reason: permanentReason,
276
- object_count: permanentFailedOutcomes(uploadOutcomes).length,
277
- }));
278
- }
279
- }
280
91
  return {
281
92
  status: "uploaded",
282
93
  dashboard_url: built.dashboard_url,
@@ -287,7 +98,7 @@ export async function syncLocalAmbientEnvelope(options = {}) {
287
98
  source_scan_count: built.source_scan_count,
288
99
  risk_flag_count: built.risk_flag_count,
289
100
  http_status: response.status,
290
- ...rawEvidenceSummary(built, uploadOutcomes, uploadedChunkCount, cursor, staging, attemptedDate),
101
+ ...summarizeRawEvidenceDelivery(built, uploadOutcomes, uploadedChunkCount, cursor, staging, attemptedDate),
291
102
  };
292
103
  }
293
104
  catch (error) {
@@ -324,1061 +135,137 @@ export async function syncLocalAmbientEnvelope(options = {}) {
324
135
  failure_reason: spooledFailureReason,
325
136
  spool_entry_id: entry.spool_id,
326
137
  retry_command: entry.retry_command,
327
- ...rawEvidenceSummary(built, uploadOutcomes, uploadedChunkCount, cursor, staging, attemptedDate),
138
+ ...summarizeRawEvidenceDelivery(built, uploadOutcomes, uploadedChunkCount, cursor, staging, attemptedDate),
328
139
  };
329
140
  }
330
141
  }
331
142
  /**
332
- * Split the pack's files into "offer these now" and "still in backoff".
333
- *
334
- * A held file becomes an `upload_failed` outcome labelled
335
- * `delivery_backoff_holding`. That is deliberate rather than a quiet omission:
336
- * the pointer gets pruned from the envelope (the object is genuinely not
337
- * durable), the sync stays in `retry_pending`, and the reason travels to the
338
- * status output. A hold that read as success would be the green-status-hiding-
339
- * missing-collection failure the fleet contract forbids.
143
+ * Assemble the envelope, and if this machine simply cannot upload, say so in
144
+ * the spool before the error leaves. A blocker that only ever surfaced as a
145
+ * thrown exception would be invisible to the next `cockpit status`.
340
146
  */
341
- export function partitionHeldEvidenceFiles(files, staging, now) {
342
- const deliverable = [];
343
- const held = [];
344
- for (const file of files) {
345
- const hold = deliveryHold(staging, file.pointer.content_hash_sha256, now);
346
- if (!hold) {
347
- deliverable.push(file);
348
- continue;
349
- }
350
- held.push({
351
- pointer: file.pointer,
352
- object_key: file.pointer.object_key ?? "",
353
- codex_session_id: file.codex_session_id ?? null,
354
- kind: file.kind ?? "raw_evidence",
355
- ...(file.artifact_metadata
356
- ? { artifact_metadata: file.artifact_metadata }
357
- : {}),
358
- upload_state: "upload_failed",
359
- reason: DELIVERY_BACKOFF_HOLDING_REASON,
360
- uploaded_chunk_count: 0,
147
+ async function buildEnvelopeOrRecordBlocker(paths, options, cursorObjects, attemptedAt) {
148
+ try {
149
+ return await buildLocalAmbientEnvelope({
150
+ ...options,
151
+ cursorObjects: options.cursorObjects ?? cursorObjects,
361
152
  });
362
153
  }
363
- return { deliverable, held };
364
- }
365
- /**
366
- * Count what just happened to each object, and when it may be offered again.
367
- *
368
- * Written immediately after the upload pass and before ingest, so an ingest
369
- * failure cannot lose the attempt counts — losing them resets every backoff to
370
- * zero and the fleet is back to 15-minute retries forever. A held outcome is
371
- * not itself an attempt: counting it would push its own next attempt further
372
- * out on every sync and eventually never retry at all.
373
- */
374
- async function persistDeliveryAttempts(stateDir, staging, outcomes, attemptedAt) {
375
- let changed = false;
376
- for (const outcome of outcomes) {
377
- const contentHash = outcome.pointer.content_hash_sha256;
378
- if (!contentHash)
379
- continue;
380
- if (outcome.reason === DELIVERY_BACKOFF_HOLDING_REASON)
381
- continue;
382
- if (outcome.upload_state === "upload_failed") {
383
- const entry = recordDeliveryFailure(staging, contentHash, {
384
- reason: outcome.reason ?? "upload_failed",
154
+ catch (error) {
155
+ if (error instanceof LocalUploadBlockedError) {
156
+ await recordUploadBlocked(paths, {
385
157
  attemptedAt,
386
- byteSize: outcome.pointer.byte_size ?? 0,
158
+ reason: error.message,
387
159
  });
388
- changed = true;
389
- console.error("[cockpit-sync] raw evidence delivery failed", JSON.stringify({
390
- reason: entry.last_reason,
391
- kind: outcome.kind,
392
- attempts: entry.attempts,
393
- first_failed_at: entry.first_failed_at,
394
- next_attempt_at: entry.next_attempt_at,
395
- byte_size: entry.byte_size,
396
- }));
397
- continue;
398
- }
399
- if (clearDeliveryAttempt(staging, contentHash)) {
400
- changed = true;
401
- console.error("[cockpit-sync] raw evidence delivery recovered", JSON.stringify({
402
- reason: "delivery_recovered",
403
- kind: outcome.kind,
404
- upload_state: outcome.upload_state,
405
- }));
406
160
  }
161
+ throw error;
407
162
  }
408
- if (!changed)
409
- return;
410
- staging.updated_at = attemptedAt.toISOString();
411
- await writeRawEvidenceStagingState(stateDir, staging).catch((error) => {
412
- console.error("[cockpit-sync] delivery attempt state write failed", JSON.stringify({
413
- reason: "staging_state_write_failed",
414
- detail: error instanceof Error ? error.name : typeof error,
415
- tracked_count: Object.keys(staging.delivery_attempts).length,
416
- }));
417
- });
418
- }
419
- function retrySourcesForFailedSync(options, facts) {
420
- const sources = new Set();
421
- if ((options.codexSessionFiles?.length ?? 0) > 0 ||
422
- (options.codexAttributionScan?.directory_read_failed_count ?? 0) > 0 ||
423
- (options.codexAttributionScan?.stat_failed_count ?? 0) > 0 ||
424
- facts?.evidence_completeness.source_counts.some((count) => count.source.startsWith("codex_") &&
425
- count.scanned_count + count.included_count + count.reused_count > 0)) {
426
- sources.add("codex");
427
- }
428
- if ((options.claudeSessionFiles?.length ?? 0) > 0 ||
429
- (options.claudeAttributionScan?.project_dir_read_failed_count ?? 0) > 0 ||
430
- (options.claudeAttributionScan?.session_stat_failed_count ?? 0) > 0 ||
431
- (options.claudeAttributionScan?.sidecar_dir_read_failed_count ?? 0) > 0 ||
432
- (options.claudeAttributionScan?.sidecar_stat_failed_count ?? 0) > 0 ||
433
- facts?.evidence_completeness.source_counts.some((count) => count.source.startsWith("claude_") &&
434
- count.scanned_count + count.included_count + count.reused_count > 0)) {
435
- sources.add("claude_code");
436
- }
437
- return [...sources];
438
- }
439
- /**
440
- * The failed uploads a later attempt could still rescue.
441
- *
442
- * An object storage has already refused on its own terms is not one of them,
443
- * and counting it as one is what kept Edward's Mac in `retry_pending` through
444
- * 13 consecutive syncs that were never going to end differently (BLI-2528).
445
- */
446
- function retryableFailedOutcomes(outcomes) {
447
- return outcomes.filter((outcome) => outcome.upload_state === "upload_failed" &&
448
- !isPermanentUploadFailure(outcome.reason));
449
- }
450
- /** Failed uploads that no retry can rescue, kept so they can still be named. */
451
- function permanentFailedOutcomes(outcomes) {
452
- return outcomes.filter((outcome) => outcome.upload_state === "upload_failed" &&
453
- isPermanentUploadFailure(outcome.reason));
454
- }
455
- /**
456
- * The reason to show for objects that failed for good.
457
- *
458
- * Returns null when there are none. These never queue a retry, but they must
459
- * never disappear either: a machine with a permanently rejected object has
460
- * missing collection, and a status that reads clean would hide it.
461
- */
462
- function permanentEvidenceFailureReason(outcomes) {
463
- const reasons = new Set(permanentFailedOutcomes(outcomes).map((outcome) => outcome.reason ?? "unknown"));
464
- if (reasons.size === 0)
465
- return null;
466
- return `raw_evidence_permanently_rejected:${[...reasons].sort().join(",")}`;
467
- }
468
- function hasRetryableEvidenceGap(facts, outcomes) {
469
- if (retryableFailedOutcomes(outcomes).length > 0) {
470
- return true;
471
- }
472
- if (!facts)
473
- return false;
474
- if (facts.deferred_byte_budget_count > 0 ||
475
- facts.deferred_object_budget_count > 0) {
476
- return true;
477
- }
478
- if (facts.evidence_completeness.status === "failed" ||
479
- facts.evidence_completeness.totals.failed_count > 0 ||
480
- facts.evidence_completeness.failure_reasons.length > 0) {
481
- return true;
482
- }
483
- return facts.evidence_completeness.skip_reasons.some(({ reason }) => isRetryableEvidenceSkipReason(reason));
484
163
  }
485
164
  /**
486
- * Skip reasons a later sync can still turn into evidence.
165
+ * POST the envelope and refuse to believe an unreadable answer.
487
166
  *
488
- * `delivery_backoff_holding` is one of them, and it has to be: a source held by
489
- * backoff is missing collection right now. If it did not land here the sync
490
- * would read clean while a transcript sat undelivered for nine days, which is
491
- * the exact shape of BLI-3066.
167
+ * A 2xx may have persisted the envelope even when a proxy or an incompatible
168
+ * dashboard mangles the receipt. Uploaded objects are kept in that ambiguous
169
+ * case they are durable either way but the caller must not advance cursors
170
+ * or clear retry state until the first-party route proves the exact submitted
171
+ * row counts, which is what the receipt check demands.
492
172
  */
493
- function isRetryableEvidenceSkipReason(reason) {
494
- if (reason === DELIVERY_BACKOFF_HOLDING_REASON)
495
- return true;
496
- return /(?:read|stat|directory)_failed|session_limit_overflow/iu.test(reason);
497
- }
498
- function retryableEvidenceGapReason(facts, outcomes) {
499
- const reasons = new Set();
500
- for (const outcome of retryableFailedOutcomes(outcomes)) {
501
- reasons.add(outcome.reason ?? "upload_failed");
502
- }
503
- if (facts) {
504
- if (facts.deferred_byte_budget_count > 0)
505
- reasons.add("deferred_byte_budget");
506
- if (facts.deferred_object_budget_count > 0) {
507
- reasons.add("deferred_object_budget");
508
- }
509
- for (const { reason } of facts.evidence_completeness.failure_reasons) {
510
- reasons.add(reason);
511
- }
512
- if (facts.evidence_completeness.status === "failed" &&
513
- facts.evidence_completeness.failure_reasons.length === 0) {
514
- reasons.add("evidence_completeness_failed");
515
- }
516
- for (const { reason } of facts.evidence_completeness.skip_reasons) {
517
- if (isRetryableEvidenceSkipReason(reason)) {
518
- reasons.add(reason);
519
- }
520
- }
521
- }
522
- return `partial_raw_evidence_retry_required:${[
523
- ...reasons,
524
- ].sort().join(",") || "unknown"}`;
525
- }
526
- /**
527
- * Posts a Codex session attribution report using the paired collector
528
- * credentials and the work context of a representative repo. Failures come
529
- * back as reason labels so the harvest never hard-fails on reporting.
530
- */
531
- export async function postCodexSessionReport(options) {
532
- if (options.sessions.length === 0) {
533
- return emptyCodexSessionReportResult("no_sessions_observed");
534
- }
535
- const fetchImpl = options.fetch ?? globalThis.fetch;
536
- if (!fetchImpl) {
537
- throw new Error("global fetch is unavailable; use Node.js 20 or newer.");
538
- }
539
- try {
540
- const paths = getCollectorRuntimePaths(options.homeDir);
541
- const config = await readLocalCollectorConfig(paths);
542
- const sessionFile = await readLocalCollectorSessionFile(paths);
543
- const session = await readLocalSessionReference(paths);
544
- if (session.session_state !== "valid") {
545
- return emptyCodexSessionReportResult("collector_not_paired");
546
- }
547
- const repoRoot = path.resolve(options.repoRoot ?? process.cwd());
548
- const activeContext = await readLocalWorkContextForRepo(paths, repoRoot);
549
- const repoLabel = safeRepoLabel(activeContext.repo_label ?? repoRoot);
550
- const provenance = makeCollectorProvenance({
551
- context: makeUploadWorkContext({
552
- activeContext,
553
- session,
554
- repoLabel,
555
- now: options.now ?? new Date(),
556
- }),
557
- session,
558
- repoLabel,
559
- });
560
- return await reportCodexSessionAttributions({
561
- fetchImpl,
562
- dashboardUrl: normalizeDashboardUrl(options.dashboardUrl ?? sessionFile.dashboard_url ?? config.dashboard_url),
563
- deviceToken: sessionFile.device_token,
564
- provenance,
565
- generatedAt: (options.now ?? new Date()).toISOString(),
566
- sessions: options.sessions,
567
- });
568
- }
569
- catch {
570
- return emptyCodexSessionReportResult("collector_not_ready");
571
- }
572
- }
573
- /**
574
- * Queues the safe session-attribution rows before the network request. The
575
- * queue is merged per work context, so an endpoint outage retains every
576
- * observed session without growing one duplicate report per scheduler pass.
577
- */
578
- export async function queueCodexSessionReport(options) {
579
- if (options.sessions.length === 0)
580
- return null;
581
- const paths = getCollectorRuntimePaths(options.homeDir);
582
- return await recordPendingSessionReport(paths, {
583
- attempted_at: options.generatedAt,
584
- dashboard_url: normalizeDashboardUrl(options.dashboardUrl),
585
- generated_at: options.generatedAt,
586
- work_context_id: options.workContextId,
587
- repo_label: safeRepoLabel(options.repoLabel),
588
- branch: options.branch,
589
- repo_fingerprint: options.repoFingerprint,
590
- repo_origin_url: options.repoOriginUrl,
591
- worktree_label: options.worktreeLabel,
592
- worktree_fingerprint: options.worktreeFingerprint,
593
- worktree_is_primary: options.worktreeIsPrimary,
594
- sessions: options.sessions,
173
+ async function postEnvelopeToIngest(options) {
174
+ const response = await options.fetchImpl(`${options.built.dashboard_url}/api/ambient/ingest`, {
175
+ method: "POST",
176
+ headers: {
177
+ "Authorization": `Bearer ${options.built.device_token}`,
178
+ "Content-Type": "application/json",
179
+ },
180
+ body: JSON.stringify(options.envelope),
595
181
  });
596
- }
597
- /**
598
- * Flushes every durable session-attribution report using current credentials.
599
- * Report payloads do not depend on the source files still being inside the live
600
- * scan window, so a transient endpoint failure cannot silently age them out.
601
- */
602
- export async function flushPendingCodexSessionReports(options) {
603
- const paths = getCollectorRuntimePaths(options.homeDir);
604
- const state = await readLocalUploadSpoolState(paths);
605
- if (state.pending_session_reports.length === 0) {
606
- return emptyCodexSessionReportResult("no_pending_session_reports");
182
+ const responseBody = await readResponseJson(response);
183
+ if (!response.ok) {
184
+ throw new Error(responseErrorMessage(responseBody, `Ambient ingest failed with HTTP ${response.status}`));
607
185
  }
608
- const attemptedAt = (options.now ?? new Date()).toISOString();
609
- const fetchImpl = options.fetch ?? globalThis.fetch;
610
- if (!fetchImpl) {
611
- throw new Error("global fetch is unavailable; use Node.js 20 or newer.");
612
- }
613
- let sessionFile;
614
- let session;
615
- try {
616
- [sessionFile, session] = await Promise.all([
617
- readLocalCollectorSessionFile(paths),
618
- readLocalSessionReference(paths),
619
- ]);
620
- }
621
- catch {
622
- return await failPendingSessionReports(paths, state.pending_session_reports, attemptedAt, "collector_not_ready");
623
- }
624
- if (session.session_state !== "valid") {
625
- return await failPendingSessionReports(paths, state.pending_session_reports, attemptedAt, "collector_not_paired");
626
- }
627
- const results = [];
628
- for (const pending of state.pending_session_reports) {
629
- const provenance = {
630
- capture_source: "collector_runtime",
631
- capture_adapter_version: LOCAL_COLLECTOR_VERSION,
632
- collector_version: LOCAL_COLLECTOR_VERSION,
633
- repo: pending.repo_label,
634
- branch: pending.branch,
635
- repo_label: pending.repo_label,
636
- repo_fingerprint: pending.repo_fingerprint,
637
- ...(pending.repo_origin_url
638
- ? { repo_origin_url: pending.repo_origin_url }
639
- : {}),
640
- worktree_label: pending.worktree_label,
641
- worktree_fingerprint: pending.worktree_fingerprint,
642
- worktree_is_primary: pending.worktree_is_primary,
643
- operator_id: session.operator_id,
644
- session_id: session.session_id,
645
- work_context_id: pending.work_context_id,
646
- };
647
- const result = await reportCodexSessionAttributions({
648
- fetchImpl,
649
- dashboardUrl: normalizeDashboardUrl(pending.dashboard_url || sessionFile.dashboard_url),
650
- deviceToken: sessionFile.device_token,
651
- provenance,
652
- generatedAt: pending.generated_at,
653
- sessions: pending.sessions,
654
- maxAttemptsPerRequest: options.maxAttemptsPerRequest,
655
- sleep: options.sleep,
656
- });
657
- results.push(result);
658
- if (result.posted) {
659
- await recordSessionReportSuccess(paths, {
660
- reportId: pending.report_id,
661
- attemptedAt,
662
- });
663
- }
664
- else {
665
- await recordSessionReportFailure(paths, {
666
- reportId: pending.report_id,
667
- attemptedAt,
668
- reason: result.reason,
669
- });
670
- }
671
- }
672
- return combineCodexSessionReportResults(results);
673
- }
674
- async function failPendingSessionReports(paths, pendingReports, attemptedAt, reason) {
675
- for (const pending of pendingReports) {
676
- await recordSessionReportFailure(paths, {
677
- reportId: pending.report_id,
678
- attemptedAt,
679
- reason,
680
- });
681
- }
682
- return {
683
- posted: false,
684
- reason,
685
- chunk_count: 0,
686
- recorded_count: 0,
687
- failed_count: pendingReports.length,
688
- chunks: [],
689
- };
690
- }
691
- function combineCodexSessionReportResults(results) {
692
- if (results.length === 0) {
693
- return emptyCodexSessionReportResult("no_pending_session_reports");
694
- }
695
- const failed = results.filter((result) => !result.posted);
696
- return {
697
- posted: failed.length === 0,
698
- reason: failed[0]?.reason ??
699
- (results.every((result) => result.reason === "recorded")
700
- ? "recorded"
701
- : results[0]?.reason ?? "recorded"),
702
- chunk_count: results.reduce((total, result) => total + result.chunk_count, 0),
703
- recorded_count: results.reduce((total, result) => total + result.recorded_count, 0),
704
- failed_count: results.reduce((total, result) => total + result.failed_count, 0),
705
- chunks: results.flatMap((result) => result.chunks),
706
- };
186
+ assertAmbientIngestReceipt(response, responseBody, options.envelope);
187
+ return response;
707
188
  }
708
189
  /**
709
- * Reports Codex session attribution outcomes after sync. Non-fatal by design:
710
- * older dashboards without the endpoint must not fail the harvest, so the
711
- * caller receives a posted/skipped label instead of an exception.
190
+ * Remember every object that is now durable, so the next sync can reuse the
191
+ * bytes instead of re-uploading them. A failed upload is deliberately absent:
192
+ * recording it would make the next sync skip a file that never landed.
712
193
  */
713
- export async function reportCodexSessionAttributions(options) {
714
- if (options.sessions.length === 0) {
715
- return emptyCodexSessionReportResult("no_sessions_observed");
716
- }
717
- const chunks = [];
718
- const sessionChunks = chunkArray(options.sessions, CODEX_SESSION_REPORT_MAX_SESSIONS);
719
- for (const [index, sessions] of sessionChunks.entries()) {
720
- const chunk = await postCodexSessionAttributionChunk({
721
- ...options,
722
- sessions,
723
- batchIndex: index + 1,
724
- });
725
- chunks.push(chunk);
726
- }
727
- const failedCount = chunks.filter((chunk) => !chunk.posted).length;
728
- const recordedCount = chunks.reduce((sum, chunk) => sum + chunk.recorded_count, 0);
729
- return {
730
- posted: failedCount === 0,
731
- reason: failedCount === 0
732
- ? "recorded"
733
- : chunks.find((chunk) => !chunk.posted)?.reason ?? "report_failed",
734
- chunk_count: chunks.length,
735
- recorded_count: recordedCount,
736
- failed_count: failedCount,
737
- chunks,
738
- };
739
- }
740
- async function postCodexSessionAttributionChunk(options) {
741
- const maxAttempts = options.maxAttemptsPerRequest ?? 3;
742
- const sleep = options.sleep ?? defaultReportSleep;
743
- let lastStatus = null;
744
- let lastFailureReason = null;
745
- for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
746
- try {
747
- const response = await options.fetchImpl(`${options.dashboardUrl}/api/ambient/codex-sessions`, {
748
- method: "POST",
749
- headers: {
750
- "Authorization": `Bearer ${options.deviceToken}`,
751
- "Content-Type": "application/json",
752
- },
753
- body: JSON.stringify({
754
- schema_version: "ambient-codex-session-attributions.v1",
755
- generated_at: options.generatedAt,
756
- provenance: options.provenance,
757
- sessions: options.sessions,
758
- }),
759
- });
760
- lastStatus = response.status;
761
- const body = await readResponseJson(response);
762
- if (response.status === 404) {
763
- return codexSessionReportChunkResult(options, {
764
- posted: false,
765
- reason: "codex_session_api_unavailable",
766
- httpStatus: response.status,
767
- recordedCount: 0,
768
- });
769
- }
770
- if (response.ok) {
771
- const parsed = CodexSessionAttributionReportResponseSchema.safeParse(body);
772
- if (!parsed.success) {
773
- lastFailureReason = "report_invalid_response";
774
- }
775
- else if (parsed.data.recorded_count <
776
- requiredCodexSessionAcknowledgementCount(options.sessions)) {
777
- lastFailureReason = "report_incomplete_acknowledgement";
778
- }
779
- else {
780
- return codexSessionReportChunkResult(options, {
781
- posted: true,
782
- reason: "recorded",
783
- httpStatus: response.status,
784
- recordedCount: parsed.data.recorded_count,
785
- });
786
- }
787
- }
788
- else {
789
- lastFailureReason = `report_failed_http_${response.status}`;
790
- }
791
- if (response.status < 500 && response.status !== 429) {
792
- if (response.ok) {
793
- // A malformed or short 2xx response is retryable. The server may have
794
- // failed between persisting the rows and producing its acknowledgement,
795
- // so only a schema-valid, sufficiently large acknowledgement is safe
796
- // to use for deleting a durable pending report.
797
- if (attempt < maxAttempts)
798
- await sleep(250 * attempt);
799
- continue;
800
- }
801
- return codexSessionReportChunkResult(options, {
802
- posted: false,
803
- reason: `report_failed_http_${response.status}`,
804
- httpStatus: response.status,
805
- recordedCount: 0,
806
- });
807
- }
808
- }
809
- catch {
810
- lastStatus = null;
811
- lastFailureReason = "report_network_error";
812
- }
813
- if (attempt < maxAttempts)
814
- await sleep(250 * attempt);
815
- }
816
- return codexSessionReportChunkResult(options, {
817
- posted: false,
818
- reason: lastFailureReason ??
819
- (lastStatus === null
820
- ? "report_network_error"
821
- : `report_failed_http_${lastStatus}`),
822
- httpStatus: lastStatus,
823
- recordedCount: 0,
824
- });
825
- }
826
- function codexSessionReportChunkResult(options, result) {
827
- return {
828
- batch_index: options.batchIndex,
829
- session_count: options.sessions.length,
830
- posted: result.posted,
831
- reason: result.reason,
832
- http_status: result.httpStatus,
833
- recorded_count: result.recordedCount,
834
- };
835
- }
836
- function emptyCodexSessionReportResult(reason) {
837
- return {
838
- posted: false,
839
- reason,
840
- chunk_count: 0,
841
- recorded_count: 0,
842
- failed_count: 0,
843
- chunks: [],
844
- };
845
- }
846
- function chunkArray(items, size) {
847
- const chunks = [];
848
- for (let offset = 0; offset < items.length; offset += size) {
849
- chunks.push(items.slice(offset, offset + size));
850
- }
851
- return chunks;
852
- }
853
- function requiredCodexSessionAcknowledgementCount(sessions) {
854
- return new Set(sessions.map((session) => `${session.source ?? "codex"}:${session.codex_session_id}`)).size;
855
- }
856
- function defaultReportSleep(milliseconds) {
857
- return new Promise((resolve) => setTimeout(resolve, milliseconds));
858
- }
859
- export async function reportAgentImageArtifacts(options) {
860
- const artifacts = agentArtifactsFromEvidence(options);
861
- if (artifacts.length === 0) {
862
- return {
863
- posted: false,
864
- reason: "no_agent_image_artifacts",
865
- recorded_count: 0,
866
- };
867
- }
868
- const payload = AgentImageArtifactReportRequestSchema.parse({
869
- schema_version: "ambient-agent-image-artifacts.v1",
870
- generated_at: options.generatedAt,
871
- provenance: options.provenance,
872
- artifacts,
873
- });
874
- try {
875
- const response = await options.fetchImpl(`${options.dashboardUrl}/api/ambient/agent-artifacts`, {
876
- method: "POST",
877
- headers: {
878
- "Authorization": `Bearer ${options.deviceToken}`,
879
- "Content-Type": "application/json",
880
- },
881
- body: JSON.stringify(payload),
882
- });
883
- if (response.status === 404) {
884
- return {
885
- posted: false,
886
- reason: "agent_artifact_api_unavailable",
887
- recorded_count: 0,
888
- };
889
- }
890
- if (!response.ok) {
891
- return {
892
- posted: false,
893
- reason: `report_failed_http_${response.status}`,
894
- recorded_count: 0,
895
- };
896
- }
897
- const body = await readResponseJson(response);
898
- const recordedCount = body && typeof body === "object"
899
- ? Number(body.recorded_count ?? 0)
900
- : 0;
901
- return {
902
- posted: true,
903
- reason: "recorded",
904
- recorded_count: Number.isFinite(recordedCount) ? recordedCount : 0,
905
- };
906
- }
907
- catch {
908
- return { posted: false, reason: "report_network_error", recorded_count: 0 };
909
- }
910
- }
911
- function agentArtifactsFromEvidence(options) {
912
- const byPointerId = new Map();
913
- for (const outcome of options.outcomes) {
914
- if (outcome.upload_state === "upload_failed" || !outcome.artifact_metadata) {
915
- continue;
916
- }
917
- const artifact = agentArtifactFromMetadata({
918
- metadata: outcome.artifact_metadata,
919
- rawEvidencePointerId: outcome.pointer.raw_evidence_pointer_id,
920
- objectKey: outcome.object_key,
921
- ticketId: options.ticketId,
922
- repoFingerprint: options.repoFingerprint,
923
- worktreeFingerprint: options.worktreeFingerprint,
924
- uploadState: outcome.upload_state,
925
- });
926
- byPointerId.set(artifact.raw_evidence_pointer_id, artifact);
927
- }
928
- for (const entry of options.reused) {
929
- if (!entry.artifact_metadata)
194
+ function advanceRawEvidenceCursor(cursor, outcomes, attemptedAt) {
195
+ for (const outcome of outcomes) {
196
+ if (outcome.upload_state === "upload_failed")
930
197
  continue;
931
- const objectKey = options.cursorObjects[entry.content_hash_sha256]?.object_key;
932
- if (!objectKey)
198
+ const contentHash = outcome.pointer.content_hash_sha256;
199
+ if (!contentHash)
933
200
  continue;
934
- const artifact = agentArtifactFromMetadata({
935
- metadata: entry.artifact_metadata,
936
- rawEvidencePointerId: objectKey,
937
- objectKey,
938
- ticketId: options.ticketId,
939
- repoFingerprint: options.repoFingerprint,
940
- worktreeFingerprint: options.worktreeFingerprint,
941
- uploadState: "reused_existing",
201
+ markObjectCommitted(cursor, contentHash, {
202
+ object_key: outcome.object_key,
203
+ byte_size: outcome.pointer.byte_size ?? 0,
204
+ committed_at: attemptedAt,
942
205
  });
943
- byPointerId.set(artifact.raw_evidence_pointer_id, artifact);
944
206
  }
945
- return [...byPointerId.values()];
946
- }
947
- function agentArtifactFromMetadata(options) {
948
- return {
949
- raw_evidence_pointer_id: options.rawEvidencePointerId,
950
- agent_source: options.metadata.agent_source,
951
- source_session_id: options.metadata.source_session_id,
952
- ...(options.metadata.source_message_id
953
- ? { source_message_id: options.metadata.source_message_id }
954
- : {}),
955
- ...(options.metadata.turn_index !== undefined
956
- ? { turn_index: options.metadata.turn_index }
957
- : {}),
958
- occurred_at: options.metadata.occurred_at,
959
- artifact_kind: options.metadata.artifact_kind,
960
- capture_origin: "agent_session_attachment",
961
- ...(options.ticketId ? { ticket_id: options.ticketId } : {}),
962
- ...(options.repoFingerprint
963
- ? { repo_fingerprint: options.repoFingerprint }
964
- : {}),
965
- ...(options.worktreeFingerprint
966
- ? { worktree_fingerprint: options.worktreeFingerprint }
967
- : {}),
968
- storage_bucket: "ambient-raw-evidence",
969
- object_key: options.objectKey,
970
- content_hash_sha256: options.metadata.content_hash_sha256,
971
- byte_size: options.metadata.byte_size,
972
- media_type: options.metadata.media_type,
973
- width: options.metadata.width,
974
- height: options.metadata.height,
975
- redaction_status: "not_started",
976
- ocr_status: "not_started",
977
- labels: {
978
- collector_upload_state: options.uploadState,
979
- ...(options.metadata.source_sidecar_id
980
- ? { source_sidecar_id: options.metadata.source_sidecar_id }
981
- : {}),
982
- },
983
- };
984
- }
985
- function rawEvidenceSummary(built, outcomes, uploadedChunkCount, cursor, staging, now) {
986
- const stuck = summarizeStuckEvidence(staging, now);
987
- const cursorReused = built.raw_evidence_facts?.reused ?? [];
988
- const serverReusedCount = outcomes.filter((outcome) => outcome.upload_state === "reused_existing").length;
989
- const failed = outcomes.filter((outcome) => outcome.upload_state === "upload_failed");
990
- const retryRequired = hasRetryableEvidenceGap(built.raw_evidence_facts, outcomes);
991
- const retryReason = retryRequired
992
- ? retryableEvidenceGapReason(built.raw_evidence_facts, outcomes)
993
- : null;
994
- const cursorReusedOutcomes = cursorReused.map((entry) => ({
995
- object_key: cursor.objects[entry.content_hash_sha256]?.object_key ?? "",
996
- raw_evidence_pointer_id: cursor.objects[entry.content_hash_sha256]?.object_key ?? "",
997
- kind: entry.kind,
998
- codex_session_id: entry.codex_session_id,
999
- ...(entry.artifact_metadata
1000
- ? { artifact_metadata: entry.artifact_metadata }
1001
- : {}),
1002
- upload_state: "reused_existing",
1003
- reason: "cursor_content_match",
1004
- }));
1005
- return {
1006
- raw_evidence_file_count: built.raw_evidence_upload_files.length,
1007
- raw_evidence_uploaded_object_count: outcomes.filter((outcome) => outcome.upload_state === "uploaded").length,
1008
- raw_evidence_uploaded_chunk_count: uploadedChunkCount,
1009
- raw_evidence_reused_count: cursorReused.length + serverReusedCount,
1010
- raw_evidence_failed_count: failed.length,
1011
- raw_evidence_sanitized_count: built.raw_evidence_facts?.sanitized_count ?? 0,
1012
- raw_evidence_failure_reasons: [
1013
- ...new Set(failed.map((outcome) => outcome.reason ?? "unknown")),
1014
- ],
1015
- raw_evidence_retry_required: retryRequired,
1016
- raw_evidence_retry_reasons: retryReason
1017
- ? retryReason
1018
- .replace(/^partial_raw_evidence_retry_required:/u, "")
1019
- .split(",")
1020
- .filter(Boolean)
1021
- : [],
1022
- raw_evidence_outcomes: [
1023
- ...outcomes.map((outcome) => ({
1024
- object_key: outcome.object_key,
1025
- raw_evidence_pointer_id: outcome.pointer.raw_evidence_pointer_id,
1026
- kind: outcome.kind,
1027
- codex_session_id: outcome.codex_session_id,
1028
- ...(outcome.artifact_metadata
1029
- ? { artifact_metadata: outcome.artifact_metadata }
1030
- : {}),
1031
- upload_state: outcome.upload_state,
1032
- reason: outcome.reason,
1033
- })),
1034
- ...cursorReusedOutcomes,
1035
- ],
1036
- raw_evidence_deferred_byte_budget: built.raw_evidence_facts?.deferred_byte_budget_count ?? 0,
1037
- raw_evidence_deferred_object_budget: built.raw_evidence_facts?.deferred_object_budget_count ?? 0,
1038
- cursor_tracked_object_count: Object.keys(cursor.objects).length,
1039
- raw_evidence_delivery_held_count: outcomes.filter((outcome) => outcome.reason === DELIVERY_BACKOFF_HOLDING_REASON).length + (built.raw_evidence_facts?.delivery_held_count ?? 0),
1040
- raw_evidence_stuck_object_count: stuck.stuck_object_count,
1041
- raw_evidence_max_delivery_attempts: stuck.max_attempts,
1042
- raw_evidence_oldest_delivery_failure_at: stuck.oldest_first_failed_at,
1043
- };
207
+ cursor.updated_at = attemptedAt;
1044
208
  }
1045
209
  /**
1046
- * Ingest refuses pointers whose objects never became durable, so failed
1047
- * uploads are pruned from the envelope instead of failing the whole sync.
1048
- * Successful upload responses can also carry server-side sanitized hash and
1049
- * redaction metadata; apply those before ingest so refs describe the bytes
1050
- * actually stored in the durable bucket.
210
+ * What the operator is owed after ingest accepted the envelope, as a decision
211
+ * table on the evidence this sync left behind:
212
+ *
213
+ * | evidence state | ledger |
214
+ * |---------------------------------------|-------------------------------------|
215
+ * | a gap a later sync could rescue | success, then a queued retry naming why |
216
+ * | a gap no retry can rescue | success, then a blocker naming why, plus a stderr record |
217
+ * | nothing missing | success, pending state cleared |
218
+ *
219
+ * The middle row is the one that matters. A permanently rejected object gets no
220
+ * retry — nothing would change — but it must still be named, or `cockpit status`
221
+ * reads clean on a machine with missing collection (BLI-2528).
1051
222
  */
1052
- function applyRawEvidenceUploadOutcomes(envelope, outcomes) {
1053
- // Content-addressed keys mean one pointer id can carry several outcomes
1054
- // (byte-identical files); the pointer is durable if ANY outcome succeeded.
1055
- const durablePointers = new Map();
1056
- for (const outcome of outcomes) {
1057
- if (outcome.upload_state === "upload_failed")
1058
- continue;
1059
- durablePointers.set(outcome.pointer.raw_evidence_pointer_id, outcome.pointer);
1060
- }
1061
- const durablePointerIds = new Set([...durablePointers.keys()]);
1062
- const failedPointerIds = new Set(outcomes
1063
- .filter((outcome) => outcome.upload_state === "upload_failed" &&
1064
- !durablePointerIds.has(outcome.pointer.raw_evidence_pointer_id))
1065
- .map((outcome) => outcome.pointer.raw_evidence_pointer_id));
1066
- const failedOutcomes = outcomes.filter((outcome) => failedPointerIds.has(outcome.pointer.raw_evidence_pointer_id));
1067
- if (failedPointerIds.size === 0 && durablePointers.size === 0)
1068
- return envelope;
1069
- return {
1070
- ...envelope,
1071
- events: envelope.events.map((event) => ({
1072
- ...event,
1073
- metrics: failedPointerIds.size > 0
1074
- ? {
1075
- ...event.metrics,
1076
- evidence_failed_count: (event.metrics["evidence_failed_count"] ?? 0) +
1077
- failedOutcomes.length,
1078
- }
1079
- : event.metrics,
1080
- attributes: failedPointerIds.size > 0 && event.evidence_completeness
1081
- ? {
1082
- ...event.attributes,
1083
- evidence_completeness_schema_version: event.evidence_completeness.schema_version,
1084
- evidence_completeness_status: "partial",
1085
- evidence_incomplete: true,
1086
- }
1087
- : event.attributes,
1088
- evidence_completeness: failedPointerIds.size > 0 && event.evidence_completeness
1089
- ? markCompletenessUploadFailures(event.evidence_completeness, failedOutcomes)
1090
- : event.evidence_completeness,
1091
- raw_evidence_pointers: event.raw_evidence_pointers.flatMap((pointer) => {
1092
- const pointerId = pointer.raw_evidence_pointer_id;
1093
- if (failedPointerIds.has(pointerId))
1094
- return [];
1095
- return [durablePointers.get(pointerId) ?? pointer];
1096
- }),
1097
- redaction: failedPointerIds.size > 0
1098
- ? {
1099
- ...event.redaction,
1100
- raw_evidence_pointer_ids: event.redaction.raw_evidence_pointer_ids.filter((pointerId) => !failedPointerIds.has(pointerId)),
1101
- }
1102
- : event.redaction,
1103
- })),
1104
- };
1105
- }
1106
- function markCompletenessUploadFailures(completeness, failedOutcomes) {
1107
- const failureCounts = new Map();
1108
- for (const outcome of failedOutcomes) {
1109
- const source = outcome.kind ?? "raw_evidence";
1110
- failureCounts.set(source, (failureCounts.get(source) ?? 0) + 1);
1111
- }
1112
- const totalFailures = [...failureCounts.values()].reduce((sum, count) => sum + count, 0);
1113
- const sourceCounts = [...completeness.source_counts];
1114
- for (const [source, count] of failureCounts) {
1115
- const existingIndex = sourceCounts.findIndex((entry) => entry.source === source);
1116
- if (existingIndex === -1) {
1117
- sourceCounts.push({
1118
- source,
1119
- scanned_count: 0,
1120
- included_count: 0,
1121
- skipped_count: 0,
1122
- truncated_count: 0,
1123
- deferred_count: 0,
1124
- reused_count: 0,
1125
- failed_count: count,
1126
- });
1127
- continue;
1128
- }
1129
- const existing = sourceCounts[existingIndex];
1130
- if (!existing)
1131
- continue;
1132
- sourceCounts[existingIndex] = {
1133
- ...existing,
1134
- failed_count: existing.failed_count + count,
1135
- };
1136
- }
1137
- const failureReasons = [...completeness.failure_reasons];
1138
- for (const [source, count] of failureCounts) {
1139
- const reason = "upload_failed";
1140
- const existingIndex = failureReasons.findIndex((entry) => entry.source === source && entry.reason === reason);
1141
- if (existingIndex === -1) {
1142
- failureReasons.push({ source, reason, count });
1143
- }
1144
- else {
1145
- const existing = failureReasons[existingIndex];
1146
- if (existing) {
1147
- failureReasons[existingIndex] = {
1148
- ...existing,
1149
- count: existing.count + count,
1150
- };
1151
- }
1152
- }
1153
- }
1154
- return EvidenceCompletenessPayloadSchema.parse({
1155
- ...completeness,
1156
- status: "partial",
1157
- source_counts: sourceCounts,
1158
- totals: {
1159
- ...completeness.totals,
1160
- failed_count: completeness.totals.failed_count + totalFailures,
1161
- },
1162
- failure_reasons: failureReasons.sort((a, b) => `${a.source}:${a.reason}`.localeCompare(`${b.source}:${b.reason}`)),
1163
- notes: [
1164
- ...new Set([
1165
- ...completeness.notes,
1166
- "Some collected evidence did not become durable; downstream analysis should lower confidence.",
1167
- ]),
1168
- ],
223
+ async function recordIngestedSyncOutcome(options) {
224
+ const { paths, built, uploadOutcomes, attemptedAt } = options;
225
+ const retryableEvidenceGap = hasRetryableEvidenceGap(built.raw_evidence_facts, uploadOutcomes);
226
+ await recordUploadSuccess(paths, {
227
+ attemptedAt,
228
+ workContextId: built.envelope.work_context.work_context_id,
229
+ clearPendingForContext: !retryableEvidenceGap,
1169
230
  });
1170
- }
1171
- function makeUploadWorkContext(options) {
1172
- const provenance = makeCollectorProvenance({
1173
- context: options.activeContext,
1174
- session: options.session,
1175
- repoLabel: options.repoLabel,
1176
- });
1177
- return {
1178
- ...options.activeContext,
1179
- repo: options.repoLabel,
1180
- repo_label: options.activeContext.repo_label ?? options.repoLabel,
1181
- repo_fingerprint: options.activeContext.repo_fingerprint,
1182
- repo_origin_url: options.activeContext.repo_origin_url,
1183
- head_sha: options.activeContext.head_sha,
1184
- worktree_label: options.activeContext.worktree_label,
1185
- worktree_fingerprint: options.activeContext.worktree_fingerprint,
1186
- worktree_is_primary: options.activeContext.worktree_is_primary,
1187
- operator_id: options.session.operator_id,
1188
- session_id: options.session.session_id,
1189
- updated_at: options.now.toISOString(),
1190
- provenance,
1191
- };
1192
- }
1193
- function makeSourceScanCompletedEvent(options) {
1194
- if (!options.context.provenance) {
1195
- throw new Error("Upload work context is missing provenance.");
231
+ if (retryableEvidenceGap) {
232
+ await recordUploadFailure(paths, {
233
+ last_attempt_at: attemptedAt,
234
+ dashboard_url: built.dashboard_url,
235
+ work_context_id: built.envelope.work_context.work_context_id,
236
+ ticket_id: built.ticket_id,
237
+ repo_label: built.repo_label,
238
+ branch: built.envelope.work_context.branch,
239
+ event_count: built.event_count,
240
+ source_scan_count: built.source_scan_count,
241
+ risk_flag_count: built.risk_flag_count,
242
+ raw_evidence_file_count: built.raw_evidence_upload_files.length,
243
+ retry_sources: retrySourcesForFailedSync(options.options, built.raw_evidence_facts),
244
+ failure_reason: retryableEvidenceGapReason(built.raw_evidence_facts, uploadOutcomes),
245
+ retry_command: "cockpit sync",
246
+ });
247
+ return;
1196
248
  }
1197
- const rawEvidencePointers = options.rawEvidenceFacts?.pointers ?? [];
1198
- const evidenceCompleteness = options.rawEvidenceFacts?.evidence_completeness;
1199
- const hasRawEvidence = rawEvidencePointers.length > 0;
1200
- const eventPrivacyClassification = hasRawEvidence
1201
- ? "redacted_summary"
1202
- : "metadata";
1203
- return TelemetryIngestEventDtoSchema.parse({
1204
- event_id: `ambient-sync:${options.context.work_context_id}:${options.generatedAt}`,
1205
- event_type: "source_scan_completed",
1206
- occurred_at: options.generatedAt,
1207
- provenance: options.context.provenance,
1208
- privacy_classification: eventPrivacyClassification,
1209
- redaction: {
1210
- privacy_classification: eventPrivacyClassification,
1211
- redaction_status: hasRawEvidence
1212
- ? "raw_remote_durable"
1213
- : "metadata_only",
1214
- redacted_fields: [
1215
- "prompt_body",
1216
- "response_body",
1217
- "diff_body",
1218
- "transcript_body",
1219
- "git.changed_paths",
1220
- "local_file_paths",
1221
- ],
1222
- raw_evidence_pointer_ids: rawEvidencePointers.map((pointer) => pointer.raw_evidence_pointer_id),
1223
- redacted_summary: hasRawEvidence
1224
- ? "Collector uploaded raw evidence objects separately and sent only references, hashes, source scan, binding, and risk summaries to ingest."
1225
- : "Collector uploaded metadata-only local work, source scan, binding, and risk summaries.",
1226
- },
1227
- redacted_summary: hasRawEvidence
1228
- ? "Collector uploaded raw evidence objects separately, then sent evidence references and metadata summaries."
1229
- : "Collector uploaded metadata-only source scan, ticket binding, and risk summaries.",
1230
- metrics: {
1231
- git_changed_file_count: options.gitChangedFileCount,
1232
- git_added_lines: options.gitAddedLines,
1233
- git_deleted_lines: options.gitDeletedLines,
1234
- car_open_ticket_count: options.carOpenTicketCount,
1235
- source_scan_count: options.scans.length,
1236
- risk_flag_count: options.riskFlags.length,
1237
- raw_evidence_file_count: options.rawEvidenceFacts?.file_count ?? 0,
1238
- raw_evidence_byte_size: options.rawEvidenceFacts?.byte_size ?? 0,
1239
- raw_evidence_skipped_count: options.rawEvidenceFacts?.skipped_count ?? 0,
1240
- raw_evidence_sanitized_count: options.rawEvidenceFacts?.sanitized_count ?? 0,
1241
- raw_evidence_reused_count: options.rawEvidenceFacts?.reused_count ?? 0,
1242
- evidence_scanned_count: evidenceCompleteness?.totals.scanned_count ?? 0,
1243
- evidence_included_count: evidenceCompleteness?.totals.included_count ?? 0,
1244
- evidence_skipped_count: evidenceCompleteness?.totals.skipped_count ?? 0,
1245
- evidence_truncated_count: evidenceCompleteness?.totals.truncated_count ?? 0,
1246
- evidence_deferred_count: evidenceCompleteness?.totals.deferred_count ?? 0,
1247
- evidence_reused_count: evidenceCompleteness?.totals.reused_count ?? 0,
1248
- evidence_failed_count: evidenceCompleteness?.totals.failed_count ?? 0,
1249
- },
1250
- attributes: {
1251
- repo_label: options.context.repo,
1252
- repo_fingerprint: options.context.repo_fingerprint ?? "unknown",
1253
- worktree_label: options.context.worktree_label ?? "unknown",
1254
- worktree_fingerprint: options.context.worktree_fingerprint ?? "unknown",
1255
- worktree_is_primary: options.context.worktree_is_primary ?? false,
1256
- branch: options.context.branch,
1257
- ticket_binding_state: options.binding.state,
1258
- ticket_binding_source: options.binding.selected_source ?? "none",
1259
- ticket_id: options.binding.selected_ticket_id ?? "unbound",
1260
- source_adapters: options.scans.map((scan) => scan.adapter.adapter_name),
1261
- source_statuses: options.scans.map((scan) => `${scan.adapter.adapter_name}:${scan.status}`),
1262
- redaction_mode: hasRawEvidence
1263
- ? "remote_durable_raw_evidence"
1264
- : "metadata_only",
1265
- raw_payload_included: false,
1266
- evidence_completeness_schema_version: evidenceCompleteness?.schema_version ?? "evidence-completeness.v1",
1267
- evidence_completeness_status: evidenceCompleteness?.status ?? "unknown",
1268
- evidence_incomplete: evidenceCompleteness
1269
- ? evidenceCompleteness.status !== "complete"
1270
- : true,
1271
- ...(options.context.topic_label
1272
- ? { topic_label: options.context.topic_label }
1273
- : {}),
1274
- ...(options.context.topic_summary_redacted
1275
- ? { topic_summary_redacted: options.context.topic_summary_redacted }
1276
- : {}),
1277
- ...(options.context.work_intent
1278
- ? { work_intent: options.context.work_intent }
1279
- : {}),
1280
- ...(options.context.work_phase
1281
- ? { work_phase: options.context.work_phase }
1282
- : {}),
1283
- ...(options.context.intent_source
1284
- ? { intent_source: options.context.intent_source }
1285
- : {}),
1286
- ...(options.context.intent_confidence !== undefined
1287
- ? { intent_confidence: options.context.intent_confidence }
1288
- : {}),
1289
- },
1290
- evidence_completeness: evidenceCompleteness,
1291
- ticket_binding: options.ticketBinding ?? undefined,
1292
- risk_flags: options.riskFlags,
1293
- raw_evidence_pointers: rawEvidencePointers,
249
+ const permanentReason = permanentEvidenceFailureReason(uploadOutcomes);
250
+ if (!permanentReason)
251
+ return;
252
+ await recordUploadBlocked(paths, {
253
+ attemptedAt,
254
+ reason: permanentReason,
1294
255
  });
1295
- }
1296
- function makeCollectorProvenance(options) {
1297
- return {
1298
- capture_source: "collector_runtime",
1299
- capture_adapter_version: LOCAL_COLLECTOR_VERSION,
1300
- collector_version: LOCAL_COLLECTOR_VERSION,
1301
- repo: options.repoLabel,
1302
- branch: options.context.branch,
1303
- repo_label: options.context.repo_label ?? options.repoLabel,
1304
- repo_fingerprint: options.context.repo_fingerprint,
1305
- repo_origin_url: options.context.repo_origin_url,
1306
- worktree_label: options.context.worktree_label,
1307
- worktree_fingerprint: options.context.worktree_fingerprint,
1308
- worktree_is_primary: options.context.worktree_is_primary,
1309
- operator_id: options.session.operator_id,
1310
- session_id: options.session.session_id,
1311
- work_context_id: options.context.work_context_id,
1312
- };
1313
- }
1314
- function sanitizeSessionReference(session) {
1315
- return {
1316
- ...session,
1317
- session_file_path: "local-session-file",
1318
- };
1319
- }
1320
- function sanitizeSourceScanResults(scans, repoLabel) {
1321
- return scans.map((scan) => ({
1322
- ...scan,
1323
- diagnostic_labels: scan.diagnostic_labels.map(sanitizeDiagnosticLabel),
1324
- events: scan.events.map((event) => ({
1325
- ...event,
1326
- raw_evidence_pointers: [],
1327
- redaction: {
1328
- ...event.redaction,
1329
- raw_evidence_pointer_ids: [],
1330
- },
1331
- })),
1332
- risk_flags: scan.risk_flags.map((flag) => sanitizeRiskFlag(flag, repoLabel)),
1333
- }));
1334
- }
1335
- function sanitizeDiagnosticLabel(label) {
1336
- if (label.includes("\n") || label.includes("/") || label.includes("\\")) {
1337
- const prefix = label.split(":", 1)[0]?.trim();
1338
- return prefix ? `${prefix}:redacted` : "diagnostic_redacted";
1339
- }
1340
- return label.length > 160 ? `${label.slice(0, 157)}...` : label;
1341
- }
1342
- function sanitizeRiskFlag(flag, repoLabel) {
1343
- return {
1344
- ...flag,
1345
- provenance: {
1346
- ...flag.provenance,
1347
- repo: repoLabel,
1348
- },
1349
- };
1350
- }
1351
- function selectedTicketBindingCandidate(binding) {
1352
- if (!binding.selected_ticket_id || !binding.selected_source)
1353
- return null;
1354
- return (binding.candidates.find((candidate) => candidate.ticket_id === binding.selected_ticket_id &&
1355
- candidate.binding_source === binding.selected_source) ?? {
1356
- ticket_id: binding.selected_ticket_id,
1357
- binding_source: binding.selected_source,
1358
- confidence: 1,
1359
- evidence_labels: [`bound_by:${binding.selected_source}`],
256
+ logPermanentlyRejectedEvidence({
257
+ reason: permanentReason,
258
+ outcomes: uploadOutcomes,
259
+ attemptedAt,
1360
260
  });
1361
261
  }
1362
- async function readResponseJson(response) {
1363
- const text = await response.text();
1364
- if (!text)
1365
- return {};
1366
- try {
1367
- return JSON.parse(text);
1368
- }
1369
- catch {
1370
- return { message: text };
1371
- }
1372
- }
1373
- function responseErrorMessage(value, fallback) {
1374
- if (value && typeof value === "object") {
1375
- const record = value;
1376
- const message = record["message"] ?? record["error"];
1377
- if (typeof message === "string" && message.trim())
1378
- return message;
1379
- }
1380
- return fallback;
1381
- }
262
+ /**
263
+ * The receipt that proves ingest persisted exactly what was submitted.
264
+ *
265
+ * Anything short of a 202 whose counts match the envelope's own totals is
266
+ * treated as no receipt at all — a proxy's 200, a truncated body, or a
267
+ * dashboard that accepted fewer rows than were sent all land here.
268
+ */
1382
269
  function assertAmbientIngestReceipt(response, value, envelope) {
1383
270
  if (response.status !== 202 || !value || typeof value !== "object") {
1384
271
  throw new Error("Ambient ingest returned an invalid durable receipt.");
@@ -1398,23 +285,4 @@ function assertAmbientIngestReceipt(response, value, envelope) {
1398
285
  ingest["evidence_ref_count"] !== expectedEvidenceRefCount) {
1399
286
  throw new Error("Ambient ingest returned an incomplete durable receipt.");
1400
287
  }
1401
- }
1402
- function isNonEmptyString(value) {
1403
- return typeof value === "string" && value.trim().length > 0;
1404
- }
1405
- function normalizeDashboardUrl(value) {
1406
- const normalized = value.trim().replace(/\/+$/, "");
1407
- if (!normalized)
1408
- throw new Error("Dashboard URL cannot be empty.");
1409
- return normalized;
1410
- }
1411
- function safeRepoLabel(repoRoot) {
1412
- const basename = path.basename(repoRoot.replace(/[\\/]+$/, ""));
1413
- return basename || "repo";
1414
- }
1415
- function rawEvidenceObjectKeyBelongsToWorkContext(objectKey, context) {
1416
- const legacyPrefix = `${context.operatorId}/${context.workContextId}/`;
1417
- const readableIdGuard = `/ids/${context.operatorId}/${context.workContextId}/`;
1418
- return (objectKey.startsWith(legacyPrefix) ||
1419
- (objectKey.startsWith("operators/") && objectKey.includes(readableIdGuard)));
1420
288
  }