@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.
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,426 +1,20 @@
1
- import crypto from "node:crypto";
2
- import fs from "node:fs/promises";
3
- import path from "node:path";
4
- import { CodexSessionAttributionSchema, } from "@bli-cockpit/telemetry-core";
5
- const SPOOL_STATE_FILENAME = "upload-state.json";
6
- const MAX_PENDING_UPLOADS = 20;
7
- export function emptyUploadSpoolState() {
8
- return {
9
- schema_version: "cockpit-upload-spool.v1",
10
- updated_at: null,
11
- last_upload_attempt_at: null,
12
- last_upload_success_at: null,
13
- last_upload_failure_reason: null,
14
- pending_uploads: [],
15
- pending_source_retries: [],
16
- pending_session_reports: [],
17
- };
18
- }
19
- export async function readLocalUploadSpoolState(paths) {
20
- const filePath = uploadSpoolStatePath(paths);
21
- let serialized;
22
- try {
23
- serialized = await fs.readFile(filePath, "utf8");
24
- }
25
- catch (error) {
26
- if (isErrnoException(error, "ENOENT"))
27
- return emptyUploadSpoolState();
28
- throw uploadSpoolReadError(filePath, "unreadable", error);
29
- }
30
- let raw;
31
- try {
32
- raw = JSON.parse(serialized);
33
- }
34
- catch (error) {
35
- throw uploadSpoolReadError(filePath, "invalid JSON", error);
36
- }
37
- try {
38
- return parseUploadSpoolState(raw);
39
- }
40
- catch (error) {
41
- throw uploadSpoolReadError(filePath, "invalid schema", error);
42
- }
43
- }
44
- export async function summarizeLocalUploadSpool(paths) {
45
- const state = await readLocalUploadSpoolState(paths);
46
- return {
47
- last_upload_attempt_at: state.last_upload_attempt_at,
48
- last_upload_success_at: state.last_upload_success_at,
49
- last_upload_failure_reason: state.last_upload_failure_reason,
50
- pending_upload_count: state.pending_uploads.length +
51
- state.pending_source_retries.length +
52
- state.pending_session_reports.length,
53
- retry_command: state.pending_uploads[0]?.retry_command ??
54
- (state.pending_source_retries.length > 0 ||
55
- state.pending_session_reports.length > 0
56
- ? "cockpit sync"
57
- : null),
58
- };
59
- }
60
- export async function recordUploadBlocked(paths, options) {
61
- const state = await readLocalUploadSpoolState(paths);
62
- const next = {
63
- ...state,
64
- updated_at: options.attemptedAt,
65
- last_upload_attempt_at: options.attemptedAt,
66
- last_upload_failure_reason: options.reason,
67
- };
68
- await writeUploadSpoolState(paths, next);
69
- return next;
70
- }
71
- export async function recordUploadSuccess(paths, options) {
72
- const state = await readLocalUploadSpoolState(paths);
73
- const pending = options.clearPendingForContext === false
74
- ? state.pending_uploads
75
- : state.pending_uploads.filter((entry) => entry.work_context_id !== options.workContextId);
76
- const next = {
77
- ...state,
78
- updated_at: options.attemptedAt,
79
- last_upload_attempt_at: options.attemptedAt,
80
- last_upload_success_at: options.attemptedAt,
81
- last_upload_failure_reason: firstPendingFailureReason({
82
- ...state,
83
- pending_uploads: pending,
84
- }),
85
- pending_uploads: pending,
86
- };
87
- await writeUploadSpoolState(paths, next);
88
- return next;
89
- }
90
- export async function recordUploadFailure(paths, entry) {
91
- const state = await readLocalUploadSpoolState(paths);
92
- const createdAt = entry.created_at ?? entry.last_attempt_at;
93
- const spoolEntry = {
94
- spool_id: `upload-${crypto.randomUUID()}`,
95
- created_at: createdAt,
96
- ...entry,
97
- };
98
- const pending = [
99
- spoolEntry,
100
- ...state.pending_uploads.filter((candidate) => candidate.work_context_id !== spoolEntry.work_context_id ||
101
- candidate.ticket_id !== spoolEntry.ticket_id),
102
- ].slice(0, MAX_PENDING_UPLOADS);
103
- await writeUploadSpoolState(paths, {
104
- ...state,
105
- updated_at: entry.last_attempt_at,
106
- last_upload_attempt_at: entry.last_attempt_at,
107
- last_upload_failure_reason: entry.failure_reason,
108
- pending_uploads: pending,
109
- });
110
- return spoolEntry;
111
- }
112
- export async function recordSourceRetryFailure(paths, options) {
113
- const state = await readLocalUploadSpoolState(paths);
114
- const retryEntry = {
115
- source: options.source,
116
- reason: options.reason,
117
- last_attempt_at: options.attemptedAt,
118
- };
119
- const next = {
120
- ...state,
121
- updated_at: options.attemptedAt,
122
- last_upload_attempt_at: options.attemptedAt,
123
- last_upload_failure_reason: options.reason,
124
- pending_source_retries: [
125
- retryEntry,
126
- ...state.pending_source_retries.filter((entry) => entry.source !== options.source),
127
- ],
128
- };
129
- await writeUploadSpoolState(paths, next);
130
- return next;
131
- }
132
- export async function clearSourceRetryFailure(paths, source, attemptedAt) {
133
- const state = await readLocalUploadSpoolState(paths);
134
- const next = {
135
- ...state,
136
- updated_at: attemptedAt,
137
- pending_source_retries: state.pending_source_retries.filter((entry) => entry.source !== source),
138
- };
139
- next.last_upload_failure_reason = firstPendingFailureReason(next);
140
- await writeUploadSpoolState(paths, next);
141
- return next;
142
- }
143
- export async function recordPendingSessionReport(paths, entry) {
144
- const state = await readLocalUploadSpoolState(paths);
145
- const existing = state.pending_session_reports.find((candidate) => candidate.work_context_id === entry.work_context_id);
146
- const safeIncomingSessions = parseMetadataOnlySessionReportRows(entry.sessions, "pending_session_report.sessions");
147
- const mergedSessions = mergeSessionReportRows(existing?.sessions ?? [], safeIncomingSessions);
148
- const pending = {
149
- report_id: existing?.report_id ?? `session-report-${crypto.randomUUID()}`,
150
- created_at: existing?.created_at ?? entry.attempted_at,
151
- last_attempt_at: entry.attempted_at,
152
- dashboard_url: entry.dashboard_url,
153
- generated_at: entry.generated_at,
154
- work_context_id: entry.work_context_id,
155
- repo_label: entry.repo_label,
156
- branch: entry.branch,
157
- repo_fingerprint: entry.repo_fingerprint,
158
- repo_origin_url: entry.repo_origin_url,
159
- worktree_label: entry.worktree_label,
160
- worktree_fingerprint: entry.worktree_fingerprint,
161
- worktree_is_primary: entry.worktree_is_primary,
162
- sessions: mergedSessions,
163
- failure_reason: entry.failure_reason ?? "session_report_pending",
164
- };
165
- const next = {
166
- ...state,
167
- updated_at: entry.attempted_at,
168
- last_upload_attempt_at: entry.attempted_at,
169
- last_upload_failure_reason: pending.failure_reason,
170
- pending_session_reports: [
171
- pending,
172
- ...state.pending_session_reports.filter((candidate) => candidate.report_id !== pending.report_id),
173
- ],
174
- };
175
- await writeUploadSpoolState(paths, next);
176
- return pending;
177
- }
178
- export async function recordSessionReportFailure(paths, options) {
179
- const state = await readLocalUploadSpoolState(paths);
180
- const next = {
181
- ...state,
182
- updated_at: options.attemptedAt,
183
- last_upload_attempt_at: options.attemptedAt,
184
- last_upload_failure_reason: options.reason,
185
- pending_session_reports: state.pending_session_reports.map((entry) => entry.report_id === options.reportId
186
- ? {
187
- ...entry,
188
- last_attempt_at: options.attemptedAt,
189
- failure_reason: options.reason,
190
- }
191
- : entry),
192
- };
193
- await writeUploadSpoolState(paths, next);
194
- return next;
195
- }
196
- export async function recordSessionReportSuccess(paths, options) {
197
- const state = await readLocalUploadSpoolState(paths);
198
- const next = {
199
- ...state,
200
- updated_at: options.attemptedAt,
201
- last_upload_attempt_at: options.attemptedAt,
202
- last_upload_success_at: options.attemptedAt,
203
- pending_session_reports: state.pending_session_reports.filter((entry) => entry.report_id !== options.reportId),
204
- };
205
- next.last_upload_failure_reason = firstPendingFailureReason(next);
206
- await writeUploadSpoolState(paths, next);
207
- return next;
208
- }
209
- function parseUploadSpoolState(value) {
210
- const record = requireRecord(value, "root");
211
- if (record["schema_version"] !== "cockpit-upload-spool.v1") {
212
- invalidUploadSpoolState("schema_version", 'literal "cockpit-upload-spool.v1"');
213
- }
214
- return {
215
- schema_version: "cockpit-upload-spool.v1",
216
- updated_at: requireNullableString(record["updated_at"], "updated_at"),
217
- last_upload_attempt_at: requireNullableString(record["last_upload_attempt_at"], "last_upload_attempt_at"),
218
- last_upload_success_at: requireNullableString(record["last_upload_success_at"], "last_upload_success_at"),
219
- last_upload_failure_reason: requireNullableString(record["last_upload_failure_reason"], "last_upload_failure_reason"),
220
- pending_uploads: requireArray(record["pending_uploads"], "pending_uploads").map((entry, index) => parseUploadSpoolEntry(entry, `pending_uploads[${index}]`)),
221
- // These fields were added without changing the v1 schema version. Missing
222
- // fields are therefore a valid legacy state, while present malformed fields
223
- // must fail closed instead of being silently discarded.
224
- pending_source_retries: optionalArray(record["pending_source_retries"], "pending_source_retries").map((entry, index) => parseSourceRetryEntry(entry, `pending_source_retries[${index}]`)),
225
- pending_session_reports: optionalArray(record["pending_session_reports"], "pending_session_reports").map((entry, index) => parsePendingSessionReport(entry, `pending_session_reports[${index}]`)),
226
- };
227
- }
228
- function parseUploadSpoolEntry(value, fieldPath) {
229
- const record = requireRecord(value, fieldPath);
230
- const retryCommand = record["retry_command"] === undefined
231
- ? "cockpit sync"
232
- : requireString(record["retry_command"], `${fieldPath}.retry_command`);
233
- return {
234
- spool_id: requireString(record["spool_id"], `${fieldPath}.spool_id`),
235
- created_at: requireString(record["created_at"], `${fieldPath}.created_at`),
236
- last_attempt_at: requireString(record["last_attempt_at"], `${fieldPath}.last_attempt_at`),
237
- dashboard_url: requireString(record["dashboard_url"], `${fieldPath}.dashboard_url`),
238
- work_context_id: requireNullableString(record["work_context_id"], `${fieldPath}.work_context_id`),
239
- ticket_id: requireNullableString(record["ticket_id"], `${fieldPath}.ticket_id`),
240
- repo_label: requireNullableString(record["repo_label"], `${fieldPath}.repo_label`),
241
- branch: requireNullableString(record["branch"], `${fieldPath}.branch`),
242
- event_count: requireNonNegativeInteger(record["event_count"], `${fieldPath}.event_count`),
243
- source_scan_count: requireNonNegativeInteger(record["source_scan_count"], `${fieldPath}.source_scan_count`),
244
- risk_flag_count: requireNonNegativeInteger(record["risk_flag_count"], `${fieldPath}.risk_flag_count`),
245
- raw_evidence_file_count: requireNonNegativeInteger(record["raw_evidence_file_count"], `${fieldPath}.raw_evidence_file_count`),
246
- retry_sources: parseRetrySources(record["retry_sources"], `${fieldPath}.retry_sources`),
247
- failure_reason: requireString(record["failure_reason"], `${fieldPath}.failure_reason`),
248
- retry_command: retryCommand,
249
- };
250
- }
251
- async function writeUploadSpoolState(paths, state) {
252
- const filePath = uploadSpoolStatePath(paths);
253
- const directoryPath = path.dirname(filePath);
254
- const serialized = serializeUploadSpoolState(state);
255
- const tempPath = path.join(directoryPath, `.${path.basename(filePath)}.${process.pid}.${crypto.randomUUID()}.tmp`);
256
- await fs.mkdir(directoryPath, { recursive: true, mode: 0o700 });
257
- let handle = null;
258
- let tempCreated = false;
259
- let renamed = false;
260
- try {
261
- handle = await fs.open(tempPath, "wx", 0o600);
262
- tempCreated = true;
263
- if (process.platform !== "win32") {
264
- await handle.chmod(0o600);
265
- }
266
- await handle.writeFile(serialized, "utf8");
267
- await handle.sync();
268
- await handle.close();
269
- handle = null;
270
- await fs.rename(tempPath, filePath);
271
- renamed = true;
272
- await fsyncDirectoryBestEffort(directoryPath);
273
- }
274
- catch (error) {
275
- await handle?.close().catch(() => undefined);
276
- if (tempCreated && !renamed) {
277
- await fs.unlink(tempPath).catch(() => undefined);
278
- }
279
- throw error;
280
- }
281
- }
282
- function serializeUploadSpoolState(state) {
283
- const json = JSON.stringify(state);
284
- const canonical = parseUploadSpoolState(JSON.parse(json));
285
- return `${JSON.stringify(canonical, null, 2)}\n`;
286
- }
287
- function uploadSpoolStatePath(paths) {
288
- return path.join(paths.spool_dir, SPOOL_STATE_FILENAME);
289
- }
290
- function parseRetrySources(value, fieldPath) {
291
- if (value === undefined)
292
- return [];
293
- const values = requireArray(value, fieldPath);
294
- return [...new Set(values.map((source) => parseRetrySource(source, fieldPath)))];
295
- }
296
- function parseSourceRetryEntry(value, fieldPath) {
297
- const record = requireRecord(value, fieldPath);
298
- return {
299
- source: parseRetrySource(record["source"], `${fieldPath}.source`),
300
- reason: requireString(record["reason"], `${fieldPath}.reason`),
301
- last_attempt_at: requireString(record["last_attempt_at"], `${fieldPath}.last_attempt_at`),
302
- };
303
- }
304
- function parsePendingSessionReport(value, fieldPath) {
305
- const record = requireRecord(value, fieldPath);
306
- const sessions = parseMetadataOnlySessionReportRows(record["sessions"], `${fieldPath}.sessions`);
307
- return {
308
- report_id: requireString(record["report_id"], `${fieldPath}.report_id`),
309
- created_at: requireString(record["created_at"], `${fieldPath}.created_at`),
310
- last_attempt_at: requireString(record["last_attempt_at"], `${fieldPath}.last_attempt_at`),
311
- dashboard_url: requireString(record["dashboard_url"], `${fieldPath}.dashboard_url`),
312
- generated_at: requireString(record["generated_at"], `${fieldPath}.generated_at`),
313
- work_context_id: requireString(record["work_context_id"], `${fieldPath}.work_context_id`),
314
- repo_label: requireString(record["repo_label"], `${fieldPath}.repo_label`),
315
- branch: requireString(record["branch"], `${fieldPath}.branch`),
316
- repo_fingerprint: requireString(record["repo_fingerprint"], `${fieldPath}.repo_fingerprint`),
317
- repo_origin_url: record["repo_origin_url"] === undefined
318
- ? null
319
- : requireNullableString(record["repo_origin_url"], `${fieldPath}.repo_origin_url`),
320
- worktree_label: requireString(record["worktree_label"], `${fieldPath}.worktree_label`),
321
- worktree_fingerprint: requireString(record["worktree_fingerprint"], `${fieldPath}.worktree_fingerprint`),
322
- worktree_is_primary: requireBoolean(record["worktree_is_primary"], `${fieldPath}.worktree_is_primary`),
323
- sessions,
324
- failure_reason: requireString(record["failure_reason"], `${fieldPath}.failure_reason`),
325
- };
326
- }
327
- function parseMetadataOnlySessionReportRows(value, fieldPath) {
328
- const sessions = requireArray(value, fieldPath).map((session, index) => {
329
- const parsed = CodexSessionAttributionSchema.safeParse(session);
330
- if (!parsed.success) {
331
- invalidUploadSpoolState(`${fieldPath}[${index}]`, "metadata-only Codex session attribution");
332
- }
333
- return parsed.data;
334
- });
335
- if (sessions.length === 0) {
336
- invalidUploadSpoolState(fieldPath, "non-empty array");
337
- }
338
- return sessions;
339
- }
340
- function parseRetrySource(value, fieldPath) {
341
- if (value === "codex" || value === "claude_code")
342
- return value;
343
- return invalidUploadSpoolState(fieldPath, '"codex" or "claude_code"');
344
- }
345
- function mergeSessionReportRows(existing, incoming) {
346
- const bySession = new Map();
347
- for (const session of [...existing, ...incoming]) {
348
- bySession.set(`${session.source ?? "codex"}:${session.codex_session_id}`, session);
349
- }
350
- return [...bySession.values()];
351
- }
352
- function firstPendingFailureReason(state) {
353
- return (state.pending_uploads[0]?.failure_reason ??
354
- state.pending_source_retries[0]?.reason ??
355
- state.pending_session_reports[0]?.failure_reason ??
356
- null);
357
- }
358
- function requireRecord(value, fieldPath) {
359
- if (value && typeof value === "object" && !Array.isArray(value)) {
360
- return value;
361
- }
362
- return invalidUploadSpoolState(fieldPath, "object");
363
- }
364
- function requireArray(value, fieldPath) {
365
- if (Array.isArray(value))
366
- return value;
367
- return invalidUploadSpoolState(fieldPath, "array");
368
- }
369
- function optionalArray(value, fieldPath) {
370
- return value === undefined ? [] : requireArray(value, fieldPath);
371
- }
372
- function requireString(value, fieldPath) {
373
- if (typeof value === "string" && value.trim())
374
- return value;
375
- return invalidUploadSpoolState(fieldPath, "non-empty string");
376
- }
377
- function requireNullableString(value, fieldPath) {
378
- if (value === null)
379
- return null;
380
- return requireString(value, fieldPath);
381
- }
382
- function requireNonNegativeInteger(value, fieldPath) {
383
- if (typeof value === "number" && Number.isInteger(value) && value >= 0) {
384
- return value;
385
- }
386
- return invalidUploadSpoolState(fieldPath, "non-negative integer");
387
- }
388
- function requireBoolean(value, fieldPath) {
389
- if (typeof value === "boolean")
390
- return value;
391
- return invalidUploadSpoolState(fieldPath, "boolean");
392
- }
393
- function invalidUploadSpoolState(fieldPath, expected) {
394
- throw new Error(`Upload spool state schema mismatch at ${fieldPath}; expected ${expected}.`);
395
- }
396
- function isErrnoException(error, code) {
397
- return (error instanceof Error &&
398
- "code" in error &&
399
- error.code === code);
400
- }
401
- function uploadSpoolReadError(filePath, classification, cause) {
402
- const detail = cause &&
403
- typeof cause === "object" &&
404
- "code" in cause &&
405
- typeof cause.code === "string"
406
- ? ` (${cause.code})`
407
- : "";
408
- return new Error(`Upload spool state at ${filePath} is ${classification}; refusing to treat pending delivery state as empty.${detail}`);
409
- }
410
- async function fsyncDirectoryBestEffort(directoryPath) {
411
- let handle = null;
412
- try {
413
- handle = await fs.open(directoryPath, "r");
414
- await handle.sync();
415
- }
416
- catch {
417
- // Directory fsync is not supported by every host/filesystem (notably some
418
- // Windows versions). The file itself was fsynced before the atomic rename.
419
- // Deliberately silent (BLI-3238): on those hosts this fails on every
420
- // single write, so a line here would be pure noise on exactly the
421
- // platform the collector most needs readable logs on.
422
- }
423
- finally {
424
- await handle?.close().catch(() => undefined);
425
- }
426
- }
1
+ /**
2
+ * Durable, metadata-only local spool for undelivered uploads. A sync tick
3
+ * that could not deliver an event batch, a session-attribution report, or a
4
+ * source's scan results records the reason here instead of losing it — the
5
+ * spool never stores transcript content or local file paths, only hashes,
6
+ * ids, and reason labels, so it can sit on disk indefinitely without
7
+ * widening what a person's machine exposes.
8
+ *
9
+ * This file is the table of contents; every responsibility below lives in a
10
+ * `local-spool-*.ts` sibling and is re-exported here so the module's public
11
+ * surface never moves:
12
+ * -types the shapes, tuning constants, and the empty-state builder
13
+ * (BLI-3637)
14
+ * -parse hand-written schema validation for a JSON-parsed spool file
15
+ * -io read/write/summarize the spool file on disk (atomic write)
16
+ * -mutations every state transition a sync tick can record
17
+ */
18
+ export { emptyUploadSpoolState, } from "./local-spool-types.js";
19
+ export { readLocalUploadSpoolState, summarizeLocalUploadSpool, } from "./local-spool-io.js";
20
+ export { recordUploadBlocked, recordUploadSuccess, recordUploadFailure, recordSourceRetryFailure, clearSourceRetryFailure, recordPendingSessionReport, recordSessionReportFailure, recordSessionReportSuccess, } from "./local-spool-mutations.js";
@@ -0,0 +1,144 @@
1
+ import { clearDeliveryAttempt, DELIVERY_BACKOFF_BYPASS_REASON, DELIVERY_BACKOFF_HOLDING_REASON, deliveryBackoffApplies, deliveryHold, recordDeliveryFailure, writeRawEvidenceStagingState, } from "./raw-evidence-staging.js";
2
+ /**
3
+ * 1. What may we offer this sync? 2. What just happened to each object?
4
+ *
5
+ * `partitionHeldEvidenceFiles` keeps objects inside their delivery-backoff
6
+ * window off the wire, and turns each one into a named failure rather than a
7
+ * silent omission. `persistDeliveryAttempts` writes the attempt counts that
8
+ * drive that backoff, before ingest, so an ingest failure cannot reset every
9
+ * window to zero.
10
+ */
11
+ /**
12
+ * Split the pack's files into "offer these now" and "still in backoff".
13
+ *
14
+ * A held file becomes an `upload_failed` outcome labelled
15
+ * `delivery_backoff_holding`. That is deliberate rather than a quiet omission:
16
+ * the pointer gets pruned from the envelope (the object is genuinely not
17
+ * durable), the sync stays in `retry_pending`, and the reason travels to the
18
+ * status output. A hold that read as success would be the green-status-hiding-
19
+ * missing-collection failure the fleet contract forbids.
20
+ */
21
+ export function partitionHeldEvidenceFiles(files, staging, now, mode) {
22
+ const deliverable = [];
23
+ const held = [];
24
+ const bypassed = [];
25
+ const backoffApplies = deliveryBackoffApplies(mode);
26
+ for (const file of files) {
27
+ const hold = deliveryHold(staging, file.pointer.content_hash_sha256, now);
28
+ if (hold && !backoffApplies) {
29
+ // BLI-3118: a person asked for this one now. Offering it is the whole
30
+ // point of the retry command Cockpit printed, and the bypass is logged
31
+ // rather than assumed.
32
+ bypassed.push(hold);
33
+ deliverable.push(file);
34
+ continue;
35
+ }
36
+ if (!hold) {
37
+ deliverable.push(file);
38
+ continue;
39
+ }
40
+ held.push({
41
+ pointer: file.pointer,
42
+ object_key: file.pointer.object_key ?? "",
43
+ codex_session_id: file.codex_session_id ?? null,
44
+ kind: file.kind ?? "raw_evidence",
45
+ ...(file.artifact_metadata
46
+ ? { artifact_metadata: file.artifact_metadata }
47
+ : {}),
48
+ upload_state: "upload_failed",
49
+ reason: DELIVERY_BACKOFF_HOLDING_REASON,
50
+ uploaded_chunk_count: 0,
51
+ });
52
+ }
53
+ return { deliverable, held, bypassed };
54
+ }
55
+ /**
56
+ * Say out loud that bytes were withheld on purpose.
57
+ *
58
+ * Counts and sizes only, never a path. Without this line an unattended machine
59
+ * withholds evidence for hours and leaves no trace of having done so.
60
+ */
61
+ export function logEvidenceHeldByBackoff(held, attemptedAt) {
62
+ if (held.length === 0)
63
+ return;
64
+ console.error("[cockpit-sync] raw evidence held by delivery backoff", JSON.stringify({
65
+ attempted_at: attemptedAt,
66
+ reason: DELIVERY_BACKOFF_HOLDING_REASON,
67
+ object_count: held.length,
68
+ byte_size: held.reduce((sum, outcome) => sum + (outcome.pointer.byte_size ?? 0), 0),
69
+ }));
70
+ }
71
+ /**
72
+ * Say out loud that an operator's retry ignored a live backoff window.
73
+ *
74
+ * The success branch of BLI-3118: without this line the only trace of the
75
+ * decision is an object that was held on one run and offered on the next, and
76
+ * nothing on the machine says which rule made the difference.
77
+ */
78
+ export function logEvidenceBackoffBypassed(bypassed, attemptedAt) {
79
+ if (bypassed.length === 0)
80
+ return;
81
+ console.error("[cockpit-sync] raw evidence delivery backoff bypassed", JSON.stringify({
82
+ attempted_at: attemptedAt,
83
+ reason: DELIVERY_BACKOFF_BYPASS_REASON,
84
+ object_count: bypassed.length,
85
+ byte_size: bypassed.reduce((sum, entry) => sum + entry.byte_size, 0),
86
+ max_attempts: bypassed.reduce((max, entry) => Math.max(max, entry.attempts), 0),
87
+ last_reasons: [...new Set(bypassed.map((entry) => entry.last_reason))]
88
+ .sort(),
89
+ }));
90
+ }
91
+ /**
92
+ * Count what just happened to each object, and when it may be offered again.
93
+ *
94
+ * Written immediately after the upload pass and before ingest, so an ingest
95
+ * failure cannot lose the attempt counts — losing them resets every backoff to
96
+ * zero and the fleet is back to 15-minute retries forever. A held outcome is
97
+ * not itself an attempt: counting it would push its own next attempt further
98
+ * out on every sync and eventually never retry at all.
99
+ */
100
+ export async function persistDeliveryAttempts(stateDir, staging, outcomes, attemptedAt) {
101
+ let changed = false;
102
+ for (const outcome of outcomes) {
103
+ const contentHash = outcome.pointer.content_hash_sha256;
104
+ if (!contentHash)
105
+ continue;
106
+ if (outcome.reason === DELIVERY_BACKOFF_HOLDING_REASON)
107
+ continue;
108
+ if (outcome.upload_state === "upload_failed") {
109
+ const entry = recordDeliveryFailure(staging, contentHash, {
110
+ reason: outcome.reason ?? "upload_failed",
111
+ attemptedAt,
112
+ byteSize: outcome.pointer.byte_size ?? 0,
113
+ });
114
+ changed = true;
115
+ console.error("[cockpit-sync] raw evidence delivery failed", JSON.stringify({
116
+ reason: entry.last_reason,
117
+ kind: outcome.kind,
118
+ attempts: entry.attempts,
119
+ first_failed_at: entry.first_failed_at,
120
+ next_attempt_at: entry.next_attempt_at,
121
+ byte_size: entry.byte_size,
122
+ }));
123
+ continue;
124
+ }
125
+ if (clearDeliveryAttempt(staging, contentHash)) {
126
+ changed = true;
127
+ console.error("[cockpit-sync] raw evidence delivery recovered", JSON.stringify({
128
+ reason: "delivery_recovered",
129
+ kind: outcome.kind,
130
+ upload_state: outcome.upload_state,
131
+ }));
132
+ }
133
+ }
134
+ if (!changed)
135
+ return;
136
+ staging.updated_at = attemptedAt.toISOString();
137
+ await writeRawEvidenceStagingState(stateDir, staging).catch((error) => {
138
+ console.error("[cockpit-sync] delivery attempt state write failed", JSON.stringify({
139
+ reason: "staging_state_write_failed",
140
+ detail: error instanceof Error ? error.name : typeof error,
141
+ tracked_count: Object.keys(staging.delivery_attempts).length,
142
+ }));
143
+ });
144
+ }