@bli-cockpit/cli 0.2.28 → 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/adapters/local-sources.js +1 -0
- package/dist/adapters/raw-evidence-completeness.js +226 -0
- package/dist/adapters/raw-evidence-git-diff.js +90 -0
- package/dist/adapters/raw-evidence-keys.js +92 -0
- package/dist/adapters/raw-evidence-manifest.js +132 -0
- package/dist/adapters/raw-evidence-pack-store.js +136 -0
- package/dist/adapters/raw-evidence-sanitize.js +190 -0
- package/dist/adapters/raw-evidence.js +656 -1257
- package/dist/commands/backfill.js +7 -0
- package/dist/commands/cli-io.js +92 -0
- package/dist/commands/collection-report.js +135 -0
- package/dist/commands/collection-roots.js +153 -0
- package/dist/commands/install-receipts.js +193 -0
- package/dist/commands/install-update.js +305 -0
- package/dist/commands/local-auth.js +268 -0
- package/dist/commands/local-discovery.js +100 -0
- package/dist/commands/local-help.js +281 -0
- package/dist/commands/local.js +170 -1860
- package/dist/commands/public-root.js +1 -1
- package/dist/commands/sessions.js +162 -0
- package/dist/commands/status.js +191 -0
- package/dist/evidence-upload-client.js +43 -2
- package/dist/raw-evidence-staging.js +15 -2
- package/dist/upload-agent-artifacts.js +153 -0
- package/dist/upload-envelope.js +407 -0
- package/dist/upload-evidence-delivery.js +505 -0
- package/dist/upload-http.js +46 -0
- package/dist/upload-session-reports.js +404 -0
- package/dist/upload.js +132 -1264
- package/package.json +2 -2
|
@@ -0,0 +1,505 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Did the bytes land, and what does the operator still owe?
|
|
3
|
+
*
|
|
4
|
+
* Everything in this file exists to keep one promise from the fleet contract:
|
|
5
|
+
* a green status must never hide missing collection. Three questions, in order:
|
|
6
|
+
*
|
|
7
|
+
* 1. **What may we even offer this sync?** `partitionHeldEvidenceFiles` keeps
|
|
8
|
+
* objects inside their delivery-backoff window off the wire, and turns each
|
|
9
|
+
* one into a named failure rather than a silent omission.
|
|
10
|
+
* 2. **What just happened to each object?** `persistDeliveryAttempts` writes the
|
|
11
|
+
* attempt counts that drive the backoff, before ingest, so an ingest failure
|
|
12
|
+
* cannot reset every window to zero.
|
|
13
|
+
* 3. **What do we tell the operator?** `hasRetryableEvidenceGap` decides whether
|
|
14
|
+
* a retry is queued, `retryableEvidenceGapReason` and
|
|
15
|
+
* `permanentEvidenceFailureReason` name why, and
|
|
16
|
+
* `summarizeRawEvidenceDelivery` builds the counts `cockpit status` reads.
|
|
17
|
+
*
|
|
18
|
+
* The one distinction that runs through all of it: a failure a later sync could
|
|
19
|
+
* still rescue is not the same thing as one storage has already refused on its
|
|
20
|
+
* own terms. Confusing them is what kept a Mac in `retry_pending` through 13
|
|
21
|
+
* identical syncs (BLI-2528), and what let a transcript sit undelivered for nine
|
|
22
|
+
* days while the sync read clean (BLI-3066).
|
|
23
|
+
*/
|
|
24
|
+
import { EvidenceCompletenessPayloadSchema, isPermanentUploadFailure, } from "@bli-cockpit/telemetry-core";
|
|
25
|
+
import { clearDeliveryAttempt, DELIVERY_BACKOFF_BYPASS_REASON, DELIVERY_BACKOFF_HOLDING_REASON, deliveryBackoffApplies, deliveryHold, recordDeliveryFailure, summarizeStuckEvidence, writeRawEvidenceStagingState, } from "./raw-evidence-staging.js";
|
|
26
|
+
// --------------------------------------------------------------------------
|
|
27
|
+
// 1. What may we offer this sync?
|
|
28
|
+
// --------------------------------------------------------------------------
|
|
29
|
+
/**
|
|
30
|
+
* Split the pack's files into "offer these now" and "still in backoff".
|
|
31
|
+
*
|
|
32
|
+
* A held file becomes an `upload_failed` outcome labelled
|
|
33
|
+
* `delivery_backoff_holding`. That is deliberate rather than a quiet omission:
|
|
34
|
+
* the pointer gets pruned from the envelope (the object is genuinely not
|
|
35
|
+
* durable), the sync stays in `retry_pending`, and the reason travels to the
|
|
36
|
+
* status output. A hold that read as success would be the green-status-hiding-
|
|
37
|
+
* missing-collection failure the fleet contract forbids.
|
|
38
|
+
*/
|
|
39
|
+
export function partitionHeldEvidenceFiles(files, staging, now, mode) {
|
|
40
|
+
const deliverable = [];
|
|
41
|
+
const held = [];
|
|
42
|
+
const bypassed = [];
|
|
43
|
+
const backoffApplies = deliveryBackoffApplies(mode);
|
|
44
|
+
for (const file of files) {
|
|
45
|
+
const hold = deliveryHold(staging, file.pointer.content_hash_sha256, now);
|
|
46
|
+
if (hold && !backoffApplies) {
|
|
47
|
+
// BLI-3118: a person asked for this one now. Offering it is the whole
|
|
48
|
+
// point of the retry command Cockpit printed, and the bypass is logged
|
|
49
|
+
// rather than assumed.
|
|
50
|
+
bypassed.push(hold);
|
|
51
|
+
deliverable.push(file);
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
if (!hold) {
|
|
55
|
+
deliverable.push(file);
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
held.push({
|
|
59
|
+
pointer: file.pointer,
|
|
60
|
+
object_key: file.pointer.object_key ?? "",
|
|
61
|
+
codex_session_id: file.codex_session_id ?? null,
|
|
62
|
+
kind: file.kind ?? "raw_evidence",
|
|
63
|
+
...(file.artifact_metadata
|
|
64
|
+
? { artifact_metadata: file.artifact_metadata }
|
|
65
|
+
: {}),
|
|
66
|
+
upload_state: "upload_failed",
|
|
67
|
+
reason: DELIVERY_BACKOFF_HOLDING_REASON,
|
|
68
|
+
uploaded_chunk_count: 0,
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
return { deliverable, held, bypassed };
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Say out loud that bytes were withheld on purpose.
|
|
75
|
+
*
|
|
76
|
+
* Counts and sizes only, never a path. Without this line an unattended machine
|
|
77
|
+
* withholds evidence for hours and leaves no trace of having done so.
|
|
78
|
+
*/
|
|
79
|
+
export function logEvidenceHeldByBackoff(held, attemptedAt) {
|
|
80
|
+
if (held.length === 0)
|
|
81
|
+
return;
|
|
82
|
+
console.error("[cockpit-sync] raw evidence held by delivery backoff", JSON.stringify({
|
|
83
|
+
attempted_at: attemptedAt,
|
|
84
|
+
reason: DELIVERY_BACKOFF_HOLDING_REASON,
|
|
85
|
+
object_count: held.length,
|
|
86
|
+
byte_size: held.reduce((sum, outcome) => sum + (outcome.pointer.byte_size ?? 0), 0),
|
|
87
|
+
}));
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Say out loud that an operator's retry ignored a live backoff window.
|
|
91
|
+
*
|
|
92
|
+
* The success branch of BLI-3118: without this line the only trace of the
|
|
93
|
+
* decision is an object that was held on one run and offered on the next, and
|
|
94
|
+
* nothing on the machine says which rule made the difference.
|
|
95
|
+
*/
|
|
96
|
+
export function logEvidenceBackoffBypassed(bypassed, attemptedAt) {
|
|
97
|
+
if (bypassed.length === 0)
|
|
98
|
+
return;
|
|
99
|
+
console.error("[cockpit-sync] raw evidence delivery backoff bypassed", JSON.stringify({
|
|
100
|
+
attempted_at: attemptedAt,
|
|
101
|
+
reason: DELIVERY_BACKOFF_BYPASS_REASON,
|
|
102
|
+
object_count: bypassed.length,
|
|
103
|
+
byte_size: bypassed.reduce((sum, entry) => sum + entry.byte_size, 0),
|
|
104
|
+
max_attempts: bypassed.reduce((max, entry) => Math.max(max, entry.attempts), 0),
|
|
105
|
+
last_reasons: [...new Set(bypassed.map((entry) => entry.last_reason))]
|
|
106
|
+
.sort(),
|
|
107
|
+
}));
|
|
108
|
+
}
|
|
109
|
+
// --------------------------------------------------------------------------
|
|
110
|
+
// 2. What just happened to each object?
|
|
111
|
+
// --------------------------------------------------------------------------
|
|
112
|
+
/**
|
|
113
|
+
* Count what just happened to each object, and when it may be offered again.
|
|
114
|
+
*
|
|
115
|
+
* Written immediately after the upload pass and before ingest, so an ingest
|
|
116
|
+
* failure cannot lose the attempt counts — losing them resets every backoff to
|
|
117
|
+
* zero and the fleet is back to 15-minute retries forever. A held outcome is
|
|
118
|
+
* not itself an attempt: counting it would push its own next attempt further
|
|
119
|
+
* out on every sync and eventually never retry at all.
|
|
120
|
+
*/
|
|
121
|
+
export async function persistDeliveryAttempts(stateDir, staging, outcomes, attemptedAt) {
|
|
122
|
+
let changed = false;
|
|
123
|
+
for (const outcome of outcomes) {
|
|
124
|
+
const contentHash = outcome.pointer.content_hash_sha256;
|
|
125
|
+
if (!contentHash)
|
|
126
|
+
continue;
|
|
127
|
+
if (outcome.reason === DELIVERY_BACKOFF_HOLDING_REASON)
|
|
128
|
+
continue;
|
|
129
|
+
if (outcome.upload_state === "upload_failed") {
|
|
130
|
+
const entry = recordDeliveryFailure(staging, contentHash, {
|
|
131
|
+
reason: outcome.reason ?? "upload_failed",
|
|
132
|
+
attemptedAt,
|
|
133
|
+
byteSize: outcome.pointer.byte_size ?? 0,
|
|
134
|
+
});
|
|
135
|
+
changed = true;
|
|
136
|
+
console.error("[cockpit-sync] raw evidence delivery failed", JSON.stringify({
|
|
137
|
+
reason: entry.last_reason,
|
|
138
|
+
kind: outcome.kind,
|
|
139
|
+
attempts: entry.attempts,
|
|
140
|
+
first_failed_at: entry.first_failed_at,
|
|
141
|
+
next_attempt_at: entry.next_attempt_at,
|
|
142
|
+
byte_size: entry.byte_size,
|
|
143
|
+
}));
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
if (clearDeliveryAttempt(staging, contentHash)) {
|
|
147
|
+
changed = true;
|
|
148
|
+
console.error("[cockpit-sync] raw evidence delivery recovered", JSON.stringify({
|
|
149
|
+
reason: "delivery_recovered",
|
|
150
|
+
kind: outcome.kind,
|
|
151
|
+
upload_state: outcome.upload_state,
|
|
152
|
+
}));
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
if (!changed)
|
|
156
|
+
return;
|
|
157
|
+
staging.updated_at = attemptedAt.toISOString();
|
|
158
|
+
await writeRawEvidenceStagingState(stateDir, staging).catch((error) => {
|
|
159
|
+
console.error("[cockpit-sync] delivery attempt state write failed", JSON.stringify({
|
|
160
|
+
reason: "staging_state_write_failed",
|
|
161
|
+
detail: error instanceof Error ? error.name : typeof error,
|
|
162
|
+
tracked_count: Object.keys(staging.delivery_attempts).length,
|
|
163
|
+
}));
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
// --------------------------------------------------------------------------
|
|
167
|
+
// 3. What do we still owe the operator?
|
|
168
|
+
// --------------------------------------------------------------------------
|
|
169
|
+
/**
|
|
170
|
+
* Which adapters a queued retry has to re-run.
|
|
171
|
+
*
|
|
172
|
+
* Anything this sync touched or tried to touch counts, including a directory it
|
|
173
|
+
* could not read — a source that failed to scan is exactly the one a retry must
|
|
174
|
+
* come back to.
|
|
175
|
+
*/
|
|
176
|
+
export function retrySourcesForFailedSync(options, facts) {
|
|
177
|
+
const sources = new Set();
|
|
178
|
+
if ((options.codexSessionFiles?.length ?? 0) > 0 ||
|
|
179
|
+
(options.codexAttributionScan?.directory_read_failed_count ?? 0) > 0 ||
|
|
180
|
+
(options.codexAttributionScan?.stat_failed_count ?? 0) > 0 ||
|
|
181
|
+
facts?.evidence_completeness.source_counts.some((count) => count.source.startsWith("codex_") &&
|
|
182
|
+
count.scanned_count + count.included_count + count.reused_count > 0)) {
|
|
183
|
+
sources.add("codex");
|
|
184
|
+
}
|
|
185
|
+
if ((options.claudeSessionFiles?.length ?? 0) > 0 ||
|
|
186
|
+
(options.claudeAttributionScan?.project_dir_read_failed_count ?? 0) > 0 ||
|
|
187
|
+
(options.claudeAttributionScan?.session_stat_failed_count ?? 0) > 0 ||
|
|
188
|
+
(options.claudeAttributionScan?.sidecar_dir_read_failed_count ?? 0) > 0 ||
|
|
189
|
+
(options.claudeAttributionScan?.sidecar_stat_failed_count ?? 0) > 0 ||
|
|
190
|
+
facts?.evidence_completeness.source_counts.some((count) => count.source.startsWith("claude_") &&
|
|
191
|
+
count.scanned_count + count.included_count + count.reused_count > 0)) {
|
|
192
|
+
sources.add("claude_code");
|
|
193
|
+
}
|
|
194
|
+
return [...sources];
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* The failed uploads a later attempt could still rescue.
|
|
198
|
+
*
|
|
199
|
+
* An object storage has already refused on its own terms is not one of them,
|
|
200
|
+
* and counting it as one is what kept Edward's Mac in `retry_pending` through
|
|
201
|
+
* 13 consecutive syncs that were never going to end differently (BLI-2528).
|
|
202
|
+
*/
|
|
203
|
+
function retryableFailedOutcomes(outcomes) {
|
|
204
|
+
return outcomes.filter((outcome) => outcome.upload_state === "upload_failed" &&
|
|
205
|
+
!isPermanentUploadFailure(outcome.reason));
|
|
206
|
+
}
|
|
207
|
+
/** Failed uploads that no retry can rescue, kept so they can still be named. */
|
|
208
|
+
function permanentFailedOutcomes(outcomes) {
|
|
209
|
+
return outcomes.filter((outcome) => outcome.upload_state === "upload_failed" &&
|
|
210
|
+
isPermanentUploadFailure(outcome.reason));
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* The reason to show for objects that failed for good.
|
|
214
|
+
*
|
|
215
|
+
* Returns null when there are none. These never queue a retry, but they must
|
|
216
|
+
* never disappear either: a machine with a permanently rejected object has
|
|
217
|
+
* missing collection, and a status that reads clean would hide it.
|
|
218
|
+
*/
|
|
219
|
+
export function permanentEvidenceFailureReason(outcomes) {
|
|
220
|
+
const reasons = new Set(permanentFailedOutcomes(outcomes).map((outcome) => outcome.reason ?? "unknown"));
|
|
221
|
+
if (reasons.size === 0)
|
|
222
|
+
return null;
|
|
223
|
+
return `raw_evidence_permanently_rejected:${[...reasons].sort().join(",")}`;
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* Leave a dated record of the objects this machine gave up on.
|
|
227
|
+
*
|
|
228
|
+
* stderr, which launchd captures to `sync.err.log`, so an unattended machine
|
|
229
|
+
* still says what it lost. Reason labels and counts only — never a path or a
|
|
230
|
+
* byte of content.
|
|
231
|
+
*/
|
|
232
|
+
export function logPermanentlyRejectedEvidence(options) {
|
|
233
|
+
console.error("[cockpit-sync] raw evidence permanently rejected", JSON.stringify({
|
|
234
|
+
attempted_at: options.attemptedAt,
|
|
235
|
+
reason: options.reason,
|
|
236
|
+
object_count: permanentFailedOutcomes(options.outcomes).length,
|
|
237
|
+
}));
|
|
238
|
+
}
|
|
239
|
+
/**
|
|
240
|
+
* Is there anything a later sync could still turn into evidence?
|
|
241
|
+
*
|
|
242
|
+
* Any one of these is enough: an upload that failed for a rescuable reason,
|
|
243
|
+
* evidence deferred by this sync's byte or object budget, a completeness
|
|
244
|
+
* payload that reports failure, or a skip a retry could undo.
|
|
245
|
+
*/
|
|
246
|
+
export function hasRetryableEvidenceGap(facts, outcomes) {
|
|
247
|
+
if (retryableFailedOutcomes(outcomes).length > 0) {
|
|
248
|
+
return true;
|
|
249
|
+
}
|
|
250
|
+
if (!facts)
|
|
251
|
+
return false;
|
|
252
|
+
if (facts.deferred_byte_budget_count > 0 ||
|
|
253
|
+
facts.deferred_object_budget_count > 0) {
|
|
254
|
+
return true;
|
|
255
|
+
}
|
|
256
|
+
if (facts.evidence_completeness.status === "failed" ||
|
|
257
|
+
facts.evidence_completeness.totals.failed_count > 0 ||
|
|
258
|
+
facts.evidence_completeness.failure_reasons.length > 0) {
|
|
259
|
+
return true;
|
|
260
|
+
}
|
|
261
|
+
return facts.evidence_completeness.skip_reasons.some(({ reason }) => isRetryableEvidenceSkipReason(reason));
|
|
262
|
+
}
|
|
263
|
+
/**
|
|
264
|
+
* Skip reasons a later sync can still turn into evidence.
|
|
265
|
+
*
|
|
266
|
+
* `delivery_backoff_holding` is one of them, and it has to be: a source held by
|
|
267
|
+
* backoff is missing collection right now. If it did not land here the sync
|
|
268
|
+
* would read clean while a transcript sat undelivered for nine days, which is
|
|
269
|
+
* the exact shape of BLI-3066.
|
|
270
|
+
*/
|
|
271
|
+
function isRetryableEvidenceSkipReason(reason) {
|
|
272
|
+
if (reason === DELIVERY_BACKOFF_HOLDING_REASON)
|
|
273
|
+
return true;
|
|
274
|
+
return /(?:read|stat|directory)_failed|session_limit_overflow/iu.test(reason);
|
|
275
|
+
}
|
|
276
|
+
/** Every distinct reason behind the gap, sorted, as one spool-ready label. */
|
|
277
|
+
export function retryableEvidenceGapReason(facts, outcomes) {
|
|
278
|
+
const reasons = new Set();
|
|
279
|
+
for (const outcome of retryableFailedOutcomes(outcomes)) {
|
|
280
|
+
reasons.add(outcome.reason ?? "upload_failed");
|
|
281
|
+
}
|
|
282
|
+
if (facts) {
|
|
283
|
+
if (facts.deferred_byte_budget_count > 0)
|
|
284
|
+
reasons.add("deferred_byte_budget");
|
|
285
|
+
if (facts.deferred_object_budget_count > 0) {
|
|
286
|
+
reasons.add("deferred_object_budget");
|
|
287
|
+
}
|
|
288
|
+
for (const { reason } of facts.evidence_completeness.failure_reasons) {
|
|
289
|
+
reasons.add(reason);
|
|
290
|
+
}
|
|
291
|
+
if (facts.evidence_completeness.status === "failed" &&
|
|
292
|
+
facts.evidence_completeness.failure_reasons.length === 0) {
|
|
293
|
+
reasons.add("evidence_completeness_failed");
|
|
294
|
+
}
|
|
295
|
+
for (const { reason } of facts.evidence_completeness.skip_reasons) {
|
|
296
|
+
if (isRetryableEvidenceSkipReason(reason)) {
|
|
297
|
+
reasons.add(reason);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
return `partial_raw_evidence_retry_required:${[
|
|
302
|
+
...reasons,
|
|
303
|
+
].sort().join(",") || "unknown"}`;
|
|
304
|
+
}
|
|
305
|
+
/**
|
|
306
|
+
* The counts `cockpit sync --json` and `cockpit status` read.
|
|
307
|
+
*
|
|
308
|
+
* Two kinds of reuse are added together on purpose: files this machine skipped
|
|
309
|
+
* because its own cursor already had the bytes, and files the server recognised
|
|
310
|
+
* from their content hash.
|
|
311
|
+
*/
|
|
312
|
+
export function summarizeRawEvidenceDelivery(built, outcomes, uploadedChunkCount, cursor, staging, now) {
|
|
313
|
+
const stuck = summarizeStuckEvidence(staging, now);
|
|
314
|
+
const cursorReused = built.raw_evidence_facts?.reused ?? [];
|
|
315
|
+
const serverReusedCount = outcomes.filter((outcome) => outcome.upload_state === "reused_existing").length;
|
|
316
|
+
const failed = outcomes.filter((outcome) => outcome.upload_state === "upload_failed");
|
|
317
|
+
const retryRequired = hasRetryableEvidenceGap(built.raw_evidence_facts, outcomes);
|
|
318
|
+
const retryReason = retryRequired
|
|
319
|
+
? retryableEvidenceGapReason(built.raw_evidence_facts, outcomes)
|
|
320
|
+
: null;
|
|
321
|
+
const cursorReusedOutcomes = cursorReused.map((entry) => ({
|
|
322
|
+
object_key: cursor.objects[entry.content_hash_sha256]?.object_key ?? "",
|
|
323
|
+
raw_evidence_pointer_id: cursor.objects[entry.content_hash_sha256]?.object_key ?? "",
|
|
324
|
+
kind: entry.kind,
|
|
325
|
+
codex_session_id: entry.codex_session_id,
|
|
326
|
+
...(entry.artifact_metadata
|
|
327
|
+
? { artifact_metadata: entry.artifact_metadata }
|
|
328
|
+
: {}),
|
|
329
|
+
upload_state: "reused_existing",
|
|
330
|
+
reason: "cursor_content_match",
|
|
331
|
+
}));
|
|
332
|
+
return {
|
|
333
|
+
raw_evidence_file_count: built.raw_evidence_upload_files.length,
|
|
334
|
+
raw_evidence_uploaded_object_count: outcomes.filter((outcome) => outcome.upload_state === "uploaded").length,
|
|
335
|
+
raw_evidence_uploaded_chunk_count: uploadedChunkCount,
|
|
336
|
+
raw_evidence_reused_count: cursorReused.length + serverReusedCount,
|
|
337
|
+
raw_evidence_failed_count: failed.length,
|
|
338
|
+
raw_evidence_sanitized_count: built.raw_evidence_facts?.sanitized_count ?? 0,
|
|
339
|
+
raw_evidence_failure_reasons: [
|
|
340
|
+
...new Set(failed.map((outcome) => outcome.reason ?? "unknown")),
|
|
341
|
+
],
|
|
342
|
+
raw_evidence_retry_required: retryRequired,
|
|
343
|
+
raw_evidence_retry_reasons: retryReason
|
|
344
|
+
? retryReason
|
|
345
|
+
.replace(/^partial_raw_evidence_retry_required:/u, "")
|
|
346
|
+
.split(",")
|
|
347
|
+
.filter(Boolean)
|
|
348
|
+
: [],
|
|
349
|
+
raw_evidence_outcomes: [
|
|
350
|
+
...outcomes.map((outcome) => ({
|
|
351
|
+
object_key: outcome.object_key,
|
|
352
|
+
raw_evidence_pointer_id: outcome.pointer.raw_evidence_pointer_id,
|
|
353
|
+
kind: outcome.kind,
|
|
354
|
+
codex_session_id: outcome.codex_session_id,
|
|
355
|
+
...(outcome.artifact_metadata
|
|
356
|
+
? { artifact_metadata: outcome.artifact_metadata }
|
|
357
|
+
: {}),
|
|
358
|
+
upload_state: outcome.upload_state,
|
|
359
|
+
reason: outcome.reason,
|
|
360
|
+
})),
|
|
361
|
+
...cursorReusedOutcomes,
|
|
362
|
+
],
|
|
363
|
+
raw_evidence_deferred_byte_budget: built.raw_evidence_facts?.deferred_byte_budget_count ?? 0,
|
|
364
|
+
raw_evidence_deferred_object_budget: built.raw_evidence_facts?.deferred_object_budget_count ?? 0,
|
|
365
|
+
cursor_tracked_object_count: Object.keys(cursor.objects).length,
|
|
366
|
+
raw_evidence_delivery_held_count: outcomes.filter((outcome) => outcome.reason === DELIVERY_BACKOFF_HOLDING_REASON).length + (built.raw_evidence_facts?.delivery_held_count ?? 0),
|
|
367
|
+
raw_evidence_stuck_object_count: stuck.stuck_object_count,
|
|
368
|
+
raw_evidence_max_delivery_attempts: stuck.max_attempts,
|
|
369
|
+
raw_evidence_oldest_delivery_failure_at: stuck.oldest_first_failed_at,
|
|
370
|
+
};
|
|
371
|
+
}
|
|
372
|
+
// --------------------------------------------------------------------------
|
|
373
|
+
// 4. Reconciling the envelope with what actually became durable
|
|
374
|
+
// --------------------------------------------------------------------------
|
|
375
|
+
/**
|
|
376
|
+
* Ingest refuses pointers whose objects never became durable, so failed
|
|
377
|
+
* uploads are pruned from the envelope instead of failing the whole sync.
|
|
378
|
+
* Successful upload responses can also carry server-side sanitized hash and
|
|
379
|
+
* redaction metadata; apply those before ingest so refs describe the bytes
|
|
380
|
+
* actually stored in the durable bucket.
|
|
381
|
+
*/
|
|
382
|
+
export function applyRawEvidenceUploadOutcomes(envelope, outcomes) {
|
|
383
|
+
// Content-addressed keys mean one pointer id can carry several outcomes
|
|
384
|
+
// (byte-identical files); the pointer is durable if ANY outcome succeeded.
|
|
385
|
+
const durablePointers = new Map();
|
|
386
|
+
for (const outcome of outcomes) {
|
|
387
|
+
if (outcome.upload_state === "upload_failed")
|
|
388
|
+
continue;
|
|
389
|
+
durablePointers.set(outcome.pointer.raw_evidence_pointer_id, outcome.pointer);
|
|
390
|
+
}
|
|
391
|
+
const durablePointerIds = new Set([...durablePointers.keys()]);
|
|
392
|
+
const failedPointerIds = new Set(outcomes
|
|
393
|
+
.filter((outcome) => outcome.upload_state === "upload_failed" &&
|
|
394
|
+
!durablePointerIds.has(outcome.pointer.raw_evidence_pointer_id))
|
|
395
|
+
.map((outcome) => outcome.pointer.raw_evidence_pointer_id));
|
|
396
|
+
const failedOutcomes = outcomes.filter((outcome) => failedPointerIds.has(outcome.pointer.raw_evidence_pointer_id));
|
|
397
|
+
if (failedPointerIds.size === 0 && durablePointers.size === 0)
|
|
398
|
+
return envelope;
|
|
399
|
+
return {
|
|
400
|
+
...envelope,
|
|
401
|
+
events: envelope.events.map((event) => ({
|
|
402
|
+
...event,
|
|
403
|
+
metrics: failedPointerIds.size > 0
|
|
404
|
+
? {
|
|
405
|
+
...event.metrics,
|
|
406
|
+
evidence_failed_count: (event.metrics["evidence_failed_count"] ?? 0) +
|
|
407
|
+
failedOutcomes.length,
|
|
408
|
+
}
|
|
409
|
+
: event.metrics,
|
|
410
|
+
attributes: failedPointerIds.size > 0 && event.evidence_completeness
|
|
411
|
+
? {
|
|
412
|
+
...event.attributes,
|
|
413
|
+
evidence_completeness_schema_version: event.evidence_completeness.schema_version,
|
|
414
|
+
evidence_completeness_status: "partial",
|
|
415
|
+
evidence_incomplete: true,
|
|
416
|
+
}
|
|
417
|
+
: event.attributes,
|
|
418
|
+
evidence_completeness: failedPointerIds.size > 0 && event.evidence_completeness
|
|
419
|
+
? markCompletenessUploadFailures(event.evidence_completeness, failedOutcomes)
|
|
420
|
+
: event.evidence_completeness,
|
|
421
|
+
raw_evidence_pointers: event.raw_evidence_pointers.flatMap((pointer) => {
|
|
422
|
+
const pointerId = pointer.raw_evidence_pointer_id;
|
|
423
|
+
if (failedPointerIds.has(pointerId))
|
|
424
|
+
return [];
|
|
425
|
+
return [durablePointers.get(pointerId) ?? pointer];
|
|
426
|
+
}),
|
|
427
|
+
redaction: failedPointerIds.size > 0
|
|
428
|
+
? {
|
|
429
|
+
...event.redaction,
|
|
430
|
+
raw_evidence_pointer_ids: event.redaction.raw_evidence_pointer_ids.filter((pointerId) => !failedPointerIds.has(pointerId)),
|
|
431
|
+
}
|
|
432
|
+
: event.redaction,
|
|
433
|
+
})),
|
|
434
|
+
};
|
|
435
|
+
}
|
|
436
|
+
/**
|
|
437
|
+
* Fold upload failures back into the completeness payload the event carries, so
|
|
438
|
+
* a downstream reader sees `partial` and the per-source failure counts rather
|
|
439
|
+
* than a complete-looking payload with fewer pointers than it claims.
|
|
440
|
+
*/
|
|
441
|
+
function markCompletenessUploadFailures(completeness, failedOutcomes) {
|
|
442
|
+
const failureCounts = new Map();
|
|
443
|
+
for (const outcome of failedOutcomes) {
|
|
444
|
+
const source = outcome.kind ?? "raw_evidence";
|
|
445
|
+
failureCounts.set(source, (failureCounts.get(source) ?? 0) + 1);
|
|
446
|
+
}
|
|
447
|
+
const totalFailures = [...failureCounts.values()].reduce((sum, count) => sum + count, 0);
|
|
448
|
+
const sourceCounts = [...completeness.source_counts];
|
|
449
|
+
for (const [source, count] of failureCounts) {
|
|
450
|
+
const existingIndex = sourceCounts.findIndex((entry) => entry.source === source);
|
|
451
|
+
if (existingIndex === -1) {
|
|
452
|
+
sourceCounts.push({
|
|
453
|
+
source,
|
|
454
|
+
scanned_count: 0,
|
|
455
|
+
included_count: 0,
|
|
456
|
+
skipped_count: 0,
|
|
457
|
+
truncated_count: 0,
|
|
458
|
+
deferred_count: 0,
|
|
459
|
+
reused_count: 0,
|
|
460
|
+
failed_count: count,
|
|
461
|
+
});
|
|
462
|
+
continue;
|
|
463
|
+
}
|
|
464
|
+
const existing = sourceCounts[existingIndex];
|
|
465
|
+
if (!existing)
|
|
466
|
+
continue;
|
|
467
|
+
sourceCounts[existingIndex] = {
|
|
468
|
+
...existing,
|
|
469
|
+
failed_count: existing.failed_count + count,
|
|
470
|
+
};
|
|
471
|
+
}
|
|
472
|
+
const failureReasons = [...completeness.failure_reasons];
|
|
473
|
+
for (const [source, count] of failureCounts) {
|
|
474
|
+
const reason = "upload_failed";
|
|
475
|
+
const existingIndex = failureReasons.findIndex((entry) => entry.source === source && entry.reason === reason);
|
|
476
|
+
if (existingIndex === -1) {
|
|
477
|
+
failureReasons.push({ source, reason, count });
|
|
478
|
+
}
|
|
479
|
+
else {
|
|
480
|
+
const existing = failureReasons[existingIndex];
|
|
481
|
+
if (existing) {
|
|
482
|
+
failureReasons[existingIndex] = {
|
|
483
|
+
...existing,
|
|
484
|
+
count: existing.count + count,
|
|
485
|
+
};
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
return EvidenceCompletenessPayloadSchema.parse({
|
|
490
|
+
...completeness,
|
|
491
|
+
status: "partial",
|
|
492
|
+
source_counts: sourceCounts,
|
|
493
|
+
totals: {
|
|
494
|
+
...completeness.totals,
|
|
495
|
+
failed_count: completeness.totals.failed_count + totalFailures,
|
|
496
|
+
},
|
|
497
|
+
failure_reasons: failureReasons.sort((a, b) => `${a.source}:${a.reason}`.localeCompare(`${b.source}:${b.reason}`)),
|
|
498
|
+
notes: [
|
|
499
|
+
...new Set([
|
|
500
|
+
...completeness.notes,
|
|
501
|
+
"Some collected evidence did not become durable; downstream analysis should lower confidence.",
|
|
502
|
+
]),
|
|
503
|
+
],
|
|
504
|
+
});
|
|
505
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The small shared vocabulary for talking to the dashboard.
|
|
3
|
+
*
|
|
4
|
+
* A sync touches three separate ambient endpoints — ingest, codex-sessions and
|
|
5
|
+
* agent-artifacts — and all three need the same two things: read whatever the
|
|
6
|
+
* server sent back without letting a non-JSON body throw, and turn a normal
|
|
7
|
+
* dashboard URL into one that can be concatenated with a path.
|
|
8
|
+
*/
|
|
9
|
+
/**
|
|
10
|
+
* Reads a reply body as JSON, and never throws doing it.
|
|
11
|
+
*
|
|
12
|
+
* An empty body reads as `{}`; a body that is not JSON at all (a proxy's HTML
|
|
13
|
+
* error page, say) reads as `{ message: <text> }` so the caller can still put a
|
|
14
|
+
* reason in front of a human.
|
|
15
|
+
*/
|
|
16
|
+
export async function readResponseJson(response) {
|
|
17
|
+
const text = await response.text();
|
|
18
|
+
if (!text)
|
|
19
|
+
return {};
|
|
20
|
+
try {
|
|
21
|
+
return JSON.parse(text);
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
return { message: text };
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
/** The server's own words when it supplied any, otherwise our fallback. */
|
|
28
|
+
export function responseErrorMessage(value, fallback) {
|
|
29
|
+
if (value && typeof value === "object") {
|
|
30
|
+
const record = value;
|
|
31
|
+
const message = record["message"] ?? record["error"];
|
|
32
|
+
if (typeof message === "string" && message.trim())
|
|
33
|
+
return message;
|
|
34
|
+
}
|
|
35
|
+
return fallback;
|
|
36
|
+
}
|
|
37
|
+
/** Trims a dashboard URL to a bare origin so `${url}/api/...` is well formed. */
|
|
38
|
+
export function normalizeDashboardUrl(value) {
|
|
39
|
+
const normalized = value.trim().replace(/\/+$/, "");
|
|
40
|
+
if (!normalized)
|
|
41
|
+
throw new Error("Dashboard URL cannot be empty.");
|
|
42
|
+
return normalized;
|
|
43
|
+
}
|
|
44
|
+
export function isNonEmptyString(value) {
|
|
45
|
+
return typeof value === "string" && value.trim().length > 0;
|
|
46
|
+
}
|