@bli-cockpit/cli 0.2.236 → 0.2.238

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 (143) hide show
  1. package/dist/adapters/attribution-core-fallbacks.js +15 -11
  2. package/dist/adapters/attribution-core-score.js +111 -87
  3. package/dist/adapters/car-state.js +38 -35
  4. package/dist/adapters/claude-attribution-discovery.js +81 -56
  5. package/dist/adapters/claude-attribution-score.js +108 -94
  6. package/dist/adapters/claude-attribution-signals.js +52 -42
  7. package/dist/adapters/codex-attribution.js +42 -33
  8. package/dist/adapters/codex-session-signals.js +11 -9
  9. package/dist/adapters/git-state.js +56 -50
  10. package/dist/adapters/local-sources.js +52 -43
  11. package/dist/adapters/raw-evidence-git-diff.js +4 -2
  12. package/dist/adapters/raw-evidence-sanitize.js +15 -12
  13. package/dist/adapters/raw-evidence-transcript-file.js +32 -31
  14. package/dist/autostart-self-heal.js +18 -2
  15. package/dist/commands/agent-session-report.js +51 -59
  16. package/dist/commands/agent-setup-observed.js +30 -23
  17. package/dist/commands/agent-setup.js +96 -45
  18. package/dist/commands/autostart-heal.js +8 -2
  19. package/dist/commands/autostart-reading.js +3 -0
  20. package/dist/commands/backfill-evidence-outcomes.js +9 -6
  21. package/dist/commands/backfill-issues.js +3 -0
  22. package/dist/commands/backfill-scan.js +27 -22
  23. package/dist/commands/backfill-upload.js +69 -60
  24. package/dist/commands/backfill.js +26 -21
  25. package/dist/commands/brief-done.js +4 -1
  26. package/dist/commands/brief-edit.js +71 -46
  27. package/dist/commands/cal-detail.js +35 -0
  28. package/dist/commands/cal-overlay.js +106 -0
  29. package/dist/commands/cal.js +40 -23
  30. package/dist/commands/clean.js +4 -1
  31. package/dist/commands/docs-read.js +5 -2
  32. package/dist/commands/doctor-backfill.js +88 -71
  33. package/dist/commands/doctor-pipeline.js +59 -46
  34. package/dist/commands/doctor-usage-telemetry.js +2 -1
  35. package/dist/commands/heartbeat-post.js +116 -0
  36. package/dist/commands/heartbeat-token-lock.js +46 -33
  37. package/dist/commands/heartbeat.js +43 -125
  38. package/dist/commands/install-update.js +17 -15
  39. package/dist/commands/issue-show.js +27 -28
  40. package/dist/commands/issue-write.js +70 -23
  41. package/dist/commands/issue.js +68 -37
  42. package/dist/commands/jarvis-live-answer.js +99 -0
  43. package/dist/commands/jarvis-render.js +1 -94
  44. package/dist/commands/jarvis-trace.js +30 -22
  45. package/dist/commands/jarvis-turn-report.js +124 -0
  46. package/dist/commands/jarvis-turn.js +108 -182
  47. package/dist/commands/jarvis.js +26 -17
  48. package/dist/commands/local-args-collector-setup.js +82 -84
  49. package/dist/commands/local-args-collector-work.js +37 -38
  50. package/dist/commands/local-args-tower-admin.js +155 -142
  51. package/dist/commands/local-args-tower-cal.js +54 -48
  52. package/dist/commands/local-args-tower-careers.js +73 -54
  53. package/dist/commands/local-args-tower-docs-msg.js +56 -188
  54. package/dist/commands/local-args-tower-github.js +26 -20
  55. package/dist/commands/local-args-tower-hits.js +32 -20
  56. package/dist/commands/local-args-tower-manager.js +85 -4
  57. package/dist/commands/local-args-tower-memory.js +139 -117
  58. package/dist/commands/local-args-tower-msg.js +149 -0
  59. package/dist/commands/local-args-tower-notes.js +346 -0
  60. package/dist/commands/local-args-tower-pages.js +100 -378
  61. package/dist/commands/local-args-tower-settings.js +28 -29
  62. package/dist/commands/local-args-tower-work.js +52 -33
  63. package/dist/commands/local-args.js +80 -129
  64. package/dist/commands/local-help-commands-tower-ops.js +15 -4
  65. package/dist/commands/local-help-commands-tower.js +11 -4
  66. package/dist/commands/local-help-commands.js +342 -341
  67. package/dist/commands/local-help.js +87 -83
  68. package/dist/commands/local-run-guards.js +100 -0
  69. package/dist/commands/local.js +137 -206
  70. package/dist/commands/login.js +13 -9
  71. package/dist/commands/logs.js +55 -38
  72. package/dist/commands/mail-attachments.js +44 -26
  73. package/dist/commands/mail.js +6 -4
  74. package/dist/commands/manager-render-home.js +208 -0
  75. package/dist/commands/manager-render.js +7 -3
  76. package/dist/commands/manager-requests.js +33 -0
  77. package/dist/commands/manager.js +39 -5
  78. package/dist/commands/memory-consolidation.js +18 -9
  79. package/dist/commands/memory-daemon-probe.js +37 -28
  80. package/dist/commands/memory-install-config.js +64 -60
  81. package/dist/commands/memory-install.js +21 -41
  82. package/dist/commands/memory-requeue.js +26 -19
  83. package/dist/commands/memory-rules.js +26 -18
  84. package/dist/commands/memory-search-print.js +26 -0
  85. package/dist/commands/memory-store.js +14 -30
  86. package/dist/commands/memory-verb-list.js +49 -48
  87. package/dist/commands/msg.js +51 -9
  88. package/dist/commands/notes-accounts.js +49 -32
  89. package/dist/commands/notes-sync-loop.js +115 -89
  90. package/dist/commands/notes-wisprflow-login.js +44 -23
  91. package/dist/commands/notes-writes.js +115 -100
  92. package/dist/commands/notes.js +33 -55
  93. package/dist/commands/onboard-flows.js +69 -51
  94. package/dist/commands/onboard.js +11 -9
  95. package/dist/commands/ops-render-memory.js +10 -7
  96. package/dist/commands/ops-render.js +33 -30
  97. package/dist/commands/ops.js +3 -0
  98. package/dist/commands/public-root.js +1 -1
  99. package/dist/commands/session-sync-outside-roots.js +40 -36
  100. package/dist/commands/session-sync-plan.js +14 -10
  101. package/dist/commands/session-sync-upload.js +24 -22
  102. package/dist/commands/session-sync.js +19 -8
  103. package/dist/commands/slack.js +25 -16
  104. package/dist/commands/sources-check.js +13 -7
  105. package/dist/commands/sources-discovery.js +25 -17
  106. package/dist/commands/sources-verdict.js +19 -18
  107. package/dist/commands/speed.js +23 -19
  108. package/dist/commands/start.js +20 -16
  109. package/dist/commands/sync-doctor.js +52 -48
  110. package/dist/commands/sync.js +22 -16
  111. package/dist/commands/usage-telemetry-toml.js +71 -45
  112. package/dist/commands/usage.js +91 -67
  113. package/dist/commands/workbook-render.js +10 -7
  114. package/dist/evidence-reconcile-client.js +75 -64
  115. package/dist/evidence-redelivery-plan.js +37 -34
  116. package/dist/evidence-redelivery.js +32 -29
  117. package/dist/evidence-transcripts-client.js +20 -20
  118. package/dist/evidence-upload-object.js +86 -89
  119. package/dist/evidence-upload-plan.js +94 -83
  120. package/dist/evidence-upload-terminal.js +45 -50
  121. package/dist/image-description-session.js +156 -159
  122. package/dist/local-state-pairing-code.js +39 -31
  123. package/dist/local-state-status.js +9 -2
  124. package/dist/onboarding-roots.js +5 -8
  125. package/dist/process-runner.js +8 -8
  126. package/dist/raw-evidence-gc.js +33 -38
  127. package/dist/raw-evidence-stuck-summary.js +7 -14
  128. package/dist/repo-identity.js +26 -23
  129. package/dist/scheduled-self-update.js +11 -15
  130. package/dist/second-install.js +3 -0
  131. package/dist/slim/codex-rules.js +23 -36
  132. package/dist/sync-lock.js +5 -15
  133. package/dist/tower-client.js +36 -53
  134. package/dist/tower-stream.js +7 -24
  135. package/dist/upload-agent-artifacts.js +58 -64
  136. package/dist/upload-envelope-build.js +60 -59
  137. package/dist/upload-evidence-delivery-reconcile.js +58 -56
  138. package/dist/upload-evidence-delivery-summary.js +32 -34
  139. package/dist/upload-ingest-receipt.js +20 -23
  140. package/dist/upload-session-reports-queue.js +4 -1
  141. package/dist/upload-session-reports-wire.js +64 -68
  142. package/dist/upload-sync.js +171 -157
  143. package/package.json +2 -2
