@bli-cockpit/cli 0.2.4 → 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/README.md +27 -7
- package/dist/adapters/attribution-core.js +152 -31
- package/dist/adapters/claude-attribution.js +14 -7
- package/dist/adapters/codex-attribution.js +8 -2
- package/dist/adapters/raw-evidence.js +33 -12
- package/dist/autostart.js +460 -20
- package/dist/commands/backfill.js +582 -87
- package/dist/commands/doctor.js +72 -60
- package/dist/commands/local-args.js +19 -2
- package/dist/commands/local.js +518 -247
- package/dist/commands/public-root.js +6 -1
- package/dist/commands/session-sync.js +259 -54
- package/dist/cursors/backfill-cursor.js +169 -1
- package/dist/cursors/raw-evidence-cursor.js +16 -2
- package/dist/evidence-upload-client.js +139 -107
- package/dist/local-state.js +82 -6
- package/dist/onboarding-roots.js +42 -17
- package/dist/process-runner.js +103 -0
- package/dist/raw-evidence-attribution-policy.js +25 -0
- package/dist/repo-identity.js +142 -26
- package/dist/root-normalization.js +29 -16
- package/dist/spool/install-event-outbox.js +191 -0
- package/dist/spool/local-spool.js +335 -58
- package/dist/upload.js +309 -66
- package/package.json +2 -2
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
229
|
-
//
|
|
230
|
-
//
|
|
231
|
-
|
|
232
|
-
|
|
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
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
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:
|
|
410
|
-
|
|
411
|
-
|
|
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.
|
|
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.
|
|
29
|
+
"@bli-cockpit/telemetry-core": "0.1.13"
|
|
30
30
|
}
|
|
31
31
|
}
|