@bli-cockpit/cli 0.2.54 → 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
@@ -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
+ }