@bli-cockpit/cli 0.2.237 → 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.
- package/dist/adapters/attribution-core-fallbacks.js +15 -11
- package/dist/adapters/attribution-core-score.js +111 -87
- package/dist/adapters/car-state.js +38 -35
- package/dist/adapters/claude-attribution-discovery.js +81 -56
- package/dist/adapters/claude-attribution-score.js +108 -94
- package/dist/adapters/claude-attribution-signals.js +52 -42
- package/dist/adapters/codex-attribution.js +42 -33
- package/dist/adapters/codex-session-signals.js +11 -9
- package/dist/adapters/git-state.js +56 -50
- package/dist/adapters/local-sources.js +52 -43
- package/dist/adapters/raw-evidence-git-diff.js +4 -2
- package/dist/adapters/raw-evidence-sanitize.js +15 -12
- package/dist/adapters/raw-evidence-transcript-file.js +32 -31
- package/dist/autostart-self-heal.js +18 -2
- package/dist/commands/agent-session-report.js +51 -59
- package/dist/commands/agent-setup-observed.js +30 -23
- package/dist/commands/agent-setup.js +96 -45
- package/dist/commands/autostart-heal.js +8 -2
- package/dist/commands/autostart-reading.js +3 -0
- package/dist/commands/backfill-evidence-outcomes.js +9 -6
- package/dist/commands/backfill-issues.js +3 -0
- package/dist/commands/backfill-scan.js +27 -22
- package/dist/commands/backfill-upload.js +69 -60
- package/dist/commands/backfill.js +26 -21
- package/dist/commands/brief-done.js +4 -1
- package/dist/commands/brief-edit.js +71 -46
- package/dist/commands/cal-detail.js +35 -0
- package/dist/commands/cal-overlay.js +73 -30
- package/dist/commands/cal.js +36 -23
- package/dist/commands/clean.js +4 -1
- package/dist/commands/docs-read.js +5 -2
- package/dist/commands/doctor-backfill.js +88 -71
- package/dist/commands/doctor-pipeline.js +59 -46
- package/dist/commands/heartbeat-post.js +116 -0
- package/dist/commands/heartbeat-token-lock.js +46 -33
- package/dist/commands/heartbeat.js +43 -125
- package/dist/commands/install-update.js +17 -15
- package/dist/commands/issue-show.js +27 -28
- package/dist/commands/issue-write.js +70 -23
- package/dist/commands/issue.js +68 -37
- package/dist/commands/jarvis-live-answer.js +99 -0
- package/dist/commands/jarvis-render.js +1 -94
- package/dist/commands/jarvis-trace.js +30 -22
- package/dist/commands/jarvis-turn-report.js +124 -0
- package/dist/commands/jarvis-turn.js +108 -182
- package/dist/commands/jarvis.js +26 -17
- package/dist/commands/local-args-collector-setup.js +82 -84
- package/dist/commands/local-args-collector-work.js +37 -38
- package/dist/commands/local-args-tower-admin.js +155 -142
- package/dist/commands/local-args-tower-cal.js +52 -50
- package/dist/commands/local-args-tower-careers.js +73 -54
- package/dist/commands/local-args-tower-docs-msg.js +55 -45
- package/dist/commands/local-args-tower-github.js +26 -20
- package/dist/commands/local-args-tower-hits.js +32 -20
- package/dist/commands/local-args-tower-manager.js +85 -4
- package/dist/commands/local-args-tower-memory.js +139 -117
- package/dist/commands/local-args-tower-notes.js +346 -0
- package/dist/commands/local-args-tower-pages.js +100 -378
- package/dist/commands/local-args-tower-settings.js +28 -29
- package/dist/commands/local-args-tower-work.js +52 -33
- package/dist/commands/local-args.js +80 -129
- package/dist/commands/local-help-commands-tower-ops.js +15 -4
- package/dist/commands/local-help-commands-tower.js +6 -2
- package/dist/commands/local-help-commands.js +342 -341
- package/dist/commands/local-help.js +87 -83
- package/dist/commands/local-run-guards.js +100 -0
- package/dist/commands/local.js +137 -206
- package/dist/commands/login.js +13 -9
- package/dist/commands/logs.js +55 -38
- package/dist/commands/mail-attachments.js +44 -26
- package/dist/commands/manager-render-home.js +208 -0
- package/dist/commands/manager-render.js +7 -3
- package/dist/commands/manager-requests.js +33 -0
- package/dist/commands/manager.js +39 -5
- package/dist/commands/memory-consolidation.js +18 -9
- package/dist/commands/memory-daemon-probe.js +37 -28
- package/dist/commands/memory-install-config.js +64 -60
- package/dist/commands/memory-install.js +21 -41
- package/dist/commands/memory-requeue.js +26 -19
- package/dist/commands/memory-rules.js +26 -18
- package/dist/commands/memory-search-print.js +26 -0
- package/dist/commands/memory-store.js +14 -30
- package/dist/commands/memory-verb-list.js +49 -48
- package/dist/commands/notes-accounts.js +49 -32
- package/dist/commands/notes-sync-loop.js +115 -89
- package/dist/commands/notes-wisprflow-login.js +44 -23
- package/dist/commands/notes-writes.js +115 -100
- package/dist/commands/notes.js +33 -55
- package/dist/commands/onboard-flows.js +69 -51
- package/dist/commands/onboard.js +11 -9
- package/dist/commands/ops-render-memory.js +10 -7
- package/dist/commands/ops-render.js +33 -30
- package/dist/commands/ops.js +3 -0
- package/dist/commands/public-root.js +1 -1
- package/dist/commands/session-sync-outside-roots.js +40 -36
- package/dist/commands/session-sync-plan.js +14 -10
- package/dist/commands/session-sync-upload.js +24 -22
- package/dist/commands/session-sync.js +19 -8
- package/dist/commands/slack.js +25 -16
- package/dist/commands/sources-check.js +13 -7
- package/dist/commands/sources-discovery.js +25 -17
- package/dist/commands/sources-verdict.js +19 -18
- package/dist/commands/speed.js +23 -19
- package/dist/commands/start.js +20 -16
- package/dist/commands/sync-doctor.js +52 -48
- package/dist/commands/sync.js +22 -16
- package/dist/commands/usage-telemetry-toml.js +71 -45
- package/dist/commands/usage.js +91 -67
- package/dist/commands/workbook-render.js +10 -7
- package/dist/evidence-reconcile-client.js +75 -64
- package/dist/evidence-redelivery-plan.js +37 -34
- package/dist/evidence-redelivery.js +32 -29
- package/dist/evidence-transcripts-client.js +20 -20
- package/dist/evidence-upload-object.js +86 -89
- package/dist/evidence-upload-plan.js +94 -83
- package/dist/evidence-upload-terminal.js +45 -50
- package/dist/image-description-session.js +156 -159
- package/dist/local-state-pairing-code.js +39 -31
- package/dist/local-state-status.js +9 -2
- package/dist/onboarding-roots.js +5 -8
- package/dist/process-runner.js +8 -8
- package/dist/raw-evidence-gc.js +33 -38
- package/dist/raw-evidence-stuck-summary.js +7 -14
- package/dist/repo-identity.js +26 -23
- package/dist/scheduled-self-update.js +11 -15
- package/dist/second-install.js +3 -0
- package/dist/slim/codex-rules.js +23 -36
- package/dist/sync-lock.js +5 -15
- package/dist/tower-client.js +36 -53
- package/dist/tower-stream.js +7 -24
- package/dist/upload-agent-artifacts.js +58 -64
- package/dist/upload-envelope-build.js +60 -59
- package/dist/upload-evidence-delivery-reconcile.js +58 -56
- package/dist/upload-evidence-delivery-summary.js +32 -34
- package/dist/upload-ingest-receipt.js +20 -23
- package/dist/upload-session-reports-queue.js +4 -1
- package/dist/upload-session-reports-wire.js +64 -68
- package/dist/upload-sync.js +171 -157
- 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
|
-
|
|
12
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
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
|
-
|
|
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
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
48
|
-
const
|
|
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
|
-
*
|
|
188
|
-
*
|
|
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
|
-
*
|
|
201
|
-
*
|
|
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)
|