@@ -8,55 +8,9 @@ export async function loadPlannedEntries(options, chunkSizeBytes, outcomes) {
8
8
  const loaded = [];
9
9
  const loadedByObjectKey = new Map();
10
10
  for (const file of options.files) {
11
- let stat;
12
- try {
13
- stat = await fs.stat(file.local_path);
14
- }
15
- catch (error) {
16
- // `file_read_failed` is a durable ledger label and stays. Beside it: a
17
- // staged object that vanished (the GC won a race) and one the process
18
- // has no permission to open are the same word and opposite repairs
19
- // (BLI-3238).
20
- console.error("[evidence-upload] staged object could not be stat'd", JSON.stringify({
21
- reason: "file_read_failed",
22
- stage: "stat",
23
- ...describeError(error),
24
- }));
25
- outcomes.push(failedOutcome(file, "file_read_failed"));
26
- continue;
27
- }
28
- if (stat.size === 0) {
29
- outcomes.push(failedOutcome(file, "empty_file"));
11
+ const bytes = await readStagedUploadBytes(file, outcomes);
12
+ if (!bytes)
30
13
  continue;
31
- }
32
- if (stat.size > RAW_EVIDENCE_UPLOAD_MAX_FILE_BYTES) {
33
- outcomes.push(failedOutcome(file, "file_too_large"));
34
- continue;
35
- }
36
- let bytes;
37
- try {
38
- bytes = await fs.readFile(file.local_path);
39
- }
40
- catch (error) {
41
- // Stat succeeded and the read did not — narrower than the stat failure
42
- // above and worth telling apart, so it carries its own stage label.
43
- console.error("[evidence-upload] staged object could not be read after a successful stat", JSON.stringify({
44
- reason: "file_read_failed",
45
- stage: "read",
46
- byte_size: stat.size,
47
- ...describeError(error),
48
- }));
49
- outcomes.push(failedOutcome(file, "file_read_failed"));
50
- continue;
51
- }
52
- if (bytes.byteLength === 0) {
53
- outcomes.push(failedOutcome(file, "empty_file"));
54
- continue;
55
- }
56
- if (bytes.byteLength > RAW_EVIDENCE_UPLOAD_MAX_FILE_BYTES) {
57
- outcomes.push(failedOutcome(file, "file_too_large"));
58
- continue;
59
- }
60
14
  // Content-addressed keys collide for byte-identical files (e.g. a resumed
61
15
  // session copied twice); upload once and share the outcome instead of
62
16
  // racing a second upload against a committed ledger row.
@@ -80,6 +34,58 @@ export async function loadPlannedEntries(options, chunkSizeBytes, outcomes) {
80
34
  }
81
35
  return loaded;
82
36
  }
37
+ async function readStagedUploadBytes(file, outcomes) {
38
+ let stat;
39
+ try {
40
+ stat = await fs.stat(file.local_path);
41
+ }
42
+ catch (error) {
43
+ // `file_read_failed` is a durable ledger label and stays. Beside it: a
44
+ // staged object that vanished (the GC won a race) and one the process
45
+ // has no permission to open are the same word and opposite repairs
46
+ // (BLI-3238).
47
+ console.error("[evidence-upload] staged object could not be stat'd", JSON.stringify({
48
+ reason: "file_read_failed",
49
+ stage: "stat",
50
+ ...describeError(error),
51
+ }));
52
+ outcomes.push(failedOutcome(file, "file_read_failed"));
53
+ return null;
54
+ }
55
+ if (stat.size === 0) {
56
+ outcomes.push(failedOutcome(file, "empty_file"));
57
+ return null;
58
+ }
59
+ if (stat.size > RAW_EVIDENCE_UPLOAD_MAX_FILE_BYTES) {
60
+ outcomes.push(failedOutcome(file, "file_too_large"));
61
+ return null;
62
+ }
63
+ let bytes;
64
+ try {
65
+ bytes = await fs.readFile(file.local_path);
66
+ }
67
+ catch (error) {
68
+ // Stat succeeded and the read did not — narrower than the stat failure
69
+ // above and worth telling apart, so it carries its own stage label.
70
+ console.error("[evidence-upload] staged object could not be read after a successful stat", JSON.stringify({
71
+ reason: "file_read_failed",
72
+ stage: "read",
73
+ byte_size: stat.size,
74
+ ...describeError(error),
75
+ }));
76
+ outcomes.push(failedOutcome(file, "file_read_failed"));
77
+ return null;
78
+ }
79
+ if (bytes.byteLength === 0) {
80
+ outcomes.push(failedOutcome(file, "empty_file"));
81
+ return null;
82
+ }
83
+ if (bytes.byteLength > RAW_EVIDENCE_UPLOAD_MAX_FILE_BYTES) {
84
+ outcomes.push(failedOutcome(file, "file_too_large"));
85
+ return null;
86
+ }
87
+ return bytes;
88
+ }
83
89
  export function batches(items, size) {
84
90
  const out = [];
85
91
  for (let offset = 0; offset < items.length; offset += size) {
@@ -112,43 +118,11 @@ export async function resolveBeginBatch(options, totalLoadedCount, batch, chunkS
112
118
  // (after retries) covers a new dashboard whose ledger migration has not
113
119
  // been applied yet. Both still serve the legacy v1 route.
114
120
  if (begin.status === 404 || begin.status >= 500) {
115
- // Until BLI-3483 this downgrade abandoned the entire chunked path
116
- // without a word, and the comment above named two causes that the fleet
117
- // had no way to tell apart — an old dashboard versus an unapplied ledger
118
- // migration. This is the BLI-2528 shape exactly: the code knew, the
119
- // operator did not. Once per run, because `break` leaves the loop.
120
- console.error("[evidence-upload] chunked upload unavailable; falling back to the legacy single-shot route", JSON.stringify({
121
- reason: begin.status === 404
122
- ? "begin_route_absent"
123
- : "begin_server_error_after_retries",
124
- http_status: begin.status,
125
- server_reason: safeFailureDetail(begin.body) ?? "none",
126
- objects_in_batch: batch.length,
127
- objects_unresolved: totalLoadedCount - resolvedEntries.size,
128
- }));
121
+ reportBeginFallback(begin, batch.length, totalLoadedCount - resolvedEntries.size);
129
122
  return true;
130
123
  }
131
- if (!begin.ok) {
132
- // The server's own `{ reason }` sat unread in this body while the ledger
133
- // recorded the transport status and nothing else (BLI-3483); the commit
134
- // path has read it since BLI-2528 and this one now does the same.
135
- const beginDetail = safeFailureDetail(begin.body);
136
- const beginReason = `begin_failed_http_${begin.status}${beginDetail ? `_${beginDetail}` : ""}`;
137
- console.error("[evidence-upload] begin refused these objects", JSON.stringify({
138
- reason: "begin_rejected",
139
- http_status: begin.status,
140
- server_reason: beginDetail ?? "none",
141
- objects_in_batch: batch.length,
142
- }));
143
- for (const entry of batch) {
144
- outcomes.push(failedOutcome(entry.file, beginReason));
145
- for (const duplicate of entry.duplicates) {
146
- outcomes.push(failedOutcome(duplicate, beginReason));
147
- }
148
- resolvedEntries.add(entry);
149
- }
150
- return false;
151
- }
124
+ if (!begin.ok)
125
+ return rejectBeginBatch(begin, batch, outcomes, resolvedEntries);
152
126
  const parsedBegin = RawEvidenceUploadBeginResponseSchema.safeParse(begin.body);
153
127
  if (!parsedBegin.success) {
154
128
  for (const entry of batch) {
@@ -177,6 +151,43 @@ export async function resolveBeginBatch(options, totalLoadedCount, batch, chunkS
177
151
  }
178
152
  return false;
179
153
  }
154
+ function rejectBeginBatch(begin, batch, outcomes, resolvedEntries) {
155
+ // The server's own `{ reason }` sat unread in this body while the ledger
156
+ // recorded the transport status and nothing else (BLI-3483); the commit
157
+ // path has read it since BLI-2528 and this one now does the same.
158
+ const beginDetail = safeFailureDetail(begin.body);
159
+ const beginReason = `begin_failed_http_${begin.status}${beginDetail ? `_${beginDetail}` : ""}`;
160
+ console.error("[evidence-upload] begin refused these objects", JSON.stringify({
161
+ reason: "begin_rejected",
162
+ http_status: begin.status,
163
+ server_reason: beginDetail ?? "none",
164
+ objects_in_batch: batch.length,
165
+ }));
166
+ for (const entry of batch) {
167
+ outcomes.push(failedOutcome(entry.file, beginReason));
168
+ for (const duplicate of entry.duplicates) {
169
+ outcomes.push(failedOutcome(duplicate, beginReason));
170
+ }
171
+ resolvedEntries.add(entry);
172
+ }
173
+ return false;
174
+ }
175
+ function reportBeginFallback(begin, objectsInBatch, objectsUnresolved) {
176
+ // Until BLI-3483 this downgrade abandoned the entire chunked path
177
+ // without a word, and the comment above named two causes that the fleet
178
+ // had no way to tell apart — an old dashboard versus an unapplied ledger
179
+ // migration. This is the BLI-2528 shape exactly: the code knew, the
180
+ // operator did not. Once per run, because `break` leaves the loop.
181
+ console.error("[evidence-upload] chunked upload unavailable; falling back to the legacy single-shot route", JSON.stringify({
182
+ reason: begin.status === 404
183
+ ? "begin_route_absent"
184
+ : "begin_server_error_after_retries",
185
+ http_status: begin.status,
186
+ server_reason: safeFailureDetail(begin.body) ?? "none",
187
+ objects_in_batch: objectsInBatch,
188
+ objects_unresolved: objectsUnresolved,
189
+ }));
190
+ }
180
191
  /**
181
192
  * One accepted object: settle a re-keyable conflict if `begin` reported one
182
193
  * (BLI-3552), then chunk/commit it, then tell the server if the attempt was
@@ -11,19 +11,9 @@ import { requestJson, safeFailureDetail } from "./evidence-upload-transport.js";
11
11
  * summarize a run's outcomes into the shape every caller reads.
12
12
  */
13
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.
14
+ * A committed key holding different bytes needs a new content-derived name.
15
+ * Retry re-keying once without deleting durable evidence. If no safe name can
16
+ * be offered, retain the original conflict so the cursor never claims success.
27
17
  */
28
18
  export async function rekeyConflictedEntry(options, entry, disposition, chunkSizeBytes) {
29
19
  if (disposition.disposition !== "conflict" ||
@@ -216,43 +206,7 @@ export async function uploadWithLegacyFallback(options, loaded, outcomes) {
216
206
  },
217
207
  ],
218
208
  });
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
- }
209
+ outcome = legacyUploadOutcome(entry.file, response);
256
210
  outcomes.push(outcome);
257
211
  for (const duplicate of entry.duplicates) {
258
212
  outcomes.push(duplicateOutcome(outcome, duplicate));
@@ -260,6 +214,47 @@ export async function uploadWithLegacyFallback(options, loaded, outcomes) {
260
214
  }
261
215
  return summarizeOutcomes(outcomes, true);
262
216
  }
217
+ function legacyUploadOutcome(file, response) {
218
+ let outcome;
219
+ if (response.ok) {
220
+ const parsedLegacy = RawEvidenceLegacyUploadResponseSchema.safeParse(response.body);
221
+ if (!parsedLegacy.success) {
222
+ outcome = failedOutcome(file, "legacy_upload_invalid_response");
223
+ }
224
+ else {
225
+ const receipt = parsedLegacy.data.uploaded.find((candidate) => candidate.raw_evidence_pointer_id ===
226
+ file.pointer.raw_evidence_pointer_id);
227
+ if (!receipt ||
228
+ parsedLegacy.data.bucket !== file.pointer.storage_bucket ||
229
+ !legacyUploadReceiptMatchesPointer(file.pointer, receipt)) {
230
+ outcome = failedOutcome(file, "legacy_upload_receipt_mismatch");
231
+ }
232
+ else {
233
+ const pointer = pointerWithLegacyUploadResponse(file.pointer, parsedLegacy.data);
234
+ outcome = {
235
+ pointer,
236
+ object_key: file.pointer.object_key ?? "",
237
+ codex_session_id: file.codex_session_id ?? null,
238
+ kind: file.kind ?? "unknown",
239
+ artifact_metadata: 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(file, "legacy_upload_conflict_http_409");
252
+ }
253
+ else {
254
+ outcome = failedOutcome(file, `legacy_upload_failed_http_${response.status}`);
255
+ }
256
+ return outcome;
257
+ }
263
258
  function pointerWithLegacyUploadResponse(pointer, body) {
264
259
  const entry = body.uploaded.find((candidate) => candidate.raw_evidence_pointer_id === pointer.raw_evidence_pointer_id);
265
260
  if (!entry)
@@ -1,25 +1,9 @@
1
1
  /**
2
- * One sync's handling of the images inside transcripts (BLI-16977).
3
- *
4
- * `prepareTranscript` takes a transcript's text just before it is masked and staged and does four
5
- * things, in this order:
6
- *
7
- * 1. FIND every embedded image (`transcript-images.ts` in telemetry-core) and group them by sha256.
8
- * 2. DESCRIBE each distinct image that this machine has not already sent: one POST per image to
9
- * the describe door, a few at a time, inside a per-sync count and time budget. The state file
10
- * remembers what was sent, so a screenshot that appears in 40 turns, or in 3 sessions, is sent
11
- * once. A failure never fails the transcript.
12
- * 3. REPLACE, only when this machine's mode (set per machine from Tower, kept on file by
13
- * `image-mode-state.ts`; the fleet default is `IMAGE_HANDLING_MODE` in telemetry-core) says
14
- * `describe_and_drop`: every image whose description is stored becomes its short marker. An
15
- * image with no stored description is KEPT. In `describe_and_keep` (the default) the text
16
- * comes back byte for byte as it went in.
17
- * 4. SLIM, only in `describe_and_drop`: the slim transcript (src/slim/): the duplicate copies and
18
- * the bookkeeping go, every line stays. In `describe_and_keep` the slim form is not computed
19
- * and not uploaded.
20
- *
21
- * Metadata only in the logs: counts, sizes, hashes, reason labels. Never image bytes, never a
22
- * description.
2
+ * Share image descriptions and count/time budgets across one sync's transcripts.
3
+ * Describe each distinct image once. Keep images without stored descriptions;
4
+ * only describe_and_drop replaces described images and then runs the slim rules.
5
+ * Failures never fail the transcript. Logs contain metadata, never image bytes
6
+ * or descriptions.
23
7
  */
24
8
  import { IMAGE_DESCRIBE_MAX_BYTES, IMAGE_HANDLING_MODE, base64Of, findEmbeddedImages, replaceImagesWithMarkers, } from "@bli-cockpit/telemetry-core";
25
9
  import { postImageToDescribeDoor } from "./image-describe-client.js";
@@ -38,144 +22,18 @@ export async function openImageDescriptions(options) {
38
22
  const described = new Set(await readDescribedImageKeys(options.stateDir));
39
23
  const orderedKeys = [...described];
40
24
  const failedImages = await readFailedImages(options.stateDir);
41
- let stateDirty = false;
25
+ const stateDirty = false;
42
26
  const settledThisRun = new Map();
43
27
  const now = options.now ?? Date.now;
44
28
  const startedAt = now();
45
29
  const maxPerRun = options.maxPerRun ?? IMAGE_DESCRIBE_MAX_PER_RUN;
46
30
  const maxRunMs = options.maxRunMs ?? IMAGE_DESCRIBE_MAX_RUN_MS;
47
- let sentThisRun = 0;
48
- const markDescribed = (sha256) => {
49
- const key = imageStateKey(sha256);
50
- if (described.has(key))
51
- return;
52
- described.add(key);
53
- orderedKeys.push(key);
54
- };
55
- async function sendOne(image, text, input) {
56
- sentThisRun += 1;
57
- const outcome = await postImageToDescribeDoor({ fetchImpl, dashboardUrl: options.dashboardUrl, deviceToken: options.deviceToken, sleep: options.sleep }, {
58
- schema_version: "image-describe.v1",
59
- sha256: image.sha256,
60
- media_type: image.mediaType,
61
- byte_size: image.byteSize,
62
- content_base64: base64Of(text, image),
63
- source: input.source,
64
- session_id: input.sessionId.slice(0, 200),
65
- });
66
- const key = imageStateKey(image.sha256);
67
- if (outcome.ok) {
68
- markDescribed(image.sha256);
69
- settledThisRun.set(image.sha256, outcome.status);
70
- if (failedImages[key]) {
71
- delete failedImages[key];
72
- stateDirty = true;
73
- }
74
- return;
75
- }
76
- settledThisRun.set(image.sha256, `failed:${outcome.reason}`);
77
- rememberFailure(key, outcome);
78
- }
79
- /**
80
- * BLI-16979: the door answered about THIS image and could not describe it (a 4xx such as
81
- * `describer_rejected_image`, or a 5xx). Wait before sending the same bytes again, instead of
82
- * re-sending them every sync. A transport error, a 401 and a 429 say nothing about the image, so
83
- * they are not remembered.
84
- */
85
- function rememberFailure(key, outcome) {
86
- if (outcome.httpStatus < 400 || outcome.httpStatus === 401 || outcome.httpStatus === 429)
87
- return;
88
- const attempts = (failedImages[key]?.attempts ?? 0) + 1;
89
- const delay = imageRetryDelayMs(attempts, outcome.permanent);
90
- failedImages[key] = { reason: outcome.reason, attempts, retry_after: now() + delay };
91
- stateDirty = true;
92
- console.error(TAG + " image not described, will not be re-sent until later", JSON.stringify({ reason: outcome.reason, http_status: outcome.httpStatus, attempts, retry_in_hours: Math.round(delay / 3_600_000), sha256_prefix: key.slice(0, 16) }));
93
- }
94
- async function describeAndReplace(input) {
95
- const images = findEmbeddedImages(input.text);
96
- if (images.length === 0)
97
- return { text: input.text, imageCount: 0, uniqueCount: 0, replacedCount: 0, charsSaved: 0 };
98
- const unique = new Map();
99
- for (const image of images)
100
- if (!unique.has(image.sha256))
101
- unique.set(image.sha256, image);
102
- const counts = { already: 0, described_now: 0, known: 0, too_large: 0, over_budget: 0, failed: 0, backing_off: 0 };
103
- const failedReasons = {};
104
- const toSend = [];
105
- for (const image of unique.values()) {
106
- if (described.has(imageStateKey(image.sha256))) {
107
- counts.already += 1;
108
- continue;
109
- }
110
- const earlier = settledThisRun.get(image.sha256);
111
- if (earlier)
112
- continue;
113
- if (image.byteSize > IMAGE_DESCRIBE_MAX_BYTES) {
114
- settledThisRun.set(image.sha256, "too_large");
115
- counts.too_large += 1;
116
- continue;
117
- }
118
- const earlierFailure = failedImages[imageStateKey(image.sha256)];
119
- if (earlierFailure && earlierFailure.retry_after > now()) {
120
- counts.backing_off += 1;
121
- continue;
122
- }
123
- toSend.push(image);
124
- }
125
- let next = 0;
126
- const worker = async () => {
127
- while (next < toSend.length) {
128
- const image = toSend[next++];
129
- if (sentThisRun >= maxPerRun || now() - startedAt > maxRunMs) {
130
- counts.over_budget += 1;
131
- continue;
132
- }
133
- await sendOne(image, input.text, input);
134
- }
135
- };
136
- await Promise.all(Array.from({ length: Math.min(CONCURRENCY, toSend.length) }, worker));
137
- for (const image of toSend) {
138
- const settled = settledThisRun.get(image.sha256);
139
- if (settled === "described")
140
- counts.described_now += 1;
141
- else if (settled === "known")
142
- counts.known += 1;
143
- else if (settled?.startsWith("failed:")) {
144
- counts.failed += 1;
145
- const reason = settled.slice("failed:".length);
146
- failedReasons[reason] = (failedReasons[reason] ?? 0) + 1;
147
- }
148
- }
149
- if (counts.described_now + counts.known > 0 || stateDirty) {
150
- await writeImageDescriptionState(options.stateDir, { described: orderedKeys, failed: failedImages });
151
- stateDirty = false;
152
- }
153
- const replaced = mode === "describe_and_drop"
154
- ? replaceImagesWithMarkers(input.text, images, (image) => described.has(imageStateKey(image.sha256)))
155
- : { text: input.text, replacedCount: 0, charsSaved: 0 };
156
- console.error(TAG + " transcript prepared", JSON.stringify({
157
- mode,
158
- source: input.source,
159
- images: images.length,
160
- unique: unique.size,
161
- ...counts,
162
- failed_reasons: failedReasons,
163
- replaced: replaced.replacedCount,
164
- chars_saved: replaced.charsSaved,
165
- kept_without_description: mode === "describe_and_drop" ? images.length - replaced.replacedCount : 0,
166
- }));
167
- return {
168
- text: replaced.text,
169
- imageCount: images.length,
170
- uniqueCount: unique.size,
171
- replacedCount: replaced.replacedCount,
172
- charsSaved: replaced.charsSaved,
173
- };
174
- }
31
+ const sentThisRun = 0;
32
+ const run = { options, mode, fetchImpl, described, orderedKeys, failedImages, stateDirty, settledThisRun, now, startedAt, maxPerRun, maxRunMs, sentThisRun };
175
33
  return {
176
34
  mode,
177
35
  async prepareTranscript(input) {
178
- const afterImages = await describeAndReplace(input);
36
+ const afterImages = await describeAndReplace(run, input);
179
37
  if (mode !== "describe_and_drop")
180
38
  return { ...afterImages, text: input.text, changed: false, slim: null };
181
39
  const slim = slimOrKeep(afterImages.text, input.source, { sessionId: input.sessionId });
@@ -183,11 +41,152 @@ export async function openImageDescriptions(options) {
183
41
  },
184
42
  };
185
43
  }
44
+ function markDescribed(run, sha256) {
45
+ const { described, orderedKeys } = run;
46
+ const key = imageStateKey(sha256);
47
+ if (described.has(key))
48
+ return;
49
+ described.add(key);
50
+ orderedKeys.push(key);
51
+ }
52
+ async function sendOne(run, image, text, input) {
53
+ const { options, fetchImpl, settledThisRun, failedImages } = run;
54
+ run.sentThisRun += 1;
55
+ const outcome = await postImageToDescribeDoor({ fetchImpl, dashboardUrl: options.dashboardUrl, deviceToken: options.deviceToken, sleep: options.sleep }, {
56
+ schema_version: "image-describe.v1",
57
+ sha256: image.sha256,
58
+ media_type: image.mediaType,
59
+ byte_size: image.byteSize,
60
+ content_base64: base64Of(text, image),
61
+ source: input.source,
62
+ session_id: input.sessionId.slice(0, 200),
63
+ });
64
+ const key = imageStateKey(image.sha256);
65
+ if (outcome.ok) {
66
+ markDescribed(run, image.sha256);
67
+ settledThisRun.set(image.sha256, outcome.status);
68
+ if (failedImages[key]) {
69
+ delete failedImages[key];
70
+ run.stateDirty = true;
71
+ }
72
+ return;
73
+ }
74
+ settledThisRun.set(image.sha256, `failed:${outcome.reason}`);
75
+ rememberFailure(run, key, outcome);
76
+ }
77
+ /**
78
+ * Remember image-specific HTTP failures for backoff. Transport failures, 401
79
+ * and 429 say nothing about the image and must not be remembered.
80
+ */
81
+ function rememberFailure(run, key, outcome) {
82
+ const { failedImages, now } = run;
83
+ if (outcome.httpStatus < 400 || outcome.httpStatus === 401 || outcome.httpStatus === 429)
84
+ return;
85
+ const attempts = (failedImages[key]?.attempts ?? 0) + 1;
86
+ const delay = imageRetryDelayMs(attempts, outcome.permanent);
87
+ failedImages[key] = { reason: outcome.reason, attempts, retry_after: now() + delay };
88
+ run.stateDirty = true;
89
+ console.error(TAG + " image not described, will not be re-sent until later", JSON.stringify({ reason: outcome.reason, http_status: outcome.httpStatus, attempts, retry_in_hours: Math.round(delay / 3_600_000), sha256_prefix: key.slice(0, 16) }));
90
+ }
91
+ async function describeAndReplace(run, input) {
92
+ const { mode, described, options, orderedKeys, failedImages } = run;
93
+ const images = findEmbeddedImages(input.text);
94
+ if (images.length === 0)
95
+ return { text: input.text, imageCount: 0, uniqueCount: 0, replacedCount: 0, charsSaved: 0 };
96
+ const unique = new Map();
97
+ for (const image of images)
98
+ if (!unique.has(image.sha256))
99
+ unique.set(image.sha256, image);
100
+ const counts = { already: 0, described_now: 0, known: 0, too_large: 0, over_budget: 0, failed: 0, backing_off: 0 };
101
+ const toSend = selectImagesToDescribe(run, unique, counts);
102
+ await sendPendingImages(run, toSend, counts, input);
103
+ const failedReasons = countImageOutcomes(run, toSend, counts);
104
+ if (counts.described_now + counts.known > 0 || run.stateDirty) {
105
+ await writeImageDescriptionState(options.stateDir, { described: orderedKeys, failed: failedImages });
106
+ run.stateDirty = false;
107
+ }
108
+ const replaced = mode === "describe_and_drop"
109
+ ? replaceImagesWithMarkers(input.text, images, (image) => described.has(imageStateKey(image.sha256)))
110
+ : { text: input.text, replacedCount: 0, charsSaved: 0 };
111
+ console.error(TAG + " transcript prepared", JSON.stringify({
112
+ mode,
113
+ source: input.source,
114
+ images: images.length,
115
+ unique: unique.size,
116
+ ...counts,
117
+ failed_reasons: failedReasons,
118
+ replaced: replaced.replacedCount,
119
+ chars_saved: replaced.charsSaved,
120
+ kept_without_description: mode === "describe_and_drop" ? images.length - replaced.replacedCount : 0,
121
+ }));
122
+ return {
123
+ text: replaced.text,
124
+ imageCount: images.length,
125
+ uniqueCount: unique.size,
126
+ replacedCount: replaced.replacedCount,
127
+ charsSaved: replaced.charsSaved,
128
+ };
129
+ }
130
+ function selectImagesToDescribe(run, unique, counts) {
131
+ const { described, settledThisRun, failedImages, now } = run;
132
+ const toSend = [];
133
+ for (const image of unique.values()) {
134
+ if (described.has(imageStateKey(image.sha256))) {
135
+ counts.already += 1;
136
+ continue;
137
+ }
138
+ const earlier = settledThisRun.get(image.sha256);
139
+ if (earlier)
140
+ continue;
141
+ if (image.byteSize > IMAGE_DESCRIBE_MAX_BYTES) {
142
+ settledThisRun.set(image.sha256, "too_large");
143
+ counts.too_large += 1;
144
+ continue;
145
+ }
146
+ const earlierFailure = failedImages[imageStateKey(image.sha256)];
147
+ if (earlierFailure && earlierFailure.retry_after > now()) {
148
+ counts.backing_off += 1;
149
+ continue;
150
+ }
151
+ toSend.push(image);
152
+ }
153
+ return toSend;
154
+ }
155
+ async function sendPendingImages(run, toSend, counts, input) {
156
+ const { now, maxPerRun, startedAt, maxRunMs } = run;
157
+ let next = 0;
158
+ const worker = async () => {
159
+ while (next < toSend.length) {
160
+ const image = toSend[next++];
161
+ if (run.sentThisRun >= maxPerRun || now() - startedAt > maxRunMs) {
162
+ counts.over_budget += 1;
163
+ continue;
164
+ }
165
+ await sendOne(run, image, input.text, input);
166
+ }
167
+ };
168
+ await Promise.all(Array.from({ length: Math.min(CONCURRENCY, toSend.length) }, worker));
169
+ }
170
+ function countImageOutcomes(run, toSend, counts) {
171
+ const { settledThisRun } = run;
172
+ const failedReasons = {};
173
+ for (const image of toSend) {
174
+ const settled = settledThisRun.get(image.sha256);
175
+ if (settled === "described")
176
+ counts.described_now += 1;
177
+ else if (settled === "known")
178
+ counts.known += 1;
179
+ else if (settled?.startsWith("failed:")) {
180
+ counts.failed += 1;
181
+ const reason = settled.slice("failed:".length);
182
+ failedReasons[reason] = (failedReasons[reason] ?? 0) + 1;
183
+ }
184
+ }
185
+ return failedReasons;
186
+ }
186
187
  /**
187
- * What the uploader sends for this transcript text, worked out offline (BLI-17013). Keep mode: the
188
- * text as it is. Drop mode: every image whose key is in `describedKeys` becomes its marker, then
189
- * the slim rules run, the same two steps as `prepareTranscript` above, with no describe-door call.
190
- * `cockpit sources check` uses it so a complete upload is judged against the bytes actually uploaded.
188
+ * Let source checks compare against the actual upload form without network calls.
189
+ * Drop mode replaces known images, then slims; keep mode preserves all text.
191
190
  */
192
191
  export function uploadFormOfTranscript(text, source, mode, describedKeys) {
193
192
  if (mode !== "describe_and_drop")
@@ -197,10 +196,8 @@ export function uploadFormOfTranscript(text, source, mode, describedKeys) {
197
196
  return slimTranscript(replaced.text, source).text;
198
197
  }
199
198
  /**
200
- * The image step for one build of an envelope. When a sync-wide budget is shared across worktrees
201
- * the session lives on it (one count budget and one state file for the whole sync); otherwise this
202
- * build gets its own. A failure to open it is logged by name and the transcripts simply go up as
203
- * they are.
199
+ * Reuse the sync-wide image budget across worktrees. If opening fails, log the
200
+ * reason and upload the original transcripts.
204
201
  */
205
202
  export async function openImageStepForSync(options) {
206
203
  if (options.budget?.images)