@bli-cockpit/cli 0.2.5 → 0.2.7

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
@@ -5,7 +5,7 @@ import { runLocalSourceCollectors } from "./adapters/local-sources.js";
5
5
  import { defaultCodexSessionDirs, } from "./adapters/codex-attribution.js";
6
6
  import { uploadRawEvidenceFilesChunked, } from "./evidence-upload-client.js";
7
7
  import { markObjectCommitted, readRawEvidenceCursor, writeRawEvidenceCursor, } from "./cursors/raw-evidence-cursor.js";
8
- import { recordUploadBlocked, recordUploadFailure, recordUploadSuccess, } from "./spool/local-spool.js";
8
+ import { readLocalUploadSpoolState, recordPendingSessionReport, recordSessionReportFailure, recordSessionReportSuccess, recordUploadBlocked, recordUploadFailure, recordUploadSuccess, } from "./spool/local-spool.js";
9
9
  export class LocalUploadBlockedError extends Error {
10
10
  blocker;
11
11
  retry_hint;
@@ -20,7 +20,7 @@ export async function buildLocalAmbientEnvelope(options = {}) {
20
20
  const now = options.now ?? new Date();
21
21
  const paths = getCollectorRuntimePaths(options.homeDir);
22
22
  const config = await readLocalCollectorConfig(paths).catch(() => {
23
- throw new LocalUploadBlockedError("not_installed", "Local collector config missing. Install/update the CLI, then run `cockpit onboard` before `cockpit sync`.", "npm install -g @bli-cockpit/cli@latest && cockpit onboard --email <email> --workspace \"$PWD\"");
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
24
  });
25
25
  const sessionFile = await readLocalCollectorSessionFile(paths).catch(() => {
26
26
  throw new LocalUploadBlockedError("unpaired", "No paired collector session found. Run `cockpit login` or `cockpit pair` before `cockpit sync`.", "cockpit login");
@@ -150,7 +150,6 @@ export async function syncLocalAmbientEnvelope(options = {}) {
150
150
  }
151
151
  let uploadOutcomes = [];
152
152
  let uploadedChunkCount = 0;
153
- let ingestAccepted = false;
154
153
  try {
155
154
  if (built.raw_evidence_upload_files.length > 0 && provenance) {
156
155
  const upload = await uploadRawEvidenceFilesChunked({
@@ -177,7 +176,11 @@ export async function syncLocalAmbientEnvelope(options = {}) {
177
176
  if (!response.ok) {
178
177
  throw new Error(responseErrorMessage(responseBody, `Ambient ingest failed with HTTP ${response.status}`));
179
178
  }
180
- ingestAccepted = true;
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);
181
184
  for (const outcome of uploadOutcomes) {
182
185
  if (outcome.upload_state === "upload_failed")
183
186
  continue;
@@ -209,7 +212,30 @@ export async function syncLocalAmbientEnvelope(options = {}) {
209
212
  reason: "agent_artifact_report_local_error",
210
213
  recorded_count: 0,
211
214
  }));
212
- await recordUploadSuccess(paths, { attemptedAt });
215
+ const retryableEvidenceGap = hasRetryableEvidenceGap(built.raw_evidence_facts, uploadOutcomes);
216
+ const retrySources = retrySourcesForFailedSync(options, built.raw_evidence_facts);
217
+ await recordUploadSuccess(paths, {
218
+ attemptedAt,
219
+ workContextId: built.envelope.work_context.work_context_id,
220
+ clearPendingForContext: !retryableEvidenceGap,
221
+ });
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
+ }
213
239
  return {
214
240
  status: "uploaded",
215
241
  dashboard_url: built.dashboard_url,
@@ -225,23 +251,11 @@ export async function syncLocalAmbientEnvelope(options = {}) {
225
251
  }
226
252
  catch (error) {
227
253
  const failureReason = error instanceof Error ? error.message : String(error);
228
- // Once ingest accepted the envelope, the evidence refs are indexed and the
229
- // objects are load-bearing — a later local write failure (cursor, spool)
230
- // must not delete durable evidence.
231
- const cleanupFailureReason = ingestAccepted
232
- ? null
233
- : await cleanupUploadedRawEvidenceObjects({
234
- fetchImpl,
235
- dashboardUrl: built.dashboard_url,
236
- deviceToken: built.device_token,
237
- envelope: built.envelope,
238
- objectKeys: uploadOutcomes
239
- .filter((outcome) => outcome.upload_state === "uploaded")
240
- .map((outcome) => outcome.object_key),
241
- });
242
- const spooledFailureReason = cleanupFailureReason
243
- ? `${failureReason}; raw evidence cleanup failed: ${cleanupFailureReason}`
244
- : failureReason;
254
+ // A committed content-addressed object may be shared by concurrent syncs.
255
+ // Never delete it from this error path: an ingest retry can reuse it, while
256
+ // client-side cleanup cannot prove exclusive ownership without racing a
257
+ // second sync that is about to index the same object.
258
+ const spooledFailureReason = failureReason;
245
259
  const entry = await recordUploadFailure(paths, {
246
260
  last_attempt_at: attemptedAt,
247
261
  dashboard_url: built.dashboard_url,
@@ -253,6 +267,7 @@ export async function syncLocalAmbientEnvelope(options = {}) {
253
267
  source_scan_count: built.source_scan_count,
254
268
  risk_flag_count: built.risk_flag_count,
255
269
  raw_evidence_file_count: built.raw_evidence_upload_files.length,
270
+ retry_sources: retrySourcesForFailedSync(options, built.raw_evidence_facts),
256
271
  failure_reason: spooledFailureReason,
257
272
  retry_command: "cockpit sync",
258
273
  });
@@ -272,6 +287,73 @@ export async function syncLocalAmbientEnvelope(options = {}) {
272
287
  };
273
288
  }
274
289
  }
290
+ function retrySourcesForFailedSync(options, facts) {
291
+ const sources = new Set();
292
+ if ((options.codexSessionFiles?.length ?? 0) > 0 ||
293
+ (options.codexAttributionScan?.directory_read_failed_count ?? 0) > 0 ||
294
+ (options.codexAttributionScan?.stat_failed_count ?? 0) > 0 ||
295
+ facts?.evidence_completeness.source_counts.some((count) => count.source.startsWith("codex_") &&
296
+ count.scanned_count + count.included_count + count.reused_count > 0)) {
297
+ sources.add("codex");
298
+ }
299
+ if ((options.claudeSessionFiles?.length ?? 0) > 0 ||
300
+ (options.claudeAttributionScan?.project_dir_read_failed_count ?? 0) > 0 ||
301
+ (options.claudeAttributionScan?.session_stat_failed_count ?? 0) > 0 ||
302
+ (options.claudeAttributionScan?.sidecar_dir_read_failed_count ?? 0) > 0 ||
303
+ (options.claudeAttributionScan?.sidecar_stat_failed_count ?? 0) > 0 ||
304
+ facts?.evidence_completeness.source_counts.some((count) => count.source.startsWith("claude_") &&
305
+ count.scanned_count + count.included_count + count.reused_count > 0)) {
306
+ sources.add("claude_code");
307
+ }
308
+ return [...sources];
309
+ }
310
+ function hasRetryableEvidenceGap(facts, outcomes) {
311
+ if (outcomes.some((outcome) => outcome.upload_state === "upload_failed")) {
312
+ return true;
313
+ }
314
+ if (!facts)
315
+ return false;
316
+ if (facts.deferred_byte_budget_count > 0 ||
317
+ facts.deferred_object_budget_count > 0) {
318
+ return true;
319
+ }
320
+ if (facts.evidence_completeness.status === "failed" ||
321
+ facts.evidence_completeness.totals.failed_count > 0 ||
322
+ facts.evidence_completeness.failure_reasons.length > 0) {
323
+ return true;
324
+ }
325
+ return facts.evidence_completeness.skip_reasons.some(({ reason }) => /(?:read|stat|directory)_failed|session_limit_overflow/iu.test(reason));
326
+ }
327
+ function retryableEvidenceGapReason(facts, outcomes) {
328
+ const reasons = new Set();
329
+ for (const outcome of outcomes) {
330
+ if (outcome.upload_state === "upload_failed") {
331
+ reasons.add(outcome.reason ?? "upload_failed");
332
+ }
333
+ }
334
+ if (facts) {
335
+ if (facts.deferred_byte_budget_count > 0)
336
+ reasons.add("deferred_byte_budget");
337
+ if (facts.deferred_object_budget_count > 0) {
338
+ reasons.add("deferred_object_budget");
339
+ }
340
+ for (const { reason } of facts.evidence_completeness.failure_reasons) {
341
+ reasons.add(reason);
342
+ }
343
+ if (facts.evidence_completeness.status === "failed" &&
344
+ facts.evidence_completeness.failure_reasons.length === 0) {
345
+ reasons.add("evidence_completeness_failed");
346
+ }
347
+ for (const { reason } of facts.evidence_completeness.skip_reasons) {
348
+ if (/(?:read|stat|directory)_failed|session_limit_overflow/iu.test(reason)) {
349
+ reasons.add(reason);
350
+ }
351
+ }
352
+ }
353
+ return `partial_raw_evidence_retry_required:${[
354
+ ...reasons,
355
+ ].sort().join(",") || "unknown"}`;
356
+ }
275
357
  /**
276
358
  * Posts a Codex session attribution report using the paired collector
277
359
  * credentials and the work context of a representative repo. Failures come
@@ -319,6 +401,141 @@ export async function postCodexSessionReport(options) {
319
401
  return emptyCodexSessionReportResult("collector_not_ready");
320
402
  }
321
403
  }
404
+ /**
405
+ * Queues the safe session-attribution rows before the network request. The
406
+ * queue is merged per work context, so an endpoint outage retains every
407
+ * observed session without growing one duplicate report per scheduler pass.
408
+ */
409
+ export async function queueCodexSessionReport(options) {
410
+ if (options.sessions.length === 0)
411
+ return null;
412
+ const paths = getCollectorRuntimePaths(options.homeDir);
413
+ return await recordPendingSessionReport(paths, {
414
+ attempted_at: options.generatedAt,
415
+ dashboard_url: normalizeDashboardUrl(options.dashboardUrl),
416
+ generated_at: options.generatedAt,
417
+ work_context_id: options.workContextId,
418
+ repo_label: safeRepoLabel(options.repoLabel),
419
+ branch: options.branch,
420
+ repo_fingerprint: options.repoFingerprint,
421
+ repo_origin_url: options.repoOriginUrl,
422
+ worktree_label: options.worktreeLabel,
423
+ worktree_fingerprint: options.worktreeFingerprint,
424
+ worktree_is_primary: options.worktreeIsPrimary,
425
+ sessions: options.sessions,
426
+ });
427
+ }
428
+ /**
429
+ * Flushes every durable session-attribution report using current credentials.
430
+ * Report payloads do not depend on the source files still being inside the live
431
+ * scan window, so a transient endpoint failure cannot silently age them out.
432
+ */
433
+ export async function flushPendingCodexSessionReports(options) {
434
+ const paths = getCollectorRuntimePaths(options.homeDir);
435
+ const state = await readLocalUploadSpoolState(paths);
436
+ if (state.pending_session_reports.length === 0) {
437
+ return emptyCodexSessionReportResult("no_pending_session_reports");
438
+ }
439
+ const attemptedAt = (options.now ?? new Date()).toISOString();
440
+ const fetchImpl = options.fetch ?? globalThis.fetch;
441
+ if (!fetchImpl) {
442
+ throw new Error("global fetch is unavailable; use Node.js 20 or newer.");
443
+ }
444
+ let sessionFile;
445
+ let session;
446
+ try {
447
+ [sessionFile, session] = await Promise.all([
448
+ readLocalCollectorSessionFile(paths),
449
+ readLocalSessionReference(paths),
450
+ ]);
451
+ }
452
+ catch {
453
+ return await failPendingSessionReports(paths, state.pending_session_reports, attemptedAt, "collector_not_ready");
454
+ }
455
+ if (session.session_state !== "valid") {
456
+ return await failPendingSessionReports(paths, state.pending_session_reports, attemptedAt, "collector_not_paired");
457
+ }
458
+ const results = [];
459
+ for (const pending of state.pending_session_reports) {
460
+ const provenance = {
461
+ capture_source: "collector_runtime",
462
+ capture_adapter_version: LOCAL_COLLECTOR_VERSION,
463
+ collector_version: LOCAL_COLLECTOR_VERSION,
464
+ repo: pending.repo_label,
465
+ branch: pending.branch,
466
+ repo_label: pending.repo_label,
467
+ repo_fingerprint: pending.repo_fingerprint,
468
+ ...(pending.repo_origin_url
469
+ ? { repo_origin_url: pending.repo_origin_url }
470
+ : {}),
471
+ worktree_label: pending.worktree_label,
472
+ worktree_fingerprint: pending.worktree_fingerprint,
473
+ worktree_is_primary: pending.worktree_is_primary,
474
+ operator_id: session.operator_id,
475
+ session_id: session.session_id,
476
+ work_context_id: pending.work_context_id,
477
+ };
478
+ const result = await reportCodexSessionAttributions({
479
+ fetchImpl,
480
+ dashboardUrl: normalizeDashboardUrl(pending.dashboard_url || sessionFile.dashboard_url),
481
+ deviceToken: sessionFile.device_token,
482
+ provenance,
483
+ generatedAt: pending.generated_at,
484
+ sessions: pending.sessions,
485
+ maxAttemptsPerRequest: options.maxAttemptsPerRequest,
486
+ sleep: options.sleep,
487
+ });
488
+ results.push(result);
489
+ if (result.posted) {
490
+ await recordSessionReportSuccess(paths, {
491
+ reportId: pending.report_id,
492
+ attemptedAt,
493
+ });
494
+ }
495
+ else {
496
+ await recordSessionReportFailure(paths, {
497
+ reportId: pending.report_id,
498
+ attemptedAt,
499
+ reason: result.reason,
500
+ });
501
+ }
502
+ }
503
+ return combineCodexSessionReportResults(results);
504
+ }
505
+ async function failPendingSessionReports(paths, pendingReports, attemptedAt, reason) {
506
+ for (const pending of pendingReports) {
507
+ await recordSessionReportFailure(paths, {
508
+ reportId: pending.report_id,
509
+ attemptedAt,
510
+ reason,
511
+ });
512
+ }
513
+ return {
514
+ posted: false,
515
+ reason,
516
+ chunk_count: 0,
517
+ recorded_count: 0,
518
+ failed_count: pendingReports.length,
519
+ chunks: [],
520
+ };
521
+ }
522
+ function combineCodexSessionReportResults(results) {
523
+ if (results.length === 0) {
524
+ return emptyCodexSessionReportResult("no_pending_session_reports");
525
+ }
526
+ const failed = results.filter((result) => !result.posted);
527
+ return {
528
+ posted: failed.length === 0,
529
+ reason: failed[0]?.reason ??
530
+ (results.every((result) => result.reason === "recorded")
531
+ ? "recorded"
532
+ : results[0]?.reason ?? "recorded"),
533
+ chunk_count: results.reduce((total, result) => total + result.chunk_count, 0),
534
+ recorded_count: results.reduce((total, result) => total + result.recorded_count, 0),
535
+ failed_count: results.reduce((total, result) => total + result.failed_count, 0),
536
+ chunks: results.flatMap((result) => result.chunks),
537
+ };
538
+ }
322
539
  /**
323
540
  * Reports Codex session attribution outcomes after sync. Non-fatal by design:
324
541
  * older dashboards without the endpoint must not fail the harvest, so the
@@ -355,6 +572,7 @@ async function postCodexSessionAttributionChunk(options) {
355
572
  const maxAttempts = options.maxAttemptsPerRequest ?? 3;
356
573
  const sleep = options.sleep ?? defaultReportSleep;
357
574
  let lastStatus = null;
575
+ let lastFailureReason = null;
358
576
  for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
359
577
  try {
360
578
  const response = await options.fetchImpl(`${options.dashboardUrl}/api/ambient/codex-sessions`, {
@@ -382,14 +600,35 @@ async function postCodexSessionAttributionChunk(options) {
382
600
  }
383
601
  if (response.ok) {
384
602
  const parsed = CodexSessionAttributionReportResponseSchema.safeParse(body);
385
- return codexSessionReportChunkResult(options, {
386
- posted: true,
387
- reason: "recorded",
388
- httpStatus: response.status,
389
- recordedCount: parsed.success ? parsed.data.recorded_count : 0,
390
- });
603
+ if (!parsed.success) {
604
+ lastFailureReason = "report_invalid_response";
605
+ }
606
+ else if (parsed.data.recorded_count <
607
+ requiredCodexSessionAcknowledgementCount(options.sessions)) {
608
+ lastFailureReason = "report_incomplete_acknowledgement";
609
+ }
610
+ else {
611
+ return codexSessionReportChunkResult(options, {
612
+ posted: true,
613
+ reason: "recorded",
614
+ httpStatus: response.status,
615
+ recordedCount: parsed.data.recorded_count,
616
+ });
617
+ }
618
+ }
619
+ else {
620
+ lastFailureReason = `report_failed_http_${response.status}`;
391
621
  }
392
622
  if (response.status < 500 && response.status !== 429) {
623
+ if (response.ok) {
624
+ // A malformed or short 2xx response is retryable. The server may have
625
+ // failed between persisting the rows and producing its acknowledgement,
626
+ // so only a schema-valid, sufficiently large acknowledgement is safe
627
+ // to use for deleting a durable pending report.
628
+ if (attempt < maxAttempts)
629
+ await sleep(250 * attempt);
630
+ continue;
631
+ }
393
632
  return codexSessionReportChunkResult(options, {
394
633
  posted: false,
395
634
  reason: `report_failed_http_${response.status}`,
@@ -400,15 +639,17 @@ async function postCodexSessionAttributionChunk(options) {
400
639
  }
401
640
  catch {
402
641
  lastStatus = null;
642
+ lastFailureReason = "report_network_error";
403
643
  }
404
644
  if (attempt < maxAttempts)
405
645
  await sleep(250 * attempt);
406
646
  }
407
647
  return codexSessionReportChunkResult(options, {
408
648
  posted: false,
409
- reason: lastStatus === null
410
- ? "report_network_error"
411
- : `report_failed_http_${lastStatus}`,
649
+ reason: lastFailureReason ??
650
+ (lastStatus === null
651
+ ? "report_network_error"
652
+ : `report_failed_http_${lastStatus}`),
412
653
  httpStatus: lastStatus,
413
654
  recordedCount: 0,
414
655
  });
@@ -440,6 +681,9 @@ function chunkArray(items, size) {
440
681
  }
441
682
  return chunks;
442
683
  }
684
+ function requiredCodexSessionAcknowledgementCount(sessions) {
685
+ return new Set(sessions.map((session) => `${session.source ?? "codex"}:${session.codex_session_id}`)).size;
686
+ }
443
687
  function defaultReportSleep(milliseconds) {
444
688
  return new Promise((resolve) => setTimeout(resolve, milliseconds));
445
689
  }
@@ -573,6 +817,10 @@ function rawEvidenceSummary(built, outcomes, uploadedChunkCount, cursor) {
573
817
  const cursorReused = built.raw_evidence_facts?.reused ?? [];
574
818
  const serverReusedCount = outcomes.filter((outcome) => outcome.upload_state === "reused_existing").length;
575
819
  const failed = outcomes.filter((outcome) => outcome.upload_state === "upload_failed");
820
+ const retryRequired = hasRetryableEvidenceGap(built.raw_evidence_facts, outcomes);
821
+ const retryReason = retryRequired
822
+ ? retryableEvidenceGapReason(built.raw_evidence_facts, outcomes)
823
+ : null;
576
824
  const cursorReusedOutcomes = cursorReused.map((entry) => ({
577
825
  object_key: cursor.objects[entry.content_hash_sha256]?.object_key ?? "",
578
826
  raw_evidence_pointer_id: cursor.objects[entry.content_hash_sha256]?.object_key ?? "",
@@ -594,6 +842,13 @@ function rawEvidenceSummary(built, outcomes, uploadedChunkCount, cursor) {
594
842
  raw_evidence_failure_reasons: [
595
843
  ...new Set(failed.map((outcome) => outcome.reason ?? "unknown")),
596
844
  ],
845
+ raw_evidence_retry_required: retryRequired,
846
+ raw_evidence_retry_reasons: retryReason
847
+ ? retryReason
848
+ .replace(/^partial_raw_evidence_retry_required:/u, "")
849
+ .split(",")
850
+ .filter(Boolean)
851
+ : [],
597
852
  raw_evidence_outcomes: [
598
853
  ...outcomes.map((outcome) => ({
599
854
  object_key: outcome.object_key,
@@ -864,41 +1119,6 @@ function makeSourceScanCompletedEvent(options) {
864
1119
  raw_evidence_pointers: rawEvidencePointers,
865
1120
  });
866
1121
  }
867
- const CLEANUP_BATCH_SIZE = 25;
868
- async function cleanupUploadedRawEvidenceObjects(options) {
869
- if (options.objectKeys.length === 0)
870
- return null;
871
- const provenance = options.envelope.work_context.provenance;
872
- if (!provenance)
873
- return "missing collector provenance";
874
- const failures = [];
875
- for (let offset = 0; offset < options.objectKeys.length; offset += CLEANUP_BATCH_SIZE) {
876
- const batch = options.objectKeys.slice(offset, offset + CLEANUP_BATCH_SIZE);
877
- try {
878
- const response = await options.fetchImpl(`${options.dashboardUrl}/api/ambient/evidence/upload`, {
879
- method: "DELETE",
880
- headers: {
881
- "Authorization": `Bearer ${options.deviceToken}`,
882
- "Content-Type": "application/json",
883
- },
884
- body: JSON.stringify({
885
- schema_version: "ambient-raw-evidence-cleanup.v1",
886
- generated_at: options.envelope.generated_at,
887
- provenance,
888
- object_keys: batch,
889
- }),
890
- });
891
- const responseBody = await readResponseJson(response);
892
- if (!response.ok) {
893
- failures.push(responseErrorMessage(responseBody, `Raw evidence cleanup failed with HTTP ${response.status}`));
894
- }
895
- }
896
- catch (error) {
897
- failures.push(error instanceof Error ? error.message : String(error));
898
- }
899
- }
900
- return failures.length > 0 ? failures.join("; ") : null;
901
- }
902
1122
  function makeCollectorProvenance(options) {
903
1123
  return {
904
1124
  capture_source: "collector_runtime",
@@ -985,6 +1205,29 @@ function responseErrorMessage(value, fallback) {
985
1205
  }
986
1206
  return fallback;
987
1207
  }
1208
+ function assertAmbientIngestReceipt(response, value, envelope) {
1209
+ if (response.status !== 202 || !value || typeof value !== "object") {
1210
+ throw new Error("Ambient ingest returned an invalid durable receipt.");
1211
+ }
1212
+ const record = value;
1213
+ const ingest = record["ingest"] && typeof record["ingest"] === "object"
1214
+ ? record["ingest"]
1215
+ : null;
1216
+ const expectedFactCount = envelope.events.length;
1217
+ const expectedRiskFlagCount = envelope.events.reduce((total, event) => total + event.risk_flags.length, 0);
1218
+ const expectedEvidenceRefCount = envelope.events.reduce((total, event) => total + event.raw_evidence_pointers.length, 0);
1219
+ if (record["ok"] !== true ||
1220
+ !ingest ||
1221
+ !isNonEmptyString(ingest["work_session_id"]) ||
1222
+ ingest["fact_count"] !== expectedFactCount ||
1223
+ ingest["risk_flag_count"] !== expectedRiskFlagCount ||
1224
+ ingest["evidence_ref_count"] !== expectedEvidenceRefCount) {
1225
+ throw new Error("Ambient ingest returned an incomplete durable receipt.");
1226
+ }
1227
+ }
1228
+ function isNonEmptyString(value) {
1229
+ return typeof value === "string" && value.trim().length > 0;
1230
+ }
988
1231
  function normalizeDashboardUrl(value) {
989
1232
  const normalized = value.trim().replace(/\/+$/, "");
990
1233
  if (!normalized)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bli-cockpit/cli",
3
- "version": "0.2.5",
3
+ "version": "0.2.7",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "bin": {
@@ -26,6 +26,6 @@
26
26
  "test": "node dist/cli.js --help && node ../../scripts/assert-public-package-pack.mjs --workspace=@bli-cockpit/cli"
27
27
  },
28
28
  "dependencies": {
29
- "@bli-cockpit/telemetry-core": "0.1.11"
29
+ "@bli-cockpit/telemetry-core": "0.1.13"
30
30
  }
31
31
  }