@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.
Files changed (60) hide show
  1. package/dist/adapters/attribution-core-fallbacks.js +247 -0
  2. package/dist/adapters/attribution-core-paths.js +182 -0
  3. package/dist/adapters/attribution-core-score.js +159 -0
  4. package/dist/adapters/attribution-core-types.js +13 -0
  5. package/dist/adapters/attribution-core.js +13 -565
  6. package/dist/adapters/claude-attribution-discovery.js +186 -0
  7. package/dist/adapters/claude-attribution-score.js +204 -0
  8. package/dist/adapters/claude-attribution-signals.js +180 -0
  9. package/dist/adapters/claude-attribution-types.js +25 -0
  10. package/dist/adapters/claude-attribution.js +14 -569
  11. package/dist/commands/doctor-access.js +129 -0
  12. package/dist/commands/doctor-pipeline.js +326 -0
  13. package/dist/commands/doctor-registration.js +105 -0
  14. package/dist/commands/doctor-report.js +111 -0
  15. package/dist/commands/doctor-update.js +120 -0
  16. package/dist/commands/doctor.js +8 -753
  17. package/dist/commands/heartbeat.js +8 -0
  18. package/dist/commands/jarvis-contracts.js +8 -0
  19. package/dist/commands/jarvis-render.js +413 -0
  20. package/dist/commands/jarvis-turn.js +305 -0
  21. package/dist/commands/jarvis.js +23 -698
  22. package/dist/commands/local-args-collector-setup.js +250 -0
  23. package/dist/commands/local-args-collector-status.js +227 -0
  24. package/dist/commands/local-args-collector-work.js +175 -0
  25. package/dist/commands/local-args-collector.js +19 -624
  26. package/dist/commands/local-args-tower-admin.js +456 -0
  27. package/dist/commands/local-args-tower-chat.js +194 -0
  28. package/dist/commands/local-args-tower-pages.js +314 -0
  29. package/dist/commands/local-args-tower.js +13 -880
  30. package/dist/commands/local-help.js +10 -2
  31. package/dist/commands/onboard-completion.js +136 -0
  32. package/dist/commands/onboard-flows.js +165 -0
  33. package/dist/commands/onboard-setup.js +102 -0
  34. package/dist/commands/onboard.js +5 -392
  35. package/dist/commands/public-root.js +1 -1
  36. package/dist/commands/session-sync-counters.js +55 -0
  37. package/dist/commands/session-sync-health.js +8 -1
  38. package/dist/commands/session-sync-plan.js +47 -7
  39. package/dist/commands/session-sync-scan.js +4 -4
  40. package/dist/commands/session-sync.js +6 -0
  41. package/dist/commands/settings-render.js +27 -0
  42. package/dist/commands/sync-followups.js +5 -1
  43. package/dist/commands/sync.js +5 -1
  44. package/dist/commands/team-device-reasons.js +16 -0
  45. package/dist/commands/team.js +87 -7
  46. package/dist/evidence-upload-client.js +14 -763
  47. package/dist/evidence-upload-object.js +181 -0
  48. package/dist/evidence-upload-plan.js +233 -0
  49. package/dist/evidence-upload-terminal.js +309 -0
  50. package/dist/evidence-upload-transport.js +104 -0
  51. package/dist/spool/local-spool-io.js +122 -0
  52. package/dist/spool/local-spool-mutations.js +174 -0
  53. package/dist/spool/local-spool-parse.js +143 -0
  54. package/dist/spool/local-spool-types.js +22 -0
  55. package/dist/spool/local-spool.js +20 -426
  56. package/dist/upload-evidence-delivery-offer.js +144 -0
  57. package/dist/upload-evidence-delivery-reconcile.js +134 -0
  58. package/dist/upload-evidence-delivery-summary.js +205 -0
  59. package/dist/upload-evidence-delivery.js +12 -482
  60. package/package.json +3 -3
@@ -1,781 +1,32 @@
1
- import { COMMIT_CRASHED_PLATFORM, RAW_EVIDENCE_UPLOAD_DEFAULT_CHUNK_BYTES, RAW_EVIDENCE_UPLOAD_MAX_FILE_BYTES, RAW_EVIDENCE_UPLOAD_MAX_OBJECTS_PER_BEGIN, RawEvidenceLegacyUploadResponseSchema, RawEvidenceRedactionMetadataSchema, RawEvidenceUploadBeginResponseSchema, RawEvidenceUploadCommitResponseSchema, isPermanentUploadFailure, isRekeyableUploadConflict, } from "@bli-cockpit/telemetry-core";
2
- import crypto from "node:crypto";
3
- import fs from "node:fs/promises";
4
- import { rekeyedEvidencePointer } from "./evidence-upload-rekey.js";
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
- const loadedByObjectKey = new Map();
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
- const begin = await requestJson(options, "/api/ambient/evidence/upload/begin", {
93
- schema_version: "ambient-raw-evidence-upload-begin.v1",
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
- * A duplicate file shares its primary's fate, but a primary that physically
210
- * uploaded leaves the duplicate as reuse of now-durable content — counting it
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 };