@bli-cockpit/cli 0.2.53 → 0.2.55
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/attribution-core-fallbacks.js +247 -0
- package/dist/adapters/attribution-core-paths.js +182 -0
- package/dist/adapters/attribution-core-score.js +159 -0
- package/dist/adapters/attribution-core-types.js +13 -0
- package/dist/adapters/attribution-core.js +13 -565
- package/dist/adapters/claude-attribution-discovery.js +186 -0
- package/dist/adapters/claude-attribution-score.js +204 -0
- package/dist/adapters/claude-attribution-signals.js +180 -0
- package/dist/adapters/claude-attribution-types.js +25 -0
- package/dist/adapters/claude-attribution.js +14 -569
- package/dist/commands/doctor-access.js +129 -0
- package/dist/commands/doctor-pipeline.js +326 -0
- package/dist/commands/doctor-registration.js +105 -0
- package/dist/commands/doctor-report.js +111 -0
- package/dist/commands/doctor-update.js +120 -0
- package/dist/commands/doctor.js +8 -753
- package/dist/commands/heartbeat.js +8 -0
- package/dist/commands/jarvis-contracts.js +8 -0
- package/dist/commands/jarvis-render.js +413 -0
- package/dist/commands/jarvis-turn.js +305 -0
- package/dist/commands/jarvis.js +23 -698
- package/dist/commands/local-args-collector-setup.js +250 -0
- package/dist/commands/local-args-collector-status.js +227 -0
- package/dist/commands/local-args-collector-work.js +175 -0
- package/dist/commands/local-args-collector.js +19 -624
- package/dist/commands/local-args-tower-admin.js +456 -0
- package/dist/commands/local-args-tower-chat.js +194 -0
- package/dist/commands/local-args-tower-pages.js +314 -0
- package/dist/commands/local-args-tower.js +13 -880
- package/dist/commands/local-help.js +10 -2
- package/dist/commands/onboard-completion.js +136 -0
- package/dist/commands/onboard-flows.js +165 -0
- package/dist/commands/onboard-setup.js +102 -0
- package/dist/commands/onboard.js +5 -392
- package/dist/commands/public-root.js +1 -1
- package/dist/commands/session-sync-counters.js +55 -0
- package/dist/commands/session-sync-health.js +8 -1
- package/dist/commands/session-sync-plan.js +47 -7
- package/dist/commands/session-sync-scan.js +4 -4
- package/dist/commands/session-sync.js +6 -0
- package/dist/commands/settings-render.js +27 -0
- package/dist/commands/sync-followups.js +5 -1
- package/dist/commands/sync.js +5 -1
- package/dist/commands/team-device-reasons.js +16 -0
- package/dist/commands/team.js +87 -7
- package/dist/evidence-upload-client.js +14 -763
- package/dist/evidence-upload-object.js +181 -0
- package/dist/evidence-upload-plan.js +233 -0
- package/dist/evidence-upload-terminal.js +309 -0
- package/dist/evidence-upload-transport.js +104 -0
- package/dist/spool/local-spool-io.js +122 -0
- package/dist/spool/local-spool-mutations.js +174 -0
- package/dist/spool/local-spool-parse.js +143 -0
- package/dist/spool/local-spool-types.js +22 -0
- package/dist/spool/local-spool.js +20 -426
- package/dist/upload-evidence-delivery-offer.js +144 -0
- package/dist/upload-evidence-delivery-reconcile.js +134 -0
- package/dist/upload-evidence-delivery-summary.js +205 -0
- package/dist/upload-evidence-delivery.js +12 -482
- package/package.json +3 -3
|
@@ -1,781 +1,32 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import
|
|
3
|
-
import
|
|
4
|
-
import {
|
|
5
|
-
import { describeError } from "./health-detail.js";
|
|
6
|
-
const DEFAULT_MAX_ATTEMPTS = 3;
|
|
7
|
-
const RETRY_DELAY_MS = 250;
|
|
1
|
+
import { RAW_EVIDENCE_UPLOAD_DEFAULT_CHUNK_BYTES, RAW_EVIDENCE_UPLOAD_MAX_OBJECTS_PER_BEGIN, } from "@bli-cockpit/telemetry-core";
|
|
2
|
+
import { batches, loadPlannedEntries, resolveBeginBatch } from "./evidence-upload-plan.js";
|
|
3
|
+
import { summarizeOutcomes, uploadWithLegacyFallback } from "./evidence-upload-terminal.js";
|
|
4
|
+
import { NON_JSON_RESPONSE_BODY_KEY } from "./evidence-upload-transport.js";
|
|
8
5
|
export async function uploadRawEvidenceFilesChunked(options) {
|
|
9
6
|
const outcomes = [];
|
|
10
7
|
if (options.files.length === 0) {
|
|
11
8
|
return summarizeOutcomes(outcomes, false);
|
|
12
9
|
}
|
|
10
|
+
// PLAN: read every staged file once, dedupe byte-identical siblings.
|
|
13
11
|
const chunkSizeBytes = options.chunkSizeBytes ?? RAW_EVIDENCE_UPLOAD_DEFAULT_CHUNK_BYTES;
|
|
14
|
-
const loaded =
|
|
15
|
-
|
|
16
|
-
for (const file of options.files) {
|
|
17
|
-
let stat;
|
|
18
|
-
try {
|
|
19
|
-
stat = await fs.stat(file.local_path);
|
|
20
|
-
}
|
|
21
|
-
catch (error) {
|
|
22
|
-
// `file_read_failed` is a durable ledger label and stays. Beside it: a
|
|
23
|
-
// staged object that vanished (the GC won a race) and one the process
|
|
24
|
-
// has no permission to open are the same word and opposite repairs
|
|
25
|
-
// (BLI-3238).
|
|
26
|
-
console.error("[evidence-upload] staged object could not be stat'd", JSON.stringify({
|
|
27
|
-
reason: "file_read_failed",
|
|
28
|
-
stage: "stat",
|
|
29
|
-
...describeError(error),
|
|
30
|
-
}));
|
|
31
|
-
outcomes.push(failedOutcome(file, "file_read_failed"));
|
|
32
|
-
continue;
|
|
33
|
-
}
|
|
34
|
-
if (stat.size === 0) {
|
|
35
|
-
outcomes.push(failedOutcome(file, "empty_file"));
|
|
36
|
-
continue;
|
|
37
|
-
}
|
|
38
|
-
if (stat.size > RAW_EVIDENCE_UPLOAD_MAX_FILE_BYTES) {
|
|
39
|
-
outcomes.push(failedOutcome(file, "file_too_large"));
|
|
40
|
-
continue;
|
|
41
|
-
}
|
|
42
|
-
let bytes;
|
|
43
|
-
try {
|
|
44
|
-
bytes = await fs.readFile(file.local_path);
|
|
45
|
-
}
|
|
46
|
-
catch (error) {
|
|
47
|
-
// Stat succeeded and the read did not — narrower than the stat failure
|
|
48
|
-
// above and worth telling apart, so it carries its own stage label.
|
|
49
|
-
console.error("[evidence-upload] staged object could not be read after a successful stat", JSON.stringify({
|
|
50
|
-
reason: "file_read_failed",
|
|
51
|
-
stage: "read",
|
|
52
|
-
byte_size: stat.size,
|
|
53
|
-
...describeError(error),
|
|
54
|
-
}));
|
|
55
|
-
outcomes.push(failedOutcome(file, "file_read_failed"));
|
|
56
|
-
continue;
|
|
57
|
-
}
|
|
58
|
-
if (bytes.byteLength === 0) {
|
|
59
|
-
outcomes.push(failedOutcome(file, "empty_file"));
|
|
60
|
-
continue;
|
|
61
|
-
}
|
|
62
|
-
if (bytes.byteLength > RAW_EVIDENCE_UPLOAD_MAX_FILE_BYTES) {
|
|
63
|
-
outcomes.push(failedOutcome(file, "file_too_large"));
|
|
64
|
-
continue;
|
|
65
|
-
}
|
|
66
|
-
// Content-addressed keys collide for byte-identical files (e.g. a resumed
|
|
67
|
-
// session copied twice); upload once and share the outcome instead of
|
|
68
|
-
// racing a second upload against a committed ledger row.
|
|
69
|
-
const existing = file.pointer.object_key
|
|
70
|
-
? loadedByObjectKey.get(file.pointer.object_key)
|
|
71
|
-
: undefined;
|
|
72
|
-
if (existing) {
|
|
73
|
-
existing.duplicates.push(file);
|
|
74
|
-
continue;
|
|
75
|
-
}
|
|
76
|
-
const entry = {
|
|
77
|
-
file,
|
|
78
|
-
bytes,
|
|
79
|
-
chunkCount: Math.ceil(bytes.byteLength / chunkSizeBytes),
|
|
80
|
-
duplicates: [],
|
|
81
|
-
};
|
|
82
|
-
loaded.push(entry);
|
|
83
|
-
if (file.pointer.object_key) {
|
|
84
|
-
loadedByObjectKey.set(file.pointer.object_key, entry);
|
|
85
|
-
}
|
|
86
|
-
}
|
|
12
|
+
const loaded = await loadPlannedEntries(options, chunkSizeBytes, outcomes);
|
|
13
|
+
// PLAN -> CHUNK -> SEND -> COMMIT -> RE-KEY, one `begin` batch at a time.
|
|
87
14
|
const resolvedEntries = new Set();
|
|
88
15
|
let beginUnavailable = false;
|
|
89
16
|
for (const batch of batches(loaded, RAW_EVIDENCE_UPLOAD_MAX_OBJECTS_PER_BEGIN)) {
|
|
90
17
|
if (beginUnavailable)
|
|
91
18
|
break;
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
generated_at: options.generatedAt,
|
|
95
|
-
provenance: options.provenance,
|
|
96
|
-
objects: batch.map((entry) => ({
|
|
97
|
-
pointer: entry.file.pointer,
|
|
98
|
-
chunk_size_bytes: chunkSizeBytes,
|
|
99
|
-
chunk_count: entry.chunkCount,
|
|
100
|
-
})),
|
|
101
|
-
});
|
|
102
|
-
// 404 means an old dashboard without the chunk routes; a persistent 5xx
|
|
103
|
-
// (after retries) covers a new dashboard whose ledger migration has not
|
|
104
|
-
// been applied yet. Both still serve the legacy v1 route.
|
|
105
|
-
if (begin.status === 404 || begin.status >= 500) {
|
|
106
|
-
// Until BLI-3483 this downgrade abandoned the entire chunked path
|
|
107
|
-
// without a word, and the comment above named two causes that the fleet
|
|
108
|
-
// had no way to tell apart — an old dashboard versus an unapplied ledger
|
|
109
|
-
// migration. This is the BLI-2528 shape exactly: the code knew, the
|
|
110
|
-
// operator did not. Once per run, because `break` leaves the loop.
|
|
111
|
-
console.error("[evidence-upload] chunked upload unavailable; falling back to the legacy single-shot route", JSON.stringify({
|
|
112
|
-
reason: begin.status === 404
|
|
113
|
-
? "begin_route_absent"
|
|
114
|
-
: "begin_server_error_after_retries",
|
|
115
|
-
http_status: begin.status,
|
|
116
|
-
server_reason: safeFailureDetail(begin.body) ?? "none",
|
|
117
|
-
objects_in_batch: batch.length,
|
|
118
|
-
objects_unresolved: loaded.length - resolvedEntries.size,
|
|
119
|
-
}));
|
|
120
|
-
beginUnavailable = true;
|
|
19
|
+
beginUnavailable = await resolveBeginBatch(options, loaded.length, batch, chunkSizeBytes, outcomes, resolvedEntries);
|
|
20
|
+
if (beginUnavailable)
|
|
121
21
|
break;
|
|
122
|
-
}
|
|
123
|
-
if (!begin.ok) {
|
|
124
|
-
// The server's own `{ reason }` sat unread in this body while the ledger
|
|
125
|
-
// recorded the transport status and nothing else (BLI-3483); the commit
|
|
126
|
-
// path has read it since BLI-2528 and this one now does the same.
|
|
127
|
-
const beginDetail = safeFailureDetail(begin.body);
|
|
128
|
-
const beginReason = `begin_failed_http_${begin.status}${beginDetail ? `_${beginDetail}` : ""}`;
|
|
129
|
-
console.error("[evidence-upload] begin refused these objects", JSON.stringify({
|
|
130
|
-
reason: "begin_rejected",
|
|
131
|
-
http_status: begin.status,
|
|
132
|
-
server_reason: beginDetail ?? "none",
|
|
133
|
-
objects_in_batch: batch.length,
|
|
134
|
-
}));
|
|
135
|
-
for (const entry of batch) {
|
|
136
|
-
outcomes.push(failedOutcome(entry.file, beginReason));
|
|
137
|
-
for (const duplicate of entry.duplicates) {
|
|
138
|
-
outcomes.push(failedOutcome(duplicate, beginReason));
|
|
139
|
-
}
|
|
140
|
-
resolvedEntries.add(entry);
|
|
141
|
-
}
|
|
142
|
-
continue;
|
|
143
|
-
}
|
|
144
|
-
const parsedBegin = RawEvidenceUploadBeginResponseSchema.safeParse(begin.body);
|
|
145
|
-
if (!parsedBegin.success) {
|
|
146
|
-
for (const entry of batch) {
|
|
147
|
-
outcomes.push(failedOutcome(entry.file, "begin_invalid_response"));
|
|
148
|
-
for (const duplicate of entry.duplicates) {
|
|
149
|
-
outcomes.push(failedOutcome(duplicate, "begin_invalid_response"));
|
|
150
|
-
}
|
|
151
|
-
resolvedEntries.add(entry);
|
|
152
|
-
}
|
|
153
|
-
continue;
|
|
154
|
-
}
|
|
155
|
-
const dispositions = readBeginDispositions(parsedBegin.data);
|
|
156
|
-
if (!dispositions) {
|
|
157
|
-
for (const entry of batch) {
|
|
158
|
-
outcomes.push(failedOutcome(entry.file, "begin_invalid_response"));
|
|
159
|
-
for (const duplicate of entry.duplicates) {
|
|
160
|
-
outcomes.push(failedOutcome(duplicate, "begin_invalid_response"));
|
|
161
|
-
}
|
|
162
|
-
resolvedEntries.add(entry);
|
|
163
|
-
}
|
|
164
|
-
continue;
|
|
165
|
-
}
|
|
166
|
-
for (const entry of batch) {
|
|
167
|
-
const objectKey = entry.file.pointer.object_key ?? "";
|
|
168
|
-
const disposition = dispositions.get(objectKey);
|
|
169
|
-
if (!disposition) {
|
|
170
|
-
outcomes.push(failedOutcome(entry.file, "begin_missing_disposition"));
|
|
171
|
-
for (const duplicate of entry.duplicates) {
|
|
172
|
-
outcomes.push(failedOutcome(duplicate, "begin_missing_disposition"));
|
|
173
|
-
}
|
|
174
|
-
resolvedEntries.add(entry);
|
|
175
|
-
continue;
|
|
176
|
-
}
|
|
177
|
-
if (disposition.raw_evidence_pointer_id !==
|
|
178
|
-
entry.file.pointer.raw_evidence_pointer_id) {
|
|
179
|
-
outcomes.push(failedOutcome(entry.file, "begin_disposition_mismatch"));
|
|
180
|
-
for (const duplicate of entry.duplicates) {
|
|
181
|
-
outcomes.push(failedOutcome(duplicate, "begin_disposition_mismatch"));
|
|
182
|
-
}
|
|
183
|
-
resolvedEntries.add(entry);
|
|
184
|
-
continue;
|
|
185
|
-
}
|
|
186
|
-
// A conflict a different name can settle gets one, here, before the
|
|
187
|
-
// upload is attempted (BLI-3552). Anything else comes back unchanged and
|
|
188
|
-
// fails on its own reason inside `uploadOneObject`.
|
|
189
|
-
const resolved = await rekeyConflictedEntry(options, entry, disposition, chunkSizeBytes);
|
|
190
|
-
const outcome = await uploadOneObject(options, resolved.entry, resolved.disposition, chunkSizeBytes);
|
|
191
|
-
await reportAbandonedUpload(options, resolved.disposition, outcome);
|
|
192
|
-
outcomes.push(outcome);
|
|
193
|
-
// The duplicates of a re-keyed primary were re-keyed with it: a duplicate
|
|
194
|
-
// is byte-identical and shared the primary's key, so it shares the new
|
|
195
|
-
// one too. Leaving them on the old key would point their evidence refs at
|
|
196
|
-
// somebody else's durable object.
|
|
197
|
-
for (const duplicate of resolved.entry.duplicates) {
|
|
198
|
-
outcomes.push(duplicateOutcome(outcome, duplicate));
|
|
199
|
-
}
|
|
200
|
-
resolvedEntries.add(entry);
|
|
201
|
-
}
|
|
202
22
|
}
|
|
23
|
+
// TERMINAL: a dashboard with no chunk routes finishes on the legacy
|
|
24
|
+
// single-shot upload for everything the batch loop never resolved.
|
|
203
25
|
if (beginUnavailable) {
|
|
204
26
|
return uploadWithLegacyFallback(options, loaded.filter((entry) => !resolvedEntries.has(entry)), outcomes);
|
|
205
27
|
}
|
|
206
28
|
return summarizeOutcomes(outcomes, false);
|
|
207
29
|
}
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
* as a second upload would inflate object counts.
|
|
212
|
-
*/
|
|
213
|
-
function duplicateOutcome(primary, duplicate) {
|
|
214
|
-
return {
|
|
215
|
-
...primary,
|
|
216
|
-
pointer: pointerWithUploadedMetadata(duplicate.pointer, primary.pointer),
|
|
217
|
-
codex_session_id: duplicate.codex_session_id ?? null,
|
|
218
|
-
kind: duplicate.kind ?? "unknown",
|
|
219
|
-
artifact_metadata: duplicate.artifact_metadata,
|
|
220
|
-
upload_state: primary.upload_state === "uploaded"
|
|
221
|
-
? "reused_existing"
|
|
222
|
-
: primary.upload_state,
|
|
223
|
-
reason: primary.upload_state === "uploaded" ? "duplicate_in_batch" : primary.reason,
|
|
224
|
-
uploaded_chunk_count: 0,
|
|
225
|
-
};
|
|
226
|
-
}
|
|
227
|
-
/**
|
|
228
|
-
* Tell the server why we gave up on a row `begin` already opened (BLI-2539).
|
|
229
|
-
*
|
|
230
|
-
* `begin` opens a ledger row per object and the chunks then go one object at a
|
|
231
|
-
* time, so one object failing leaves its siblings untouched — the production
|
|
232
|
-
* shape is a lone failure among committed rows. This function is the only thing
|
|
233
|
-
* standing between that failure and a row that sits `pending` with a null
|
|
234
|
-
* reason until a drain relabels it `staging_incomplete` weeks later, which
|
|
235
|
-
* names the shape and not the cause. 275 rows reached that state by 2026-08-14.
|
|
236
|
-
*
|
|
237
|
-
* The server records the reason on the still-open row (for a reason classified
|
|
238
|
-
* permanent it fails the row closed), so reporting a give-up never costs the
|
|
239
|
-
* staged chunks the next sync resumes from.
|
|
240
|
-
*
|
|
241
|
-
* Deliberately best-effort: the upload has already failed and the caller's
|
|
242
|
-
* outcome is the answer that matters. Losing the abort as well leaves exactly
|
|
243
|
-
* the row we had before this existed, so a delivery failure must not throw —
|
|
244
|
-
* but it is named on stderr rather than swallowed, because a reason the server
|
|
245
|
-
* never received is invisible precisely where BLI-2539 needed it visible.
|
|
246
|
-
*/
|
|
247
|
-
async function reportAbandonedUpload(options, disposition, outcome) {
|
|
248
|
-
if (outcome.upload_state !== "upload_failed")
|
|
249
|
-
return;
|
|
250
|
-
if (!outcome.reason)
|
|
251
|
-
return;
|
|
252
|
-
// Only a row this call actually opened or resumed. `conflict` never allocated
|
|
253
|
-
// one for us, and `already_committed` names durable content an abort must not
|
|
254
|
-
// touch.
|
|
255
|
-
if (disposition.disposition !== "new" && disposition.disposition !== "resume") {
|
|
256
|
-
return;
|
|
257
|
-
}
|
|
258
|
-
if (!disposition.upload_id)
|
|
259
|
-
return;
|
|
260
|
-
const reason = conformReasonLabel(outcome.reason);
|
|
261
|
-
const response = await requestJson(options, "/api/ambient/evidence/upload/abort", {
|
|
262
|
-
schema_version: "ambient-raw-evidence-upload-abort.v1",
|
|
263
|
-
generated_at: options.generatedAt,
|
|
264
|
-
provenance: options.provenance,
|
|
265
|
-
upload_id: disposition.upload_id,
|
|
266
|
-
object_key: outcome.object_key,
|
|
267
|
-
reason,
|
|
268
|
-
uploaded_chunk_count: outcome.uploaded_chunk_count,
|
|
269
|
-
});
|
|
270
|
-
if (!response.ok) {
|
|
271
|
-
// status 404 is an old dashboard without the abort route; status 0 means
|
|
272
|
-
// the request itself never completed. Metadata only — ids, status, labels.
|
|
273
|
-
console.error("[evidence-abort] delivery failed; the server did not record the reason", JSON.stringify({
|
|
274
|
-
upload_id: disposition.upload_id,
|
|
275
|
-
reason,
|
|
276
|
-
http_status: response.status,
|
|
277
|
-
}));
|
|
278
|
-
}
|
|
279
|
-
}
|
|
280
|
-
/**
|
|
281
|
-
* Force a reason into the label shape the abort route accepts.
|
|
282
|
-
*
|
|
283
|
-
* Every reason this client composes already fits, and this exists so that stays
|
|
284
|
-
* true without anyone having to remember it. A label the server rejects comes
|
|
285
|
-
* back 400 and the reason is lost — which is the precise failure BLI-2539 is
|
|
286
|
-
* about, arriving through the code meant to fix it. A mangled label that lands
|
|
287
|
-
* beats a perfect one that does not.
|
|
288
|
-
*/
|
|
289
|
-
function conformReasonLabel(reason) {
|
|
290
|
-
const conformed = reason.replace(/[^a-z0-9_:.-]/gi, "_").slice(0, 120);
|
|
291
|
-
return conformed.length > 0 ? conformed : "upload_failed_unlabelled";
|
|
292
|
-
}
|
|
293
|
-
/**
|
|
294
|
-
* Settle a re-keyable conflict by asking for a second name (BLI-3552).
|
|
295
|
-
*
|
|
296
|
-
* `hash_mismatch_committed_object` means the key already holds different bytes
|
|
297
|
-
* that are already durable. Re-sending is the loop; deleting the durable object
|
|
298
|
-
* to make room is destroying evidence. The third answer is to offer this
|
|
299
|
-
* content under a name derived from its own hash, which is what this does, once
|
|
300
|
-
* per object per sync.
|
|
301
|
-
*
|
|
302
|
-
* Every path that cannot get there returns the ORIGINAL pair, so the object
|
|
303
|
-
* fails on the conflict reason `begin` actually gave rather than on a label
|
|
304
|
-
* invented here. Nothing is written off permanently either way: the upload
|
|
305
|
-
* cursor only remembers successes, so the next eligible sync offers the bytes
|
|
306
|
-
* again — at delivery-backoff cadence now instead of every 15 minutes.
|
|
307
|
-
*/
|
|
308
|
-
async function rekeyConflictedEntry(options, entry, disposition, chunkSizeBytes) {
|
|
309
|
-
if (disposition.disposition !== "conflict" ||
|
|
310
|
-
!isRekeyableUploadConflict(disposition.reason)) {
|
|
311
|
-
return { entry, disposition };
|
|
312
|
-
}
|
|
313
|
-
const contentHashPrefix = (entry.file.pointer.content_hash_sha256 ?? "none").slice(0, 12);
|
|
314
|
-
const rekeyedPointer = rekeyedEvidencePointer(entry.file.pointer);
|
|
315
|
-
if (!rekeyedPointer) {
|
|
316
|
-
// The key already names this content and the server still says it holds
|
|
317
|
-
// something else. A rename cannot answer that; a person has to.
|
|
318
|
-
console.error("[evidence-rekey] the key already carries this content hash; leaving the conflict for a person", JSON.stringify({
|
|
319
|
-
reason: disposition.reason,
|
|
320
|
-
kind: entry.file.kind ?? "unknown",
|
|
321
|
-
content_hash_prefix: contentHashPrefix,
|
|
322
|
-
byte_size: entry.bytes.byteLength,
|
|
323
|
-
}));
|
|
324
|
-
return { entry, disposition };
|
|
325
|
-
}
|
|
326
|
-
const rekeyedEntry = {
|
|
327
|
-
...entry,
|
|
328
|
-
file: { ...entry.file, pointer: rekeyedPointer },
|
|
329
|
-
duplicates: entry.duplicates.map((duplicate) => ({
|
|
330
|
-
...duplicate,
|
|
331
|
-
pointer: rekeyedEvidencePointer(duplicate.pointer) ?? duplicate.pointer,
|
|
332
|
-
})),
|
|
333
|
-
};
|
|
334
|
-
const rekeyedDisposition = await beginOneObject(options, rekeyedEntry, chunkSizeBytes);
|
|
335
|
-
if (!rekeyedDisposition) {
|
|
336
|
-
return { entry, disposition };
|
|
337
|
-
}
|
|
338
|
-
console.error("[evidence-rekey] committed object holds other content; offering these bytes under their own hash", JSON.stringify({
|
|
339
|
-
reason: disposition.reason,
|
|
340
|
-
kind: entry.file.kind ?? "unknown",
|
|
341
|
-
content_hash_prefix: contentHashPrefix,
|
|
342
|
-
byte_size: entry.bytes.byteLength,
|
|
343
|
-
chunk_count: entry.chunkCount,
|
|
344
|
-
rekeyed_disposition: rekeyedDisposition.disposition,
|
|
345
|
-
rekeyed_reason: rekeyedDisposition.reason ?? "none",
|
|
346
|
-
duplicate_count: entry.duplicates.length,
|
|
347
|
-
}));
|
|
348
|
-
return { entry: rekeyedEntry, disposition: rekeyedDisposition };
|
|
349
|
-
}
|
|
350
|
-
/**
|
|
351
|
-
* `begin` for a single object. Null whenever the answer cannot be trusted —
|
|
352
|
-
* transport failure, an unparseable body, a duplicate or missing key, or a
|
|
353
|
-
* pointer id that is not the one we sent — and the caller then keeps the
|
|
354
|
-
* original conflict rather than acting on a guess.
|
|
355
|
-
*/
|
|
356
|
-
async function beginOneObject(options, entry, chunkSizeBytes) {
|
|
357
|
-
const objectKey = entry.file.pointer.object_key ?? "";
|
|
358
|
-
const response = await requestJson(options, "/api/ambient/evidence/upload/begin", {
|
|
359
|
-
schema_version: "ambient-raw-evidence-upload-begin.v1",
|
|
360
|
-
generated_at: options.generatedAt,
|
|
361
|
-
provenance: options.provenance,
|
|
362
|
-
objects: [
|
|
363
|
-
{
|
|
364
|
-
pointer: entry.file.pointer,
|
|
365
|
-
chunk_size_bytes: chunkSizeBytes,
|
|
366
|
-
chunk_count: entry.chunkCount,
|
|
367
|
-
},
|
|
368
|
-
],
|
|
369
|
-
});
|
|
370
|
-
if (!response.ok) {
|
|
371
|
-
console.error("[evidence-rekey] begin refused the re-keyed object; keeping the original conflict", JSON.stringify({
|
|
372
|
-
reason: "rekey_begin_rejected",
|
|
373
|
-
http_status: response.status,
|
|
374
|
-
server_reason: safeFailureDetail(response.body) ?? "none",
|
|
375
|
-
kind: entry.file.kind ?? "unknown",
|
|
376
|
-
}));
|
|
377
|
-
return null;
|
|
378
|
-
}
|
|
379
|
-
const parsed = RawEvidenceUploadBeginResponseSchema.safeParse(response.body);
|
|
380
|
-
if (!parsed.success)
|
|
381
|
-
return null;
|
|
382
|
-
const dispositions = readBeginDispositions(parsed.data);
|
|
383
|
-
const disposition = dispositions?.get(objectKey);
|
|
384
|
-
if (!disposition)
|
|
385
|
-
return null;
|
|
386
|
-
if (disposition.raw_evidence_pointer_id !==
|
|
387
|
-
entry.file.pointer.raw_evidence_pointer_id) {
|
|
388
|
-
return null;
|
|
389
|
-
}
|
|
390
|
-
return disposition;
|
|
391
|
-
}
|
|
392
|
-
async function uploadOneObject(options, entry, disposition, chunkSizeBytes) {
|
|
393
|
-
const objectKey = entry.file.pointer.object_key ?? "";
|
|
394
|
-
if (disposition.disposition === "already_committed") {
|
|
395
|
-
if (!disposition.upload_id) {
|
|
396
|
-
return failedOutcome(entry.file, "begin_committed_receipt_unavailable");
|
|
397
|
-
}
|
|
398
|
-
}
|
|
399
|
-
if (disposition.disposition === "conflict" || !disposition.upload_id) {
|
|
400
|
-
return failedOutcome(entry.file, disposition.reason ?? "upload_conflict");
|
|
401
|
-
}
|
|
402
|
-
const received = new Set(disposition.received_chunk_indexes);
|
|
403
|
-
let uploadedChunks = 0;
|
|
404
|
-
if (disposition.disposition !== "already_committed" &&
|
|
405
|
-
!disposition.commit_ready) {
|
|
406
|
-
for (let index = 0; index < entry.chunkCount; index += 1) {
|
|
407
|
-
if (received.has(index))
|
|
408
|
-
continue;
|
|
409
|
-
const chunk = entry.bytes.subarray(index * chunkSizeBytes, Math.min((index + 1) * chunkSizeBytes, entry.bytes.byteLength));
|
|
410
|
-
const chunkResponse = await requestJson(options, "/api/ambient/evidence/upload/chunk", {
|
|
411
|
-
schema_version: "ambient-raw-evidence-upload-chunk.v1",
|
|
412
|
-
generated_at: options.generatedAt,
|
|
413
|
-
provenance: options.provenance,
|
|
414
|
-
upload_id: disposition.upload_id,
|
|
415
|
-
object_key: objectKey,
|
|
416
|
-
chunk_index: index,
|
|
417
|
-
chunk_count: entry.chunkCount,
|
|
418
|
-
chunk_hash_sha256: sha256(chunk),
|
|
419
|
-
content_base64: chunk.toString("base64"),
|
|
420
|
-
});
|
|
421
|
-
if (!chunkResponse.ok) {
|
|
422
|
-
// Same treatment the commit path has had since BLI-2528: the server's
|
|
423
|
-
// own reason rides on the label, so `chunk_3_failed_http_413` becomes
|
|
424
|
-
// `chunk_3_failed_http_413_object_too_large` and the ledger row names
|
|
425
|
-
// the cause instead of the transport (BLI-3483).
|
|
426
|
-
const chunkDetail = safeFailureDetail(chunkResponse.body);
|
|
427
|
-
console.error("[evidence-upload] chunk rejected", JSON.stringify({
|
|
428
|
-
reason: "chunk_rejected",
|
|
429
|
-
upload_id: disposition.upload_id,
|
|
430
|
-
http_status: chunkResponse.status,
|
|
431
|
-
server_reason: chunkDetail ?? "none",
|
|
432
|
-
chunk_index: index,
|
|
433
|
-
chunk_count: entry.chunkCount,
|
|
434
|
-
uploaded_chunk_count: uploadedChunks,
|
|
435
|
-
}));
|
|
436
|
-
return failedOutcome(entry.file, `chunk_${index}_failed_http_${chunkResponse.status}${chunkDetail ? `_${chunkDetail}` : ""}`, uploadedChunks);
|
|
437
|
-
}
|
|
438
|
-
uploadedChunks += 1;
|
|
439
|
-
}
|
|
440
|
-
}
|
|
441
|
-
const commit = await requestJson(options, "/api/ambient/evidence/upload/commit", {
|
|
442
|
-
schema_version: "ambient-raw-evidence-upload-commit.v1",
|
|
443
|
-
generated_at: options.generatedAt,
|
|
444
|
-
provenance: options.provenance,
|
|
445
|
-
upload_id: disposition.upload_id,
|
|
446
|
-
object_key: objectKey,
|
|
447
|
-
});
|
|
448
|
-
if (!commit.ok) {
|
|
449
|
-
// The server can tell us this object will be refused again. Take its reason
|
|
450
|
-
// verbatim so the label names the cause ("storage rejected 116 MB") instead
|
|
451
|
-
// of the transport ("commit_failed_http_500"), which is all the fleet could
|
|
452
|
-
// say for the 57 days of BLI-2528.
|
|
453
|
-
const permanent = permanentCommitRejection(commit.body);
|
|
454
|
-
if (permanent) {
|
|
455
|
-
return failedOutcome(entry.file, permanent, uploadedChunks);
|
|
456
|
-
}
|
|
457
|
-
// A 5xx whose body is not JSON did not come from the route. The commit
|
|
458
|
-
// handler always answers `{ code, message, ... }`; an HTML error page means
|
|
459
|
-
// the serverless process was killed — an out-of-memory on a large
|
|
460
|
-
// assembly, or a hard timeout — so nothing server-side ran a catch, wrote a
|
|
461
|
-
// ledger reason, or logged a line. Naming it separately is the only way an
|
|
462
|
-
// operator reading upload reasons can tell "the server refused these bytes"
|
|
463
|
-
// from "the server never survived them".
|
|
464
|
-
//
|
|
465
|
-
// The stem comes from telemetry-core so the label the collector writes and
|
|
466
|
-
// the label `classifyUploadFailure` reads are the same string by
|
|
467
|
-
// construction; the status rides on the end so a 502 gateway timeout can
|
|
468
|
-
// still be told from a 500 process kill, and core strips it back off.
|
|
469
|
-
if (commit.status >= 500 && isNonJsonResponseBody(commit.body)) {
|
|
470
|
-
console.error("[evidence-commit] the server died before it could answer; the object is still pending", JSON.stringify({
|
|
471
|
-
upload_id: disposition.upload_id,
|
|
472
|
-
http_status: commit.status,
|
|
473
|
-
byte_size: entry.bytes.byteLength,
|
|
474
|
-
chunk_count: entry.chunkCount,
|
|
475
|
-
uploaded_chunk_count: uploadedChunks,
|
|
476
|
-
reason: COMMIT_CRASHED_PLATFORM,
|
|
477
|
-
}));
|
|
478
|
-
return failedOutcome(entry.file, `${COMMIT_CRASHED_PLATFORM}_http_${commit.status}`, uploadedChunks);
|
|
479
|
-
}
|
|
480
|
-
const detail = safeFailureDetail(commit.body);
|
|
481
|
-
return failedOutcome(entry.file, `commit_failed_http_${commit.status}${detail ? `_${detail}` : ""}`, uploadedChunks);
|
|
482
|
-
}
|
|
483
|
-
// "already_committed" means a concurrent or earlier sync made these bytes
|
|
484
|
-
// durable; this sync does not own them, so they must be reported as reuse —
|
|
485
|
-
// a later ingest failure here must not clean up an object another sync's
|
|
486
|
-
// indexed refs already point at.
|
|
487
|
-
const parsedCommit = RawEvidenceUploadCommitResponseSchema.safeParse(commit.body);
|
|
488
|
-
if (!parsedCommit.success) {
|
|
489
|
-
return failedOutcome(entry.file, "commit_invalid_response", uploadedChunks);
|
|
490
|
-
}
|
|
491
|
-
if (!chunkCommitReceiptMatchesPointer(entry.file.pointer, parsedCommit.data)) {
|
|
492
|
-
return failedOutcome(entry.file, "commit_receipt_mismatch", uploadedChunks);
|
|
493
|
-
}
|
|
494
|
-
const committedPointer = entry.file.pointer;
|
|
495
|
-
if (parsedCommit.data.status === "already_committed") {
|
|
496
|
-
return {
|
|
497
|
-
pointer: committedPointer,
|
|
498
|
-
object_key: objectKey,
|
|
499
|
-
codex_session_id: entry.file.codex_session_id ?? null,
|
|
500
|
-
kind: entry.file.kind ?? "unknown",
|
|
501
|
-
artifact_metadata: entry.file.artifact_metadata,
|
|
502
|
-
upload_state: "reused_existing",
|
|
503
|
-
reason: "already_committed",
|
|
504
|
-
uploaded_chunk_count: uploadedChunks,
|
|
505
|
-
};
|
|
506
|
-
}
|
|
507
|
-
return {
|
|
508
|
-
pointer: committedPointer,
|
|
509
|
-
object_key: objectKey,
|
|
510
|
-
codex_session_id: entry.file.codex_session_id ?? null,
|
|
511
|
-
kind: entry.file.kind ?? "unknown",
|
|
512
|
-
artifact_metadata: entry.file.artifact_metadata,
|
|
513
|
-
upload_state: "uploaded",
|
|
514
|
-
reason: null,
|
|
515
|
-
uploaded_chunk_count: uploadedChunks,
|
|
516
|
-
};
|
|
517
|
-
}
|
|
518
|
-
/**
|
|
519
|
-
* Older dashboards predate the chunk endpoints. Fall back to one legacy
|
|
520
|
-
* request per file so an oversized or already-existing object fails (or
|
|
521
|
-
* reuses) individually instead of dragging the whole batch down. Large files
|
|
522
|
-
* are attempted rather than pre-failed: a non-serverless dashboard (the
|
|
523
|
-
* default localhost deployment) accepts them like the old CLI could, and a
|
|
524
|
-
* platform body-limit rejection comes back as a labeled per-file failure.
|
|
525
|
-
*/
|
|
526
|
-
async function uploadWithLegacyFallback(options, loaded, outcomes) {
|
|
527
|
-
for (const entry of loaded) {
|
|
528
|
-
let outcome;
|
|
529
|
-
const response = await requestJson(options, "/api/ambient/evidence/upload", {
|
|
530
|
-
schema_version: "ambient-raw-evidence-upload.v1",
|
|
531
|
-
generated_at: options.generatedAt,
|
|
532
|
-
provenance: options.provenance,
|
|
533
|
-
files: [
|
|
534
|
-
{
|
|
535
|
-
pointer: entry.file.pointer,
|
|
536
|
-
content_base64: entry.bytes.toString("base64"),
|
|
537
|
-
},
|
|
538
|
-
],
|
|
539
|
-
});
|
|
540
|
-
if (response.ok) {
|
|
541
|
-
const parsedLegacy = RawEvidenceLegacyUploadResponseSchema.safeParse(response.body);
|
|
542
|
-
if (!parsedLegacy.success) {
|
|
543
|
-
outcome = failedOutcome(entry.file, "legacy_upload_invalid_response");
|
|
544
|
-
}
|
|
545
|
-
else {
|
|
546
|
-
const receipt = parsedLegacy.data.uploaded.find((candidate) => candidate.raw_evidence_pointer_id ===
|
|
547
|
-
entry.file.pointer.raw_evidence_pointer_id);
|
|
548
|
-
if (!receipt ||
|
|
549
|
-
parsedLegacy.data.bucket !== entry.file.pointer.storage_bucket ||
|
|
550
|
-
!legacyUploadReceiptMatchesPointer(entry.file.pointer, receipt)) {
|
|
551
|
-
outcome = failedOutcome(entry.file, "legacy_upload_receipt_mismatch");
|
|
552
|
-
}
|
|
553
|
-
else {
|
|
554
|
-
const pointer = pointerWithLegacyUploadResponse(entry.file.pointer, parsedLegacy.data);
|
|
555
|
-
outcome = {
|
|
556
|
-
pointer,
|
|
557
|
-
object_key: entry.file.pointer.object_key ?? "",
|
|
558
|
-
codex_session_id: entry.file.codex_session_id ?? null,
|
|
559
|
-
kind: entry.file.kind ?? "unknown",
|
|
560
|
-
artifact_metadata: entry.file.artifact_metadata,
|
|
561
|
-
upload_state: "uploaded",
|
|
562
|
-
reason: "legacy_single_shot_upload",
|
|
563
|
-
uploaded_chunk_count: 1,
|
|
564
|
-
};
|
|
565
|
-
}
|
|
566
|
-
}
|
|
567
|
-
}
|
|
568
|
-
else if (response.status === 409) {
|
|
569
|
-
// The pre-ledger route cannot prove that a conflicting object contains
|
|
570
|
-
// these exact bytes. Fail closed instead of seeding the cursor with an
|
|
571
|
-
// unverified pointer.
|
|
572
|
-
outcome = failedOutcome(entry.file, "legacy_upload_conflict_http_409");
|
|
573
|
-
}
|
|
574
|
-
else {
|
|
575
|
-
outcome = failedOutcome(entry.file, `legacy_upload_failed_http_${response.status}`);
|
|
576
|
-
}
|
|
577
|
-
outcomes.push(outcome);
|
|
578
|
-
for (const duplicate of entry.duplicates) {
|
|
579
|
-
outcomes.push(duplicateOutcome(outcome, duplicate));
|
|
580
|
-
}
|
|
581
|
-
}
|
|
582
|
-
return summarizeOutcomes(outcomes, true);
|
|
583
|
-
}
|
|
584
|
-
async function requestJson(options, routePath, body) {
|
|
585
|
-
const maxAttempts = options.maxAttemptsPerRequest ?? DEFAULT_MAX_ATTEMPTS;
|
|
586
|
-
const sleep = options.sleep ?? defaultSleep;
|
|
587
|
-
let lastStatus = 0;
|
|
588
|
-
let lastBody = null;
|
|
589
|
-
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
|
590
|
-
try {
|
|
591
|
-
const response = await options.fetchImpl(`${options.dashboardUrl}${routePath}`, {
|
|
592
|
-
method: "POST",
|
|
593
|
-
headers: {
|
|
594
|
-
"Authorization": `Bearer ${options.deviceToken}`,
|
|
595
|
-
"Content-Type": "application/json",
|
|
596
|
-
},
|
|
597
|
-
body: JSON.stringify(body),
|
|
598
|
-
});
|
|
599
|
-
lastStatus = response.status;
|
|
600
|
-
lastBody = await readResponseJson(response);
|
|
601
|
-
if (response.ok || (response.status < 500 && response.status !== 429)) {
|
|
602
|
-
return { ok: response.ok, status: response.status, body: lastBody };
|
|
603
|
-
}
|
|
604
|
-
}
|
|
605
|
-
catch (error) {
|
|
606
|
-
// `status: 0` is this function's word for "no answer came back", and it
|
|
607
|
-
// travels all the way to the upload ledger without ever saying whether
|
|
608
|
-
// the request left the machine. This is the exact silence BLI-2528 sat
|
|
609
|
-
// behind for 57 days; the route is named, the body never is.
|
|
610
|
-
console.error("[evidence-upload] request failed before a status came back", JSON.stringify({
|
|
611
|
-
reason: "upload_request_transport_error",
|
|
612
|
-
route: routePath,
|
|
613
|
-
attempt,
|
|
614
|
-
max_attempts: maxAttempts,
|
|
615
|
-
...describeError(error),
|
|
616
|
-
}));
|
|
617
|
-
lastStatus = 0;
|
|
618
|
-
lastBody = null;
|
|
619
|
-
}
|
|
620
|
-
if (attempt < maxAttempts)
|
|
621
|
-
await sleep(RETRY_DELAY_MS * attempt);
|
|
622
|
-
}
|
|
623
|
-
return { ok: false, status: lastStatus, body: lastBody };
|
|
624
|
-
}
|
|
625
|
-
function readBeginDispositions(body) {
|
|
626
|
-
const map = new Map();
|
|
627
|
-
for (const entry of body.objects) {
|
|
628
|
-
if (map.has(entry.object_key))
|
|
629
|
-
return null;
|
|
630
|
-
map.set(entry.object_key, {
|
|
631
|
-
disposition: entry.disposition,
|
|
632
|
-
upload_id: entry.upload_id ?? null,
|
|
633
|
-
received_chunk_indexes: entry.received_chunk_indexes,
|
|
634
|
-
commit_ready: entry.commit_ready === true,
|
|
635
|
-
reason: entry.reason ?? null,
|
|
636
|
-
raw_evidence_pointer_id: entry.raw_evidence_pointer_id,
|
|
637
|
-
});
|
|
638
|
-
}
|
|
639
|
-
return map;
|
|
640
|
-
}
|
|
641
|
-
function failedOutcome(file, reason, uploadedChunks = 0) {
|
|
642
|
-
return {
|
|
643
|
-
pointer: file.pointer,
|
|
644
|
-
object_key: file.pointer.object_key ?? "",
|
|
645
|
-
codex_session_id: file.codex_session_id ?? null,
|
|
646
|
-
kind: file.kind ?? "unknown",
|
|
647
|
-
artifact_metadata: file.artifact_metadata,
|
|
648
|
-
upload_state: "upload_failed",
|
|
649
|
-
reason,
|
|
650
|
-
uploaded_chunk_count: uploadedChunks,
|
|
651
|
-
};
|
|
652
|
-
}
|
|
653
|
-
/**
|
|
654
|
-
* The server's verdict that repeating this commit is pointless, or null.
|
|
655
|
-
*
|
|
656
|
-
* Two conditions, both required: the response says `retryable: false`, and the
|
|
657
|
-
* reason is one this collector has classified as permanent. The second check is
|
|
658
|
-
* the important one. A newer dashboard could declare a reason this CLI has never
|
|
659
|
-
* heard of, and quietly abandoning an object on a word we cannot interpret is
|
|
660
|
-
* exactly the silent drop the fleet contract forbids — so an unclassified reason
|
|
661
|
-
* falls through to the ordinary retry path and stays visible.
|
|
662
|
-
*/
|
|
663
|
-
function permanentCommitRejection(body) {
|
|
664
|
-
if (!body || typeof body !== "object")
|
|
665
|
-
return null;
|
|
666
|
-
if (body.retryable !== false)
|
|
667
|
-
return null;
|
|
668
|
-
const reason = safeFailureDetail(body);
|
|
669
|
-
return reason && isPermanentUploadFailure(reason) ? reason : null;
|
|
670
|
-
}
|
|
671
|
-
function safeFailureDetail(body) {
|
|
672
|
-
if (!body || typeof body !== "object")
|
|
673
|
-
return null;
|
|
674
|
-
const reason = body.reason;
|
|
675
|
-
return typeof reason === "string" && /^[a-z0-9_:-]{1,80}$/i.test(reason)
|
|
676
|
-
? reason
|
|
677
|
-
: null;
|
|
678
|
-
}
|
|
679
|
-
function pointerWithLegacyUploadResponse(pointer, body) {
|
|
680
|
-
const entry = body.uploaded.find((candidate) => candidate.raw_evidence_pointer_id === pointer.raw_evidence_pointer_id);
|
|
681
|
-
if (!entry)
|
|
682
|
-
return pointer;
|
|
683
|
-
return pointerWithUploadedMetadata(pointer, {
|
|
684
|
-
content_hash_sha256: entry.content_hash_sha256,
|
|
685
|
-
byte_size: entry.byte_size,
|
|
686
|
-
redaction: entry.redaction,
|
|
687
|
-
});
|
|
688
|
-
}
|
|
689
|
-
function chunkCommitReceiptMatchesPointer(pointer, receipt) {
|
|
690
|
-
return (receipt.object_key === pointer.object_key &&
|
|
691
|
-
receipt.content_hash_sha256 === pointer.content_hash_sha256 &&
|
|
692
|
-
receipt.byte_size === pointer.byte_size &&
|
|
693
|
-
receipt.redaction === undefined);
|
|
694
|
-
}
|
|
695
|
-
function legacyUploadReceiptMatchesPointer(pointer, receipt) {
|
|
696
|
-
if (receipt.object_key !== pointer.object_key)
|
|
697
|
-
return false;
|
|
698
|
-
if (receipt.redaction) {
|
|
699
|
-
return (receipt.redaction.original_content_hash_sha256 ===
|
|
700
|
-
pointer.content_hash_sha256 &&
|
|
701
|
-
receipt.redaction.original_byte_size === pointer.byte_size &&
|
|
702
|
-
receipt.redaction.sanitized_content_hash_sha256 ===
|
|
703
|
-
receipt.content_hash_sha256 &&
|
|
704
|
-
receipt.redaction.sanitized_byte_size === receipt.byte_size);
|
|
705
|
-
}
|
|
706
|
-
return (receipt.content_hash_sha256 === pointer.content_hash_sha256 &&
|
|
707
|
-
receipt.byte_size === pointer.byte_size);
|
|
708
|
-
}
|
|
709
|
-
function pointerWithUploadedMetadata(pointer, metadata) {
|
|
710
|
-
const redaction = RawEvidenceRedactionMetadataSchema.safeParse(metadata.redaction);
|
|
711
|
-
return {
|
|
712
|
-
...pointer,
|
|
713
|
-
content_hash_sha256: typeof metadata.content_hash_sha256 === "string"
|
|
714
|
-
? metadata.content_hash_sha256
|
|
715
|
-
: pointer.content_hash_sha256,
|
|
716
|
-
byte_size: typeof metadata.byte_size === "number" ? metadata.byte_size : pointer.byte_size,
|
|
717
|
-
redaction: redaction.success ? redaction.data : pointer.redaction,
|
|
718
|
-
};
|
|
719
|
-
}
|
|
720
|
-
function summarizeOutcomes(outcomes, usedLegacyFallback) {
|
|
721
|
-
const uploaded = outcomes.filter((outcome) => outcome.upload_state === "uploaded");
|
|
722
|
-
return {
|
|
723
|
-
outcomes,
|
|
724
|
-
uploaded_object_keys: uploaded.map((outcome) => outcome.object_key),
|
|
725
|
-
uploaded_object_count: uploaded.length,
|
|
726
|
-
uploaded_chunk_count: outcomes.reduce((sum, outcome) => sum + outcome.uploaded_chunk_count, 0),
|
|
727
|
-
reused_count: outcomes.filter((outcome) => outcome.upload_state === "reused_existing").length,
|
|
728
|
-
failed_count: outcomes.filter((outcome) => outcome.upload_state === "upload_failed").length,
|
|
729
|
-
used_legacy_fallback: usedLegacyFallback,
|
|
730
|
-
};
|
|
731
|
-
}
|
|
732
|
-
/**
|
|
733
|
-
* Marks a body the server did not produce as JSON.
|
|
734
|
-
*
|
|
735
|
-
* A dashboard route always answers with a JSON envelope, so an HTML body on a
|
|
736
|
-
* 500 means the response came from the platform's error page and not from the
|
|
737
|
-
* route — the process died before any handler ran. That distinction is the
|
|
738
|
-
* whole difference between "the commit rejected these bytes" and "the commit
|
|
739
|
-
* never got to decide", and it was invisible for the nine days of BLI-3067
|
|
740
|
-
* because both collapsed into `commit_failed_http_500`.
|
|
741
|
-
*/
|
|
742
|
-
export const NON_JSON_RESPONSE_BODY_KEY = "__cockpit_response_body_format";
|
|
743
|
-
function isNonJsonResponseBody(body) {
|
|
744
|
-
return (!!body &&
|
|
745
|
-
typeof body === "object" &&
|
|
746
|
-
body[NON_JSON_RESPONSE_BODY_KEY] === "non_json");
|
|
747
|
-
}
|
|
748
|
-
async function readResponseJson(response) {
|
|
749
|
-
const text = await response.text();
|
|
750
|
-
if (!text)
|
|
751
|
-
return {};
|
|
752
|
-
try {
|
|
753
|
-
return JSON.parse(text);
|
|
754
|
-
}
|
|
755
|
-
catch {
|
|
756
|
-
// The raw text is kept for the caller that wants to show it, never for a
|
|
757
|
-
// label or a log — it is an unbounded HTML page. Its SHAPE is safe and is
|
|
758
|
-
// the part that matters: a non-JSON body means something in front of the
|
|
759
|
-
// dashboard answered instead of it (BLI-3067, BLI-3238).
|
|
760
|
-
console.error("[evidence-upload] reply was not JSON", JSON.stringify({
|
|
761
|
-
reason: "response_body_not_json",
|
|
762
|
-
http_status: response.status,
|
|
763
|
-
byte_size: text.length,
|
|
764
|
-
content_type: response.headers.get("content-type") ?? "none",
|
|
765
|
-
}));
|
|
766
|
-
return { message: text, [NON_JSON_RESPONSE_BODY_KEY]: "non_json" };
|
|
767
|
-
}
|
|
768
|
-
}
|
|
769
|
-
function batches(items, size) {
|
|
770
|
-
const out = [];
|
|
771
|
-
for (let offset = 0; offset < items.length; offset += size) {
|
|
772
|
-
out.push(items.slice(offset, offset + size));
|
|
773
|
-
}
|
|
774
|
-
return out;
|
|
775
|
-
}
|
|
776
|
-
function defaultSleep(milliseconds) {
|
|
777
|
-
return new Promise((resolve) => setTimeout(resolve, milliseconds));
|
|
778
|
-
}
|
|
779
|
-
function sha256(value) {
|
|
780
|
-
return crypto.createHash("sha256").update(value).digest("hex");
|
|
781
|
-
}
|
|
30
|
+
// Re-exported so every consumer keeps importing from `./evidence-upload-
|
|
31
|
+
// client.js` regardless of which sibling a symbol now lives in.
|
|
32
|
+
export { NON_JSON_RESPONSE_BODY_KEY };
|