@bli-cockpit/cli 0.2.54 → 0.2.56
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
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
import { RawEvidenceLegacyUploadResponseSchema, RawEvidenceRedactionMetadataSchema, RawEvidenceUploadBeginResponseSchema, isRekeyableUploadConflict, } from "@bli-cockpit/telemetry-core";
|
|
2
|
+
import { readBeginDispositions } from "./evidence-upload-plan.js";
|
|
3
|
+
import { failedOutcome } from "./evidence-upload-object.js";
|
|
4
|
+
import { rekeyedEvidencePointer } from "./evidence-upload-rekey.js";
|
|
5
|
+
import { requestJson, safeFailureDetail } from "./evidence-upload-transport.js";
|
|
6
|
+
/**
|
|
7
|
+
* Re-key and terminal outcomes: settle a re-keyable `begin` conflict
|
|
8
|
+
* (BLI-3552), tell the server about a delivery this run gave up on
|
|
9
|
+
* (BLI-2539), fold a duplicate's fate into its primary's, run the legacy
|
|
10
|
+
* single-shot fallback for a dashboard without the chunk routes, and
|
|
11
|
+
* summarize a run's outcomes into the shape every caller reads.
|
|
12
|
+
*/
|
|
13
|
+
/**
|
|
14
|
+
* Settle a re-keyable conflict by asking for a second name (BLI-3552).
|
|
15
|
+
*
|
|
16
|
+
* `hash_mismatch_committed_object` means the key already holds different bytes
|
|
17
|
+
* that are already durable. Re-sending is the loop; deleting the durable object
|
|
18
|
+
* to make room is destroying evidence. The third answer is to offer this
|
|
19
|
+
* content under a name derived from its own hash, which is what this does, once
|
|
20
|
+
* per object per sync.
|
|
21
|
+
*
|
|
22
|
+
* Every path that cannot get there returns the ORIGINAL pair, so the object
|
|
23
|
+
* fails on the conflict reason `begin` actually gave rather than on a label
|
|
24
|
+
* invented here. Nothing is written off permanently either way: the upload
|
|
25
|
+
* cursor only remembers successes, so the next eligible sync offers the bytes
|
|
26
|
+
* again — at delivery-backoff cadence now instead of every 15 minutes.
|
|
27
|
+
*/
|
|
28
|
+
export async function rekeyConflictedEntry(options, entry, disposition, chunkSizeBytes) {
|
|
29
|
+
if (disposition.disposition !== "conflict" ||
|
|
30
|
+
!isRekeyableUploadConflict(disposition.reason)) {
|
|
31
|
+
return { entry, disposition };
|
|
32
|
+
}
|
|
33
|
+
const contentHashPrefix = (entry.file.pointer.content_hash_sha256 ?? "none").slice(0, 12);
|
|
34
|
+
const rekeyedPointer = rekeyedEvidencePointer(entry.file.pointer);
|
|
35
|
+
if (!rekeyedPointer) {
|
|
36
|
+
// The key already names this content and the server still says it holds
|
|
37
|
+
// something else. A rename cannot answer that; a person has to.
|
|
38
|
+
console.error("[evidence-rekey] the key already carries this content hash; leaving the conflict for a person", JSON.stringify({
|
|
39
|
+
reason: disposition.reason,
|
|
40
|
+
kind: entry.file.kind ?? "unknown",
|
|
41
|
+
content_hash_prefix: contentHashPrefix,
|
|
42
|
+
byte_size: entry.bytes.byteLength,
|
|
43
|
+
}));
|
|
44
|
+
return { entry, disposition };
|
|
45
|
+
}
|
|
46
|
+
const rekeyedEntry = {
|
|
47
|
+
...entry,
|
|
48
|
+
file: { ...entry.file, pointer: rekeyedPointer },
|
|
49
|
+
duplicates: entry.duplicates.map((duplicate) => ({
|
|
50
|
+
...duplicate,
|
|
51
|
+
pointer: rekeyedEvidencePointer(duplicate.pointer) ?? duplicate.pointer,
|
|
52
|
+
})),
|
|
53
|
+
};
|
|
54
|
+
const rekeyedDisposition = await beginOneObject(options, rekeyedEntry, chunkSizeBytes);
|
|
55
|
+
if (!rekeyedDisposition) {
|
|
56
|
+
return { entry, disposition };
|
|
57
|
+
}
|
|
58
|
+
console.error("[evidence-rekey] committed object holds other content; offering these bytes under their own hash", JSON.stringify({
|
|
59
|
+
reason: disposition.reason,
|
|
60
|
+
kind: entry.file.kind ?? "unknown",
|
|
61
|
+
content_hash_prefix: contentHashPrefix,
|
|
62
|
+
byte_size: entry.bytes.byteLength,
|
|
63
|
+
chunk_count: entry.chunkCount,
|
|
64
|
+
rekeyed_disposition: rekeyedDisposition.disposition,
|
|
65
|
+
rekeyed_reason: rekeyedDisposition.reason ?? "none",
|
|
66
|
+
duplicate_count: entry.duplicates.length,
|
|
67
|
+
}));
|
|
68
|
+
return { entry: rekeyedEntry, disposition: rekeyedDisposition };
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* `begin` for a single object. Null whenever the answer cannot be trusted —
|
|
72
|
+
* transport failure, an unparseable body, a duplicate or missing key, or a
|
|
73
|
+
* pointer id that is not the one we sent — and the caller then keeps the
|
|
74
|
+
* original conflict rather than acting on a guess.
|
|
75
|
+
*/
|
|
76
|
+
async function beginOneObject(options, entry, chunkSizeBytes) {
|
|
77
|
+
const objectKey = entry.file.pointer.object_key ?? "";
|
|
78
|
+
const response = await requestJson(options, "/api/ambient/evidence/upload/begin", {
|
|
79
|
+
schema_version: "ambient-raw-evidence-upload-begin.v1",
|
|
80
|
+
generated_at: options.generatedAt,
|
|
81
|
+
provenance: options.provenance,
|
|
82
|
+
objects: [
|
|
83
|
+
{
|
|
84
|
+
pointer: entry.file.pointer,
|
|
85
|
+
chunk_size_bytes: chunkSizeBytes,
|
|
86
|
+
chunk_count: entry.chunkCount,
|
|
87
|
+
},
|
|
88
|
+
],
|
|
89
|
+
});
|
|
90
|
+
if (!response.ok) {
|
|
91
|
+
console.error("[evidence-rekey] begin refused the re-keyed object; keeping the original conflict", JSON.stringify({
|
|
92
|
+
reason: "rekey_begin_rejected",
|
|
93
|
+
http_status: response.status,
|
|
94
|
+
server_reason: safeFailureDetail(response.body) ?? "none",
|
|
95
|
+
kind: entry.file.kind ?? "unknown",
|
|
96
|
+
}));
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
const parsed = RawEvidenceUploadBeginResponseSchema.safeParse(response.body);
|
|
100
|
+
if (!parsed.success)
|
|
101
|
+
return null;
|
|
102
|
+
const dispositions = readBeginDispositions(parsed.data);
|
|
103
|
+
const disposition = dispositions?.get(objectKey);
|
|
104
|
+
if (!disposition)
|
|
105
|
+
return null;
|
|
106
|
+
if (disposition.raw_evidence_pointer_id !==
|
|
107
|
+
entry.file.pointer.raw_evidence_pointer_id) {
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
return disposition;
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* A duplicate file shares its primary's fate, but a primary that physically
|
|
114
|
+
* uploaded leaves the duplicate as reuse of now-durable content — counting it
|
|
115
|
+
* as a second upload would inflate object counts.
|
|
116
|
+
*/
|
|
117
|
+
export function duplicateOutcome(primary, duplicate) {
|
|
118
|
+
return {
|
|
119
|
+
...primary,
|
|
120
|
+
pointer: pointerWithUploadedMetadata(duplicate.pointer, primary.pointer),
|
|
121
|
+
codex_session_id: duplicate.codex_session_id ?? null,
|
|
122
|
+
kind: duplicate.kind ?? "unknown",
|
|
123
|
+
artifact_metadata: duplicate.artifact_metadata,
|
|
124
|
+
upload_state: primary.upload_state === "uploaded"
|
|
125
|
+
? "reused_existing"
|
|
126
|
+
: primary.upload_state,
|
|
127
|
+
reason: primary.upload_state === "uploaded" ? "duplicate_in_batch" : primary.reason,
|
|
128
|
+
uploaded_chunk_count: 0,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Tell the server why we gave up on a row `begin` already opened (BLI-2539).
|
|
133
|
+
*
|
|
134
|
+
* `begin` opens a ledger row per object and the chunks then go one object at a
|
|
135
|
+
* time, so one object failing leaves its siblings untouched — the production
|
|
136
|
+
* shape is a lone failure among committed rows. This function is the only thing
|
|
137
|
+
* standing between that failure and a row that sits `pending` with a null
|
|
138
|
+
* reason until a drain relabels it `staging_incomplete` weeks later, which
|
|
139
|
+
* names the shape and not the cause. 275 rows reached that state by 2026-08-14.
|
|
140
|
+
*
|
|
141
|
+
* The server records the reason on the still-open row (for a reason classified
|
|
142
|
+
* permanent it fails the row closed), so reporting a give-up never costs the
|
|
143
|
+
* staged chunks the next sync resumes from.
|
|
144
|
+
*
|
|
145
|
+
* Deliberately best-effort: the upload has already failed and the caller's
|
|
146
|
+
* outcome is the answer that matters. Losing the abort as well leaves exactly
|
|
147
|
+
* the row we had before this existed, so a delivery failure must not throw —
|
|
148
|
+
* but it is named on stderr rather than swallowed, because a reason the server
|
|
149
|
+
* never received is invisible precisely where BLI-2539 needed it visible.
|
|
150
|
+
*/
|
|
151
|
+
export async function reportAbandonedUpload(options, disposition, outcome) {
|
|
152
|
+
if (outcome.upload_state !== "upload_failed")
|
|
153
|
+
return;
|
|
154
|
+
if (!outcome.reason)
|
|
155
|
+
return;
|
|
156
|
+
// Only a row this call actually opened or resumed. `conflict` never allocated
|
|
157
|
+
// one for us, and `already_committed` names durable content an abort must not
|
|
158
|
+
// touch.
|
|
159
|
+
if (disposition.disposition !== "new" && disposition.disposition !== "resume") {
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
if (!disposition.upload_id)
|
|
163
|
+
return;
|
|
164
|
+
const reason = conformReasonLabel(outcome.reason);
|
|
165
|
+
const response = await requestJson(options, "/api/ambient/evidence/upload/abort", {
|
|
166
|
+
schema_version: "ambient-raw-evidence-upload-abort.v1",
|
|
167
|
+
generated_at: options.generatedAt,
|
|
168
|
+
provenance: options.provenance,
|
|
169
|
+
upload_id: disposition.upload_id,
|
|
170
|
+
object_key: outcome.object_key,
|
|
171
|
+
reason,
|
|
172
|
+
uploaded_chunk_count: outcome.uploaded_chunk_count,
|
|
173
|
+
});
|
|
174
|
+
if (!response.ok) {
|
|
175
|
+
// status 404 is an old dashboard without the abort route; status 0 means
|
|
176
|
+
// the request itself never completed. Metadata only — ids, status, labels.
|
|
177
|
+
console.error("[evidence-abort] delivery failed; the server did not record the reason", JSON.stringify({
|
|
178
|
+
upload_id: disposition.upload_id,
|
|
179
|
+
reason,
|
|
180
|
+
http_status: response.status,
|
|
181
|
+
}));
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Force a reason into the label shape the abort route accepts.
|
|
186
|
+
*
|
|
187
|
+
* Every reason this client composes already fits, and this exists so that stays
|
|
188
|
+
* true without anyone having to remember it. A label the server rejects comes
|
|
189
|
+
* back 400 and the reason is lost — which is the precise failure BLI-2539 is
|
|
190
|
+
* about, arriving through the code meant to fix it. A mangled label that lands
|
|
191
|
+
* beats a perfect one that does not.
|
|
192
|
+
*/
|
|
193
|
+
function conformReasonLabel(reason) {
|
|
194
|
+
const conformed = reason.replace(/[^a-z0-9_:.-]/gi, "_").slice(0, 120);
|
|
195
|
+
return conformed.length > 0 ? conformed : "upload_failed_unlabelled";
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* Older dashboards predate the chunk endpoints. Fall back to one legacy
|
|
199
|
+
* request per file so an oversized or already-existing object fails (or
|
|
200
|
+
* reuses) individually instead of dragging the whole batch down. Large files
|
|
201
|
+
* are attempted rather than pre-failed: a non-serverless dashboard (the
|
|
202
|
+
* default localhost deployment) accepts them like the old CLI could, and a
|
|
203
|
+
* platform body-limit rejection comes back as a labeled per-file failure.
|
|
204
|
+
*/
|
|
205
|
+
export async function uploadWithLegacyFallback(options, loaded, outcomes) {
|
|
206
|
+
for (const entry of loaded) {
|
|
207
|
+
let outcome;
|
|
208
|
+
const response = await requestJson(options, "/api/ambient/evidence/upload", {
|
|
209
|
+
schema_version: "ambient-raw-evidence-upload.v1",
|
|
210
|
+
generated_at: options.generatedAt,
|
|
211
|
+
provenance: options.provenance,
|
|
212
|
+
files: [
|
|
213
|
+
{
|
|
214
|
+
pointer: entry.file.pointer,
|
|
215
|
+
content_base64: entry.bytes.toString("base64"),
|
|
216
|
+
},
|
|
217
|
+
],
|
|
218
|
+
});
|
|
219
|
+
if (response.ok) {
|
|
220
|
+
const parsedLegacy = RawEvidenceLegacyUploadResponseSchema.safeParse(response.body);
|
|
221
|
+
if (!parsedLegacy.success) {
|
|
222
|
+
outcome = failedOutcome(entry.file, "legacy_upload_invalid_response");
|
|
223
|
+
}
|
|
224
|
+
else {
|
|
225
|
+
const receipt = parsedLegacy.data.uploaded.find((candidate) => candidate.raw_evidence_pointer_id ===
|
|
226
|
+
entry.file.pointer.raw_evidence_pointer_id);
|
|
227
|
+
if (!receipt ||
|
|
228
|
+
parsedLegacy.data.bucket !== entry.file.pointer.storage_bucket ||
|
|
229
|
+
!legacyUploadReceiptMatchesPointer(entry.file.pointer, receipt)) {
|
|
230
|
+
outcome = failedOutcome(entry.file, "legacy_upload_receipt_mismatch");
|
|
231
|
+
}
|
|
232
|
+
else {
|
|
233
|
+
const pointer = pointerWithLegacyUploadResponse(entry.file.pointer, parsedLegacy.data);
|
|
234
|
+
outcome = {
|
|
235
|
+
pointer,
|
|
236
|
+
object_key: entry.file.pointer.object_key ?? "",
|
|
237
|
+
codex_session_id: entry.file.codex_session_id ?? null,
|
|
238
|
+
kind: entry.file.kind ?? "unknown",
|
|
239
|
+
artifact_metadata: entry.file.artifact_metadata,
|
|
240
|
+
upload_state: "uploaded",
|
|
241
|
+
reason: "legacy_single_shot_upload",
|
|
242
|
+
uploaded_chunk_count: 1,
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
else if (response.status === 409) {
|
|
248
|
+
// The pre-ledger route cannot prove that a conflicting object contains
|
|
249
|
+
// these exact bytes. Fail closed instead of seeding the cursor with an
|
|
250
|
+
// unverified pointer.
|
|
251
|
+
outcome = failedOutcome(entry.file, "legacy_upload_conflict_http_409");
|
|
252
|
+
}
|
|
253
|
+
else {
|
|
254
|
+
outcome = failedOutcome(entry.file, `legacy_upload_failed_http_${response.status}`);
|
|
255
|
+
}
|
|
256
|
+
outcomes.push(outcome);
|
|
257
|
+
for (const duplicate of entry.duplicates) {
|
|
258
|
+
outcomes.push(duplicateOutcome(outcome, duplicate));
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
return summarizeOutcomes(outcomes, true);
|
|
262
|
+
}
|
|
263
|
+
function pointerWithLegacyUploadResponse(pointer, body) {
|
|
264
|
+
const entry = body.uploaded.find((candidate) => candidate.raw_evidence_pointer_id === pointer.raw_evidence_pointer_id);
|
|
265
|
+
if (!entry)
|
|
266
|
+
return pointer;
|
|
267
|
+
return pointerWithUploadedMetadata(pointer, {
|
|
268
|
+
content_hash_sha256: entry.content_hash_sha256,
|
|
269
|
+
byte_size: entry.byte_size,
|
|
270
|
+
redaction: entry.redaction,
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
function legacyUploadReceiptMatchesPointer(pointer, receipt) {
|
|
274
|
+
if (receipt.object_key !== pointer.object_key)
|
|
275
|
+
return false;
|
|
276
|
+
if (receipt.redaction) {
|
|
277
|
+
return (receipt.redaction.original_content_hash_sha256 ===
|
|
278
|
+
pointer.content_hash_sha256 &&
|
|
279
|
+
receipt.redaction.original_byte_size === pointer.byte_size &&
|
|
280
|
+
receipt.redaction.sanitized_content_hash_sha256 ===
|
|
281
|
+
receipt.content_hash_sha256 &&
|
|
282
|
+
receipt.redaction.sanitized_byte_size === receipt.byte_size);
|
|
283
|
+
}
|
|
284
|
+
return (receipt.content_hash_sha256 === pointer.content_hash_sha256 &&
|
|
285
|
+
receipt.byte_size === pointer.byte_size);
|
|
286
|
+
}
|
|
287
|
+
function pointerWithUploadedMetadata(pointer, metadata) {
|
|
288
|
+
const redaction = RawEvidenceRedactionMetadataSchema.safeParse(metadata.redaction);
|
|
289
|
+
return {
|
|
290
|
+
...pointer,
|
|
291
|
+
content_hash_sha256: typeof metadata.content_hash_sha256 === "string"
|
|
292
|
+
? metadata.content_hash_sha256
|
|
293
|
+
: pointer.content_hash_sha256,
|
|
294
|
+
byte_size: typeof metadata.byte_size === "number" ? metadata.byte_size : pointer.byte_size,
|
|
295
|
+
redaction: redaction.success ? redaction.data : pointer.redaction,
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
export function summarizeOutcomes(outcomes, usedLegacyFallback) {
|
|
299
|
+
const uploaded = outcomes.filter((outcome) => outcome.upload_state === "uploaded");
|
|
300
|
+
return {
|
|
301
|
+
outcomes,
|
|
302
|
+
uploaded_object_keys: uploaded.map((outcome) => outcome.object_key),
|
|
303
|
+
uploaded_object_count: uploaded.length,
|
|
304
|
+
uploaded_chunk_count: outcomes.reduce((sum, outcome) => sum + outcome.uploaded_chunk_count, 0),
|
|
305
|
+
reused_count: outcomes.filter((outcome) => outcome.upload_state === "reused_existing").length,
|
|
306
|
+
failed_count: outcomes.filter((outcome) => outcome.upload_state === "upload_failed").length,
|
|
307
|
+
used_legacy_fallback: usedLegacyFallback,
|
|
308
|
+
};
|
|
309
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import { describeError } from "./health-detail.js";
|
|
3
|
+
/**
|
|
4
|
+
* The `send` stage: one retrying POST to an evidence-upload route, plus the
|
|
5
|
+
* response-body plumbing every later stage reads a reason off of.
|
|
6
|
+
*
|
|
7
|
+
* Raw content is never printed, diffed, or summarized here — only hashes,
|
|
8
|
+
* sizes, keys, and reason labels leave this module.
|
|
9
|
+
*/
|
|
10
|
+
const DEFAULT_MAX_ATTEMPTS = 3;
|
|
11
|
+
const RETRY_DELAY_MS = 250;
|
|
12
|
+
export async function requestJson(options, routePath, body) {
|
|
13
|
+
const maxAttempts = options.maxAttemptsPerRequest ?? DEFAULT_MAX_ATTEMPTS;
|
|
14
|
+
const sleep = options.sleep ?? defaultSleep;
|
|
15
|
+
let lastStatus = 0;
|
|
16
|
+
let lastBody = null;
|
|
17
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
|
18
|
+
try {
|
|
19
|
+
const response = await options.fetchImpl(`${options.dashboardUrl}${routePath}`, {
|
|
20
|
+
method: "POST",
|
|
21
|
+
headers: {
|
|
22
|
+
"Authorization": `Bearer ${options.deviceToken}`,
|
|
23
|
+
"Content-Type": "application/json",
|
|
24
|
+
},
|
|
25
|
+
body: JSON.stringify(body),
|
|
26
|
+
});
|
|
27
|
+
lastStatus = response.status;
|
|
28
|
+
lastBody = await readResponseJson(response);
|
|
29
|
+
if (response.ok || (response.status < 500 && response.status !== 429)) {
|
|
30
|
+
return { ok: response.ok, status: response.status, body: lastBody };
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
catch (error) {
|
|
34
|
+
// `status: 0` is this function's word for "no answer came back", and it
|
|
35
|
+
// travels all the way to the upload ledger without ever saying whether
|
|
36
|
+
// the request left the machine. This is the exact silence BLI-2528 sat
|
|
37
|
+
// behind for 57 days; the route is named, the body never is.
|
|
38
|
+
console.error("[evidence-upload] request failed before a status came back", JSON.stringify({
|
|
39
|
+
reason: "upload_request_transport_error",
|
|
40
|
+
route: routePath,
|
|
41
|
+
attempt,
|
|
42
|
+
max_attempts: maxAttempts,
|
|
43
|
+
...describeError(error),
|
|
44
|
+
}));
|
|
45
|
+
lastStatus = 0;
|
|
46
|
+
lastBody = null;
|
|
47
|
+
}
|
|
48
|
+
if (attempt < maxAttempts)
|
|
49
|
+
await sleep(RETRY_DELAY_MS * attempt);
|
|
50
|
+
}
|
|
51
|
+
return { ok: false, status: lastStatus, body: lastBody };
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Marks a body the server did not produce as JSON.
|
|
55
|
+
*
|
|
56
|
+
* A dashboard route always answers with a JSON envelope, so an HTML body on a
|
|
57
|
+
* 500 means the response came from the platform's error page and not from the
|
|
58
|
+
* route — the process died before any handler ran. That distinction is the
|
|
59
|
+
* whole difference between "the commit rejected these bytes" and "the commit
|
|
60
|
+
* never got to decide", and it was invisible for the nine days of BLI-3067
|
|
61
|
+
* because both collapsed into `commit_failed_http_500`.
|
|
62
|
+
*/
|
|
63
|
+
export const NON_JSON_RESPONSE_BODY_KEY = "__cockpit_response_body_format";
|
|
64
|
+
export function isNonJsonResponseBody(body) {
|
|
65
|
+
return (!!body &&
|
|
66
|
+
typeof body === "object" &&
|
|
67
|
+
body[NON_JSON_RESPONSE_BODY_KEY] === "non_json");
|
|
68
|
+
}
|
|
69
|
+
async function readResponseJson(response) {
|
|
70
|
+
const text = await response.text();
|
|
71
|
+
if (!text)
|
|
72
|
+
return {};
|
|
73
|
+
try {
|
|
74
|
+
return JSON.parse(text);
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
// The raw text is kept for the caller that wants to show it, never for a
|
|
78
|
+
// label or a log — it is an unbounded HTML page. Its SHAPE is safe and is
|
|
79
|
+
// the part that matters: a non-JSON body means something in front of the
|
|
80
|
+
// dashboard answered instead of it (BLI-3067, BLI-3238).
|
|
81
|
+
console.error("[evidence-upload] reply was not JSON", JSON.stringify({
|
|
82
|
+
reason: "response_body_not_json",
|
|
83
|
+
http_status: response.status,
|
|
84
|
+
byte_size: text.length,
|
|
85
|
+
content_type: response.headers.get("content-type") ?? "none",
|
|
86
|
+
}));
|
|
87
|
+
return { message: text, [NON_JSON_RESPONSE_BODY_KEY]: "non_json" };
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
/** The server's `{ reason }` field, sanity-checked before it is trusted as a label. */
|
|
91
|
+
export function safeFailureDetail(body) {
|
|
92
|
+
if (!body || typeof body !== "object")
|
|
93
|
+
return null;
|
|
94
|
+
const reason = body.reason;
|
|
95
|
+
return typeof reason === "string" && /^[a-z0-9_:-]{1,80}$/i.test(reason)
|
|
96
|
+
? reason
|
|
97
|
+
: null;
|
|
98
|
+
}
|
|
99
|
+
function defaultSleep(milliseconds) {
|
|
100
|
+
return new Promise((resolve) => setTimeout(resolve, milliseconds));
|
|
101
|
+
}
|
|
102
|
+
export function sha256(value) {
|
|
103
|
+
return crypto.createHash("sha256").update(value).digest("hex");
|
|
104
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { parseUploadSpoolState } from "./local-spool-parse.js";
|
|
5
|
+
import { SPOOL_STATE_FILENAME, emptyUploadSpoolState, } from "./local-spool-types.js";
|
|
6
|
+
/**
|
|
7
|
+
* Reads, writes, and summarizes the spool file on disk. The write path is
|
|
8
|
+
* durable-write-then-atomic-rename (temp file, fsync, rename, best-effort
|
|
9
|
+
* directory fsync) so a crash mid-write never leaves a half-written spool
|
|
10
|
+
* that a later read would have to guess about.
|
|
11
|
+
*/
|
|
12
|
+
export async function readLocalUploadSpoolState(paths) {
|
|
13
|
+
const filePath = uploadSpoolStatePath(paths);
|
|
14
|
+
let serialized;
|
|
15
|
+
try {
|
|
16
|
+
serialized = await fs.readFile(filePath, "utf8");
|
|
17
|
+
}
|
|
18
|
+
catch (error) {
|
|
19
|
+
if (isErrnoException(error, "ENOENT"))
|
|
20
|
+
return emptyUploadSpoolState();
|
|
21
|
+
throw uploadSpoolReadError(filePath, "unreadable", error);
|
|
22
|
+
}
|
|
23
|
+
let raw;
|
|
24
|
+
try {
|
|
25
|
+
raw = JSON.parse(serialized);
|
|
26
|
+
}
|
|
27
|
+
catch (error) {
|
|
28
|
+
throw uploadSpoolReadError(filePath, "invalid JSON", error);
|
|
29
|
+
}
|
|
30
|
+
try {
|
|
31
|
+
return parseUploadSpoolState(raw);
|
|
32
|
+
}
|
|
33
|
+
catch (error) {
|
|
34
|
+
throw uploadSpoolReadError(filePath, "invalid schema", error);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
export async function summarizeLocalUploadSpool(paths) {
|
|
38
|
+
const state = await readLocalUploadSpoolState(paths);
|
|
39
|
+
return {
|
|
40
|
+
last_upload_attempt_at: state.last_upload_attempt_at,
|
|
41
|
+
last_upload_success_at: state.last_upload_success_at,
|
|
42
|
+
last_upload_failure_reason: state.last_upload_failure_reason,
|
|
43
|
+
pending_upload_count: state.pending_uploads.length +
|
|
44
|
+
state.pending_source_retries.length +
|
|
45
|
+
state.pending_session_reports.length,
|
|
46
|
+
retry_command: state.pending_uploads[0]?.retry_command ??
|
|
47
|
+
(state.pending_source_retries.length > 0 ||
|
|
48
|
+
state.pending_session_reports.length > 0
|
|
49
|
+
? "cockpit sync"
|
|
50
|
+
: null),
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
export async function writeUploadSpoolState(paths, state) {
|
|
54
|
+
const filePath = uploadSpoolStatePath(paths);
|
|
55
|
+
const directoryPath = path.dirname(filePath);
|
|
56
|
+
const serialized = serializeUploadSpoolState(state);
|
|
57
|
+
const tempPath = path.join(directoryPath, `.${path.basename(filePath)}.${process.pid}.${crypto.randomUUID()}.tmp`);
|
|
58
|
+
await fs.mkdir(directoryPath, { recursive: true, mode: 0o700 });
|
|
59
|
+
let handle = null;
|
|
60
|
+
let tempCreated = false;
|
|
61
|
+
let renamed = false;
|
|
62
|
+
try {
|
|
63
|
+
handle = await fs.open(tempPath, "wx", 0o600);
|
|
64
|
+
tempCreated = true;
|
|
65
|
+
if (process.platform !== "win32") {
|
|
66
|
+
await handle.chmod(0o600);
|
|
67
|
+
}
|
|
68
|
+
await handle.writeFile(serialized, "utf8");
|
|
69
|
+
await handle.sync();
|
|
70
|
+
await handle.close();
|
|
71
|
+
handle = null;
|
|
72
|
+
await fs.rename(tempPath, filePath);
|
|
73
|
+
renamed = true;
|
|
74
|
+
await fsyncDirectoryBestEffort(directoryPath);
|
|
75
|
+
}
|
|
76
|
+
catch (error) {
|
|
77
|
+
await handle?.close().catch(() => undefined);
|
|
78
|
+
if (tempCreated && !renamed) {
|
|
79
|
+
await fs.unlink(tempPath).catch(() => undefined);
|
|
80
|
+
}
|
|
81
|
+
throw error;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
function serializeUploadSpoolState(state) {
|
|
85
|
+
const json = JSON.stringify(state);
|
|
86
|
+
const canonical = parseUploadSpoolState(JSON.parse(json));
|
|
87
|
+
return `${JSON.stringify(canonical, null, 2)}\n`;
|
|
88
|
+
}
|
|
89
|
+
function uploadSpoolStatePath(paths) {
|
|
90
|
+
return path.join(paths.spool_dir, SPOOL_STATE_FILENAME);
|
|
91
|
+
}
|
|
92
|
+
function isErrnoException(error, code) {
|
|
93
|
+
return (error instanceof Error &&
|
|
94
|
+
"code" in error &&
|
|
95
|
+
error.code === code);
|
|
96
|
+
}
|
|
97
|
+
function uploadSpoolReadError(filePath, classification, cause) {
|
|
98
|
+
const detail = cause &&
|
|
99
|
+
typeof cause === "object" &&
|
|
100
|
+
"code" in cause &&
|
|
101
|
+
typeof cause.code === "string"
|
|
102
|
+
? ` (${cause.code})`
|
|
103
|
+
: "";
|
|
104
|
+
return new Error(`Upload spool state at ${filePath} is ${classification}; refusing to treat pending delivery state as empty.${detail}`);
|
|
105
|
+
}
|
|
106
|
+
async function fsyncDirectoryBestEffort(directoryPath) {
|
|
107
|
+
let handle = null;
|
|
108
|
+
try {
|
|
109
|
+
handle = await fs.open(directoryPath, "r");
|
|
110
|
+
await handle.sync();
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
// Directory fsync is not supported by every host/filesystem (notably some
|
|
114
|
+
// Windows versions). The file itself was fsynced before the atomic rename.
|
|
115
|
+
// Deliberately silent (BLI-3238): on those hosts this fails on every
|
|
116
|
+
// single write, so a line here would be pure noise on exactly the
|
|
117
|
+
// platform the collector most needs readable logs on.
|
|
118
|
+
}
|
|
119
|
+
finally {
|
|
120
|
+
await handle?.close().catch(() => undefined);
|
|
121
|
+
}
|
|
122
|
+
}
|