@bli-cockpit/cli 0.2.27 → 0.2.29

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