@bli-cockpit/cli 0.2.26 → 0.2.28
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/adapters/raw-evidence.js +327 -30
- package/dist/autostart.js +99 -13
- package/dist/commands/local.js +33 -2
- package/dist/commands/public-root.js +1 -1
- package/dist/local-state.js +12 -1
- package/dist/raw-evidence-gc.js +178 -0
- package/dist/raw-evidence-staging.js +309 -0
- package/dist/upload.js +141 -15
- package/package.json +1 -1
package/dist/upload.js
CHANGED
|
@@ -5,6 +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 { clearDeliveryAttempt, DELIVERY_BACKOFF_HOLDING_REASON, deliveryHold, readRawEvidenceStagingState, recordDeliveryFailure, summarizeStuckEvidence, writeRawEvidenceStagingState, } from "./raw-evidence-staging.js";
|
|
8
9
|
import { readLocalUploadSpoolState, recordPendingSessionReport, recordSessionReportFailure, recordSessionReportSuccess, recordUploadBlocked, recordUploadFailure, recordUploadSuccess, } from "./spool/local-spool.js";
|
|
9
10
|
export class LocalUploadBlockedError extends Error {
|
|
10
11
|
blocker;
|
|
@@ -150,18 +151,37 @@ export async function syncLocalAmbientEnvelope(options = {}) {
|
|
|
150
151
|
}
|
|
151
152
|
let uploadOutcomes = [];
|
|
152
153
|
let uploadedChunkCount = 0;
|
|
154
|
+
const attemptedDate = options.now ?? new Date(attemptedAt);
|
|
155
|
+
const staging = await readRawEvidenceStagingState(paths.state_dir);
|
|
153
156
|
try {
|
|
154
157
|
if (built.raw_evidence_upload_files.length > 0 && provenance) {
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
158
|
+
// Objects still inside their backoff window are not offered at all. They
|
|
159
|
+
// become held outcomes below, which keeps them out of the envelope, keeps
|
|
160
|
+
// the sync in retry_pending, and names the reason — the 559th identical
|
|
161
|
+
// attempt is what BLI-3066 made impossible.
|
|
162
|
+
const { deliverable, held } = partitionHeldEvidenceFiles(built.raw_evidence_upload_files, staging, attemptedDate);
|
|
163
|
+
if (held.length > 0) {
|
|
164
|
+
console.error("[cockpit-sync] raw evidence held by delivery backoff", JSON.stringify({
|
|
165
|
+
attempted_at: attemptedAt,
|
|
166
|
+
reason: DELIVERY_BACKOFF_HOLDING_REASON,
|
|
167
|
+
object_count: held.length,
|
|
168
|
+
byte_size: held.reduce((sum, outcome) => sum + (outcome.pointer.byte_size ?? 0), 0),
|
|
169
|
+
}));
|
|
170
|
+
}
|
|
171
|
+
uploadOutcomes = held;
|
|
172
|
+
if (deliverable.length > 0) {
|
|
173
|
+
const upload = await uploadRawEvidenceFilesChunked({
|
|
174
|
+
fetchImpl,
|
|
175
|
+
dashboardUrl: built.dashboard_url,
|
|
176
|
+
deviceToken: built.device_token,
|
|
177
|
+
provenance,
|
|
178
|
+
generatedAt: built.envelope.generated_at,
|
|
179
|
+
files: deliverable,
|
|
180
|
+
});
|
|
181
|
+
uploadOutcomes = [...held, ...upload.outcomes];
|
|
182
|
+
uploadedChunkCount = upload.uploaded_chunk_count;
|
|
183
|
+
}
|
|
184
|
+
await persistDeliveryAttempts(paths.state_dir, staging, uploadOutcomes, attemptedDate);
|
|
165
185
|
}
|
|
166
186
|
const envelope = applyRawEvidenceUploadOutcomes(built.envelope, uploadOutcomes);
|
|
167
187
|
const response = await fetchImpl(`${built.dashboard_url}/api/ambient/ingest`, {
|
|
@@ -267,7 +287,7 @@ export async function syncLocalAmbientEnvelope(options = {}) {
|
|
|
267
287
|
source_scan_count: built.source_scan_count,
|
|
268
288
|
risk_flag_count: built.risk_flag_count,
|
|
269
289
|
http_status: response.status,
|
|
270
|
-
...rawEvidenceSummary(built, uploadOutcomes, uploadedChunkCount, cursor),
|
|
290
|
+
...rawEvidenceSummary(built, uploadOutcomes, uploadedChunkCount, cursor, staging, attemptedDate),
|
|
271
291
|
};
|
|
272
292
|
}
|
|
273
293
|
catch (error) {
|
|
@@ -304,10 +324,98 @@ export async function syncLocalAmbientEnvelope(options = {}) {
|
|
|
304
324
|
failure_reason: spooledFailureReason,
|
|
305
325
|
spool_entry_id: entry.spool_id,
|
|
306
326
|
retry_command: entry.retry_command,
|
|
307
|
-
...rawEvidenceSummary(built, uploadOutcomes, uploadedChunkCount, cursor),
|
|
327
|
+
...rawEvidenceSummary(built, uploadOutcomes, uploadedChunkCount, cursor, staging, attemptedDate),
|
|
308
328
|
};
|
|
309
329
|
}
|
|
310
330
|
}
|
|
331
|
+
/**
|
|
332
|
+
* Split the pack's files into "offer these now" and "still in backoff".
|
|
333
|
+
*
|
|
334
|
+
* A held file becomes an `upload_failed` outcome labelled
|
|
335
|
+
* `delivery_backoff_holding`. That is deliberate rather than a quiet omission:
|
|
336
|
+
* the pointer gets pruned from the envelope (the object is genuinely not
|
|
337
|
+
* durable), the sync stays in `retry_pending`, and the reason travels to the
|
|
338
|
+
* status output. A hold that read as success would be the green-status-hiding-
|
|
339
|
+
* missing-collection failure the fleet contract forbids.
|
|
340
|
+
*/
|
|
341
|
+
export function partitionHeldEvidenceFiles(files, staging, now) {
|
|
342
|
+
const deliverable = [];
|
|
343
|
+
const held = [];
|
|
344
|
+
for (const file of files) {
|
|
345
|
+
const hold = deliveryHold(staging, file.pointer.content_hash_sha256, now);
|
|
346
|
+
if (!hold) {
|
|
347
|
+
deliverable.push(file);
|
|
348
|
+
continue;
|
|
349
|
+
}
|
|
350
|
+
held.push({
|
|
351
|
+
pointer: file.pointer,
|
|
352
|
+
object_key: file.pointer.object_key ?? "",
|
|
353
|
+
codex_session_id: file.codex_session_id ?? null,
|
|
354
|
+
kind: file.kind ?? "raw_evidence",
|
|
355
|
+
...(file.artifact_metadata
|
|
356
|
+
? { artifact_metadata: file.artifact_metadata }
|
|
357
|
+
: {}),
|
|
358
|
+
upload_state: "upload_failed",
|
|
359
|
+
reason: DELIVERY_BACKOFF_HOLDING_REASON,
|
|
360
|
+
uploaded_chunk_count: 0,
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
return { deliverable, held };
|
|
364
|
+
}
|
|
365
|
+
/**
|
|
366
|
+
* Count what just happened to each object, and when it may be offered again.
|
|
367
|
+
*
|
|
368
|
+
* Written immediately after the upload pass and before ingest, so an ingest
|
|
369
|
+
* failure cannot lose the attempt counts — losing them resets every backoff to
|
|
370
|
+
* zero and the fleet is back to 15-minute retries forever. A held outcome is
|
|
371
|
+
* not itself an attempt: counting it would push its own next attempt further
|
|
372
|
+
* out on every sync and eventually never retry at all.
|
|
373
|
+
*/
|
|
374
|
+
async function persistDeliveryAttempts(stateDir, staging, outcomes, attemptedAt) {
|
|
375
|
+
let changed = false;
|
|
376
|
+
for (const outcome of outcomes) {
|
|
377
|
+
const contentHash = outcome.pointer.content_hash_sha256;
|
|
378
|
+
if (!contentHash)
|
|
379
|
+
continue;
|
|
380
|
+
if (outcome.reason === DELIVERY_BACKOFF_HOLDING_REASON)
|
|
381
|
+
continue;
|
|
382
|
+
if (outcome.upload_state === "upload_failed") {
|
|
383
|
+
const entry = recordDeliveryFailure(staging, contentHash, {
|
|
384
|
+
reason: outcome.reason ?? "upload_failed",
|
|
385
|
+
attemptedAt,
|
|
386
|
+
byteSize: outcome.pointer.byte_size ?? 0,
|
|
387
|
+
});
|
|
388
|
+
changed = true;
|
|
389
|
+
console.error("[cockpit-sync] raw evidence delivery failed", JSON.stringify({
|
|
390
|
+
reason: entry.last_reason,
|
|
391
|
+
kind: outcome.kind,
|
|
392
|
+
attempts: entry.attempts,
|
|
393
|
+
first_failed_at: entry.first_failed_at,
|
|
394
|
+
next_attempt_at: entry.next_attempt_at,
|
|
395
|
+
byte_size: entry.byte_size,
|
|
396
|
+
}));
|
|
397
|
+
continue;
|
|
398
|
+
}
|
|
399
|
+
if (clearDeliveryAttempt(staging, contentHash)) {
|
|
400
|
+
changed = true;
|
|
401
|
+
console.error("[cockpit-sync] raw evidence delivery recovered", JSON.stringify({
|
|
402
|
+
reason: "delivery_recovered",
|
|
403
|
+
kind: outcome.kind,
|
|
404
|
+
upload_state: outcome.upload_state,
|
|
405
|
+
}));
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
if (!changed)
|
|
409
|
+
return;
|
|
410
|
+
staging.updated_at = attemptedAt.toISOString();
|
|
411
|
+
await writeRawEvidenceStagingState(stateDir, staging).catch((error) => {
|
|
412
|
+
console.error("[cockpit-sync] delivery attempt state write failed", JSON.stringify({
|
|
413
|
+
reason: "staging_state_write_failed",
|
|
414
|
+
detail: error instanceof Error ? error.name : typeof error,
|
|
415
|
+
tracked_count: Object.keys(staging.delivery_attempts).length,
|
|
416
|
+
}));
|
|
417
|
+
});
|
|
418
|
+
}
|
|
311
419
|
function retrySourcesForFailedSync(options, facts) {
|
|
312
420
|
const sources = new Set();
|
|
313
421
|
if ((options.codexSessionFiles?.length ?? 0) > 0 ||
|
|
@@ -372,7 +480,20 @@ function hasRetryableEvidenceGap(facts, outcomes) {
|
|
|
372
480
|
facts.evidence_completeness.failure_reasons.length > 0) {
|
|
373
481
|
return true;
|
|
374
482
|
}
|
|
375
|
-
return facts.evidence_completeness.skip_reasons.some(({ reason }) =>
|
|
483
|
+
return facts.evidence_completeness.skip_reasons.some(({ reason }) => isRetryableEvidenceSkipReason(reason));
|
|
484
|
+
}
|
|
485
|
+
/**
|
|
486
|
+
* Skip reasons a later sync can still turn into evidence.
|
|
487
|
+
*
|
|
488
|
+
* `delivery_backoff_holding` is one of them, and it has to be: a source held by
|
|
489
|
+
* backoff is missing collection right now. If it did not land here the sync
|
|
490
|
+
* would read clean while a transcript sat undelivered for nine days, which is
|
|
491
|
+
* the exact shape of BLI-3066.
|
|
492
|
+
*/
|
|
493
|
+
function isRetryableEvidenceSkipReason(reason) {
|
|
494
|
+
if (reason === DELIVERY_BACKOFF_HOLDING_REASON)
|
|
495
|
+
return true;
|
|
496
|
+
return /(?:read|stat|directory)_failed|session_limit_overflow/iu.test(reason);
|
|
376
497
|
}
|
|
377
498
|
function retryableEvidenceGapReason(facts, outcomes) {
|
|
378
499
|
const reasons = new Set();
|
|
@@ -393,7 +514,7 @@ function retryableEvidenceGapReason(facts, outcomes) {
|
|
|
393
514
|
reasons.add("evidence_completeness_failed");
|
|
394
515
|
}
|
|
395
516
|
for (const { reason } of facts.evidence_completeness.skip_reasons) {
|
|
396
|
-
if (
|
|
517
|
+
if (isRetryableEvidenceSkipReason(reason)) {
|
|
397
518
|
reasons.add(reason);
|
|
398
519
|
}
|
|
399
520
|
}
|
|
@@ -861,7 +982,8 @@ function agentArtifactFromMetadata(options) {
|
|
|
861
982
|
},
|
|
862
983
|
};
|
|
863
984
|
}
|
|
864
|
-
function rawEvidenceSummary(built, outcomes, uploadedChunkCount, cursor) {
|
|
985
|
+
function rawEvidenceSummary(built, outcomes, uploadedChunkCount, cursor, staging, now) {
|
|
986
|
+
const stuck = summarizeStuckEvidence(staging, now);
|
|
865
987
|
const cursorReused = built.raw_evidence_facts?.reused ?? [];
|
|
866
988
|
const serverReusedCount = outcomes.filter((outcome) => outcome.upload_state === "reused_existing").length;
|
|
867
989
|
const failed = outcomes.filter((outcome) => outcome.upload_state === "upload_failed");
|
|
@@ -914,6 +1036,10 @@ function rawEvidenceSummary(built, outcomes, uploadedChunkCount, cursor) {
|
|
|
914
1036
|
raw_evidence_deferred_byte_budget: built.raw_evidence_facts?.deferred_byte_budget_count ?? 0,
|
|
915
1037
|
raw_evidence_deferred_object_budget: built.raw_evidence_facts?.deferred_object_budget_count ?? 0,
|
|
916
1038
|
cursor_tracked_object_count: Object.keys(cursor.objects).length,
|
|
1039
|
+
raw_evidence_delivery_held_count: outcomes.filter((outcome) => outcome.reason === DELIVERY_BACKOFF_HOLDING_REASON).length + (built.raw_evidence_facts?.delivery_held_count ?? 0),
|
|
1040
|
+
raw_evidence_stuck_object_count: stuck.stuck_object_count,
|
|
1041
|
+
raw_evidence_max_delivery_attempts: stuck.max_attempts,
|
|
1042
|
+
raw_evidence_oldest_delivery_failure_at: stuck.oldest_first_failed_at,
|
|
917
1043
|
};
|
|
918
1044
|
}
|
|
919
1045
|
/**
|