@bli-cockpit/cli 0.2.276 → 0.2.278

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.
@@ -38,7 +38,7 @@ import path from "node:path";
38
38
  import { RAW_EVIDENCE_UPLOAD_MAX_FILE_BYTES, REDACTION_STUB_SHA256, } from "@bli-cockpit/telemetry-core";
39
39
  import { isWholeFileMask, sanitizeTextEvidenceForUpload } from "../adapters/raw-evidence-sanitize.js";
40
40
  import { isSecretLikePath } from "../adapters/raw-evidence-keys.js";
41
- import { IMAGE_HANDLING_MODE } from "@bli-cockpit/telemetry-core";
41
+ import { IMAGE_HANDLING_MODE, modeDropsImages } from "@bli-cockpit/telemetry-core";
42
42
  import { uploadFormOfTranscript } from "../image-description-session.js";
43
43
  export async function judgeSource(input) {
44
44
  const { file, remotes, cursorEntry } = input;
@@ -161,7 +161,7 @@ export function fileFacts(file, maxBytes = RAW_EVIDENCE_UPLOAD_MAX_FILE_BYTES,
161
161
  upload = {}) {
162
162
  const mode = upload.mode ?? IMAGE_HANDLING_MODE;
163
163
  const describedKeys = upload.describedKeys ?? new Set();
164
- const slimAware = upload.slimAware ?? mode === "describe_and_drop";
164
+ const slimAware = upload.slimAware ?? modeDropsImages(mode);
165
165
  const redactForUpload = (text, original) => redactTranscriptForm(file, text, original);
166
166
  let split = null;
167
167
  let whole = null;
@@ -152,6 +152,8 @@ async function commitUploadedObject(options, entry, disposition, objectKey, uplo
152
152
  upload_id: disposition.upload_id,
153
153
  object_key: objectKey,
154
154
  ...(disposition.signed_uploads ? { direct_chunks: directChunks } : {}),
155
+ // BLI-23329: the commit of an image object records this session as a user of the image.
156
+ ...(entry.file.image_use ? { image_use: entry.file.image_use } : {}),
155
157
  });
156
158
  if (!commit.ok)
157
159
  return rejectCommit(commit, entry, disposition, uploadedChunks);
@@ -116,6 +116,8 @@ export async function resolveBeginBatch(options, totalLoadedCount, batch, chunkS
116
116
  pointer: entry.file.pointer,
117
117
  chunk_size_bytes: chunkSizeBytes,
118
118
  chunk_count: entry.chunkCount,
119
+ // BLI-23329: an image-store object names the session it is staged for.
120
+ ...(entry.file.image_use ? { image_use: entry.file.image_use } : {}),
119
121
  })),
120
122
  });
121
123
  // 404 means an old dashboard without the chunk routes; a persistent 5xx
@@ -1,4 +1,4 @@
1
- import { RawEvidenceRedactionMetadataSchema, RawEvidenceUploadBeginResponseSchema, isRekeyableUploadConflict, } from "@bli-cockpit/telemetry-core";
1
+ import { RawEvidenceRedactionMetadataSchema, RawEvidenceUploadBeginResponseSchema, isImageObjectKey, isRekeyableUploadConflict, } from "@bli-cockpit/telemetry-core";
2
2
  import { readBeginDispositions } from "./evidence-upload-plan.js";
3
3
  import { rekeyedEvidencePointer } from "./evidence-upload-rekey.js";
4
4
  import { requestJson, safeFailureDetail } from "./evidence-upload-transport.js";
@@ -19,6 +19,9 @@ export async function rekeyConflictedEntry(options, entry, disposition, chunkSiz
19
19
  !isRekeyableUploadConflict(disposition.reason)) {
20
20
  return { entry, disposition };
21
21
  }
22
+ // BLI-23329: an image object's name IS its sha256; a different name would not be an image object any more.
23
+ if (isImageObjectKey(entry.file.pointer.object_key ?? ""))
24
+ return { entry, disposition };
22
25
  const contentHashPrefix = (entry.file.pointer.content_hash_sha256 ?? "none").slice(0, 12);
23
26
  const rekeyedPointer = rekeyedEvidencePointer(entry.file.pointer);
24
27
  if (!rekeyedPointer) {
@@ -73,6 +76,7 @@ export async function beginOneObject(options, entry, chunkSizeBytes) {
73
76
  pointer: entry.file.pointer,
74
77
  chunk_size_bytes: chunkSizeBytes,
75
78
  chunk_count: entry.chunkCount,
79
+ ...(entry.file.image_use ? { image_use: entry.file.image_use } : {}),
76
80
  },
77
81
  ],
78
82
  });
@@ -6,8 +6,10 @@
6
6
  * or descriptions. The sends themselves (three at a time, budgets, backoff) are in
7
7
  * `image-description-send.ts`.
8
8
  */
9
- import { IMAGE_DESCRIBE_MAX_BYTES, IMAGE_HANDLING_MODE, base64Of, findEmbeddedImages, formatImageMarker, replaceImagesWithMarkers, } from "@bli-cockpit/telemetry-core";
9
+ import { IMAGE_DESCRIBE_MAX_BYTES, IMAGE_HANDLING_MODE, IMAGE_STORE_MAX_BYTES, base64Of, findEmbeddedImages, formatImageMarker, modeDropsImages, modeStoresImages, replaceImagesWithMarkers, } from "@bli-cockpit/telemetry-core";
10
10
  import { sendPendingImages } from "./image-description-send.js";
11
+ import { hasStoreRoom, isStoredThisRun, openImageStoreRun, storeImages } from "./image-store-send.js";
12
+ import { IMAGE_STORE_BLOCK_CHARS, blocksOfText } from "./image-store-stage.js";
11
13
  import { resolveImageHandlingMode } from "./image-mode-state.js";
12
14
  import { censusOfLines } from "./image-census-stream.js";
13
15
  import { sha256OfBase64Run } from "./image-byte-scan.js";
@@ -40,21 +42,25 @@ export async function openImageDescriptions(options) {
40
42
  const maxPerRun = options.maxPerRun ?? IMAGE_DESCRIBE_MAX_PER_RUN;
41
43
  const maxRunMs = options.maxRunMs ?? IMAGE_DESCRIBE_MAX_RUN_MS;
42
44
  const sentThisRun = 0;
43
- const run = { options, mode, fetchImpl, described, orderedKeys, failedImages, stateDirty, settledThisRun, now, describingMs: 0, maxPerRun, maxRunMs, sentThisRun };
45
+ const store = modeStoresImages(mode)
46
+ ? await openImageStoreRun(options.stateDir, { ...(options.storeMaxUploads === undefined ? {} : { maxUploads: options.storeMaxUploads }), ...(options.storeMaxConfirms === undefined ? {} : { maxConfirms: options.storeMaxConfirms }), ...(options.storeMaxRunMs === undefined ? {} : { maxRunMs: options.storeMaxRunMs }) })
47
+ : undefined;
48
+ const run = { options, mode, fetchImpl, described, orderedKeys, failedImages, stateDirty, settledThisRun, now, describingMs: 0, maxPerRun, maxRunMs, sentThisRun, ...(store ? { store } : {}) };
44
49
  const streamMinBytes = options.streamMinBytes ?? STREAM_MIN_BYTES;
45
50
  return {
46
51
  mode,
47
52
  streamMinBytes,
48
53
  isDescribed: (sha256) => described.has(imageStateKey(sha256)),
49
54
  hasDescribeRoom: (sha256s) => describeRoomLeft(run, sha256s),
55
+ hasStoreRoom: (sha256s) => (run.store ? hasStoreRoom(run.store, sha256s, now()) : false),
50
56
  async prepareTranscript(input) {
51
- // BLI-23347: in drop mode each base64 PDF becomes a marker block (its text, or its described page images).
57
+ // BLI-23347: in a drop mode (describe_and_drop, store_and_drop) each base64 PDF becomes a marker block (its text, or its described page images).
52
58
  const imageFinds = findsOfText(input.text);
53
- const finds0 = mode === "describe_and_drop"
59
+ const finds0 = modeDropsImages(mode)
54
60
  ? await addPdfsToFinds(imageFinds, pdfsOfText(input.text), pdfContext(run, { stateDir: options.stateDir, read: async (pdf) => input.text.slice(pdf.base64Start, pdf.base64End), changed: () => new Error("pdf_changed_in_text") }))
55
61
  : imageFinds;
56
62
  const { replace, finds, ...afterImages } = await describeAndReplace(run, input, finds0);
57
- if (mode !== "describe_and_drop")
63
+ if (!modeDropsImages(mode))
58
64
  return { ...afterImages, text: input.text, changed: false, slim: null };
59
65
  // BLI-23317: the image swap and the slim rules are ONE pass over the lines (one joined string, no
60
66
  // intermediate copy). A pass that cannot run falls back to the two-step order, which is the same bytes.
@@ -66,7 +72,7 @@ export async function openImageDescriptions(options) {
66
72
  const slim = slimOrKeep(replaced.text, input.source, { sessionId: input.sessionId });
67
73
  return { ...afterImages, text: slim.text, changed: slim.text !== input.text, slim: slim.stats };
68
74
  },
69
- prepareTranscriptFile: mode === "describe_and_drop" ? (input) => prepareFileInTwoPasses(run, input) : undefined,
75
+ prepareTranscriptFile: modeDropsImages(mode) ? (input) => prepareFileInTwoPasses(run, input) : undefined,
70
76
  };
71
77
  }
72
78
  /** Files smaller than this are cheaper to read whole; the streamed path pays a second read. */
@@ -82,9 +88,17 @@ function findsOfText(text) {
82
88
  unique,
83
89
  images,
84
90
  base64For: (image) => base64Of(text, image),
91
+ blocksOf: (image) => blocksOfText(text, image),
85
92
  swapped: (replace) => countImageSwaps({ images, replace }),
86
93
  };
87
94
  }
95
+ /** The base64 of an image from the pinned file, in blocks of `IMAGE_STORE_BLOCK_CHARS`; each read is bounded, never the whole run. */
96
+ async function* blocksOfPinned(source, image) {
97
+ for (let at = image.base64Start; at < image.base64End; at += IMAGE_STORE_BLOCK_CHARS) {
98
+ const block = await source.read(at, Math.min(image.base64End, at + IMAGE_STORE_BLOCK_CHARS) - at);
99
+ yield block.toString("latin1");
100
+ }
101
+ }
88
102
  function findsOfCensus(census, source) {
89
103
  return {
90
104
  total: census.total,
@@ -98,6 +112,8 @@ function findsOfCensus(census, source) {
98
112
  throw new TranscriptNotStreamable("file_changed_during_pass");
99
113
  return payload.toString("latin1");
100
114
  },
115
+ // The same pinned bytes, in 48 Ki-character blocks, so a large image is decoded to a file without being held whole.
116
+ blocksOf: (image) => blocksOfPinned(source, image),
101
117
  swapped: (replace) => {
102
118
  let replacedCount = 0;
103
119
  let charsSaved = 0;
@@ -164,8 +180,9 @@ function describeRoomLeft(run, sha256s) {
164
180
  });
165
181
  }
166
182
  async function describeAndReplace(run, input, finds) {
167
- const { mode, described, options, orderedKeys, failedImages } = run;
168
- const replace = (image) => described.has(imageStateKey(image.sha256));
183
+ const { mode, described, options, orderedKeys, failedImages, store } = run;
184
+ // describe_and_drop replaces what has a description; store_and_drop replaces what Tower confirmed for THIS session this tick.
185
+ const replace = (image) => store ? isStoredThisRun(store, image.sha256, input.sessionId) : described.has(imageStateKey(image.sha256));
169
186
  const { unique } = finds;
170
187
  if (finds.total === 0)
171
188
  return { replace, finds, pendingImages: [], imageCount: 0, uniqueCount: 0, replacedCount: 0, charsSaved: 0, deferredOnly: false };
@@ -177,14 +194,26 @@ async function describeAndReplace(run, input, finds) {
177
194
  await writeImageDescriptionState(options.stateDir, { described: orderedKeys, failed: failedImages });
178
195
  run.stateDirty = false;
179
196
  }
197
+ // store_and_drop: after describing, before the swap. Whatever Tower does not confirm stays inline (BLI-23329).
198
+ const stored = store
199
+ ? await storeImages(store, {
200
+ fetchImpl: run.fetchImpl, dashboardUrl: options.dashboardUrl, deviceToken: options.deviceToken, stateDir: options.stateDir,
201
+ ...(options.sleep ? { sleep: options.sleep } : {}), now: run.now, source: input.source, sessionId: input.sessionId,
202
+ provenance: input.provenance, images: [...unique.values()], blocksOf: finds.blocksOf,
203
+ })
204
+ : null;
180
205
  const deferredOnly = counts.over_budget + counts.backing_off > 0
181
206
  && counts.described_now + counts.known + counts.failed + counts.too_large + counts.settled_earlier === 0;
182
- const replaced = mode === "describe_and_drop" ? finds.swapped(replace) : { replacedCount: 0, charsSaved: 0 };
207
+ const replaced = modeDropsImages(mode) ? finds.swapped(replace) : { replacedCount: 0, charsSaved: 0 };
183
208
  // BLI-23317: an image over the describe limit is never sent, so it can never gain a description and
184
209
  // never changes the output. Listing it made the describer look like it still had room, and the
185
210
  // transcript was rebuilt on every tick (rebuilt_describer_has_room). Counted in `too_large` above.
211
+ // store_and_drop: what is still pending is every image Tower did not confirm this tick (described or not), unless it is
212
+ // over the store's size limit and so can never be stored; the memo reuses the entry only while there is no room to store.
186
213
  const pendingImages = [...unique.values()]
187
- .filter((image) => !described.has(imageStateKey(image.sha256)) && image.byteSize <= IMAGE_DESCRIBE_MAX_BYTES)
214
+ .filter((image) => store
215
+ ? !isStoredThisRun(store, image.sha256, input.sessionId) && image.byteSize <= IMAGE_STORE_MAX_BYTES
216
+ : !described.has(imageStateKey(image.sha256)) && image.byteSize <= IMAGE_DESCRIBE_MAX_BYTES)
188
217
  .map((image) => image.sha256);
189
218
  console.error(TAG + " transcript prepared", JSON.stringify({
190
219
  mode,
@@ -196,7 +225,8 @@ async function describeAndReplace(run, input, finds) {
196
225
  failed_reasons: failedReasons,
197
226
  replaced: replaced.replacedCount,
198
227
  chars_saved: replaced.charsSaved,
199
- kept_without_description: mode === "describe_and_drop" ? finds.total - replaced.replacedCount : 0,
228
+ kept_without_description: modeDropsImages(mode) ? finds.total - replaced.replacedCount : 0,
229
+ ...(stored ? { store: stored } : {}),
200
230
  }));
201
231
  return {
202
232
  replace,
@@ -281,7 +311,7 @@ function countImageOutcomes(run, toSend, counts) {
281
311
  * Drop mode replaces known images, then slims; keep mode preserves all text.
282
312
  */
283
313
  export function uploadFormOfTranscript(text, source, mode, describedKeys) {
284
- if (mode !== "describe_and_drop")
314
+ if (!modeDropsImages(mode))
285
315
  return text;
286
316
  const images = findEmbeddedImages(text);
287
317
  const replaced = replaceImagesWithMarkers(text, images, (image) => describedKeys.has(imageStateKey(image.sha256)));
@@ -13,20 +13,20 @@
13
13
  * transcript as a new object)
14
14
  * else `IMAGE_HANDLING_MODE`
15
15
  *
16
- * The file also remembers, sticky, that this machine EVER ran slim (`drop_seen_at`). A machine put
16
+ * The file also remembers, sticky, that this machine EVER ran slim (`drop_seen_at`; `store_and_drop` is slim too, BLI-23329). A machine put
17
17
  * back on keep still holds slim server copies of the files it uploaded while slim, and
18
18
  * `cockpit sources check` must go on treating those as "keep your local file".
19
19
  *
20
20
  * Holds the mode and two timestamps. Never an id, a path or a byte.
21
21
  */
22
22
  import path from "node:path";
23
- import { IMAGE_HANDLING_MODE } from "@bli-cockpit/telemetry-core";
23
+ import { IMAGE_HANDLING_MODE, modeDropsImages } from "@bli-cockpit/telemetry-core";
24
24
  import { describeError, isMissingFileFailure } from "./health-detail.js";
25
25
  import { readJsonFile, writeJsonFileAtomic } from "./local-state-files.js";
26
26
  export const IMAGE_MODE_STATE_FILENAME = "image-handling-mode.json";
27
27
  const TAG = "[image-mode]";
28
28
  export function isImageHandlingMode(value) {
29
- return value === "describe_and_keep" || value === "describe_and_drop";
29
+ return value === "describe_and_keep" || value === "describe_and_drop" || value === "store_and_drop";
30
30
  }
31
31
  export function imageModeStatePath(stateDir) {
32
32
  return path.join(stateDir, IMAGE_MODE_STATE_FILENAME);
@@ -87,7 +87,7 @@ export async function resolveImageHandlingMode(stateDir, read) {
87
87
  mode,
88
88
  source: state ? "cache" : damaged ? "damaged_cache" : "default",
89
89
  // A file that is there but unreadable is "ever slim, unknown": fail closed (BLI-17273).
90
- slimAware: mode === "describe_and_drop" || state?.drop_seen_at != null || damaged,
90
+ slimAware: modeDropsImages(mode) || state?.drop_seen_at != null || damaged,
91
91
  state,
92
92
  };
93
93
  }
@@ -109,7 +109,7 @@ export async function recordObservedImageMode(stateDir, observed, now = () => ne
109
109
  mode: observed,
110
110
  observed_at: stamp,
111
111
  // Never rebuild "never ran slim" over a file we could not read: keep what parsed, else the unknown marker.
112
- drop_seen_at: previous?.drop_seen_at ?? (damaged ? (recoveredDropSeenAt ?? DROP_SEEN_UNKNOWN) : observed === "describe_and_drop" ? stamp : null),
112
+ drop_seen_at: previous?.drop_seen_at ?? (damaged ? (recoveredDropSeenAt ?? DROP_SEEN_UNKNOWN) : modeDropsImages(observed) ? stamp : null),
113
113
  };
114
114
  if (damaged) {
115
115
  console.error(TAG + " rewriting a damaged mode file; ever-slim history kept", JSON.stringify({ reason: "image_mode_cache_damaged_rewritten", drop_seen_recovered: recoveredDropSeenAt !== null }));
@@ -0,0 +1,326 @@
1
+ /**
2
+ * The store step of `store_and_drop` (BLI-23329): put each distinct image of a transcript on Tower once, and say which
3
+ * ones Tower confirmed THIS tick, so only those are replaced by their marker.
4
+ *
5
+ * "Stored" means one thing here: Tower answered `already_committed` (begin) or `committed` / `already_committed`
6
+ * (commit) for this image AND this session, and the server records the session as a user of the image in the same step
7
+ * that answers (`apps/dashboard/src/lib/ambient/image-store/image-store-door.ts`). Nothing local is trusted: no list of
8
+ * "stored" keys survives the tick, because a forget on Tower can make it a lie. Everything else (offline, refused,
9
+ * over budget, a 5xx, a changed source) leaves the image inline, which is today's behaviour; an image is never lost.
10
+ *
11
+ * Order per transcript: a cheap begin probe for every candidate (25 per request); the ones already on Tower are done;
12
+ * the rest are decoded to a temp file block by block (`image-store-stage.ts`) and sent through the ordinary chunked
13
+ * upload client. Logs carry counts, reason labels and the first 16 characters of a hash; never bytes, paths or ids.
14
+ */
15
+ import fs from "node:fs/promises";
16
+ import os from "node:os";
17
+ import path from "node:path";
18
+ import { IMAGE_STORE_MAX_BYTES, IMAGE_STORE_MAX_PER_RUN, IMAGE_USE_SESSION_ID, IMAGE_STORE_MAX_RUN_MS, RAW_EVIDENCE_UPLOAD_DEFAULT_CHUNK_BYTES, RAW_EVIDENCE_UPLOAD_MAX_OBJECTS_PER_BEGIN, RawEvidenceUploadBeginResponseSchema, imageObjectKey, } from "@bli-cockpit/telemetry-core";
19
+ import { uploadRawEvidenceFilesChunked } from "./evidence-upload-client.js";
20
+ import { requestJson, safeFailureDetail } from "./evidence-upload-transport.js";
21
+ import { stageDecodedImage } from "./image-store-stage.js";
22
+ import { isBackingOff, readStoreFailures, rememberStoreFailure, storeFailureKey, writeStoreFailures } from "./image-store-state.js";
23
+ const TAG = "[image-store]";
24
+ /** Distinct (image, session) pairs asked about per sync. Asking is cheap (one begin per 25); uploading is what the other budget bounds. */
25
+ export const IMAGE_STORE_MAX_CONFIRMS_PER_RUN = 2_000;
26
+ /** Images sent to the upload client together; the time budget is checked between groups. */
27
+ const UPLOAD_GROUP = 10;
28
+ const BEGIN_PATH = "/api/ambient/evidence/upload/begin";
29
+ /** What the server says when it, not the image, is the problem: retried next tick, never remembered against the image. */
30
+ const SERVER_SIDE_TRANSIENT = /image_store_unavailable|image_ledger_not_committed|image_bytes_missing/;
31
+ /** Temp directories of images being sent are named with this; a run that died leaves one behind. */
32
+ export const IMAGE_STORE_TEMP_PREFIX = "tower-image-store-";
33
+ const STALE_TEMP_MS = 6 * 3_600_000;
34
+ /** Remove staging directories older than six hours (a run that was killed mid-upload). Best effort, said out loud. */
35
+ export async function pruneStaleImageStoreTemp(now, tempRoot = os.tmpdir()) {
36
+ let removed = 0;
37
+ try {
38
+ for (const name of await fs.readdir(tempRoot)) {
39
+ if (!name.startsWith(IMAGE_STORE_TEMP_PREFIX))
40
+ continue;
41
+ const dir = path.join(tempRoot, name);
42
+ const info = await fs.stat(dir).catch(() => null);
43
+ if (!info?.isDirectory() || now - info.mtimeMs < STALE_TEMP_MS)
44
+ continue;
45
+ await fs.rm(dir, { recursive: true, force: true });
46
+ removed += 1;
47
+ }
48
+ }
49
+ catch (error) {
50
+ console.error(`${TAG} could not prune old staging directories`, JSON.stringify({ reason: "image_store_temp_prune_failed", error_name: error instanceof Error ? error.name : typeof error }));
51
+ return removed;
52
+ }
53
+ if (removed > 0)
54
+ console.error(`${TAG} removed staging directories a killed run left behind`, JSON.stringify({ reason: "image_store_temp_pruned", removed }));
55
+ return removed;
56
+ }
57
+ export async function openImageStoreRun(stateDir, overrides = {}) {
58
+ await pruneStaleImageStoreTemp(Date.now());
59
+ return {
60
+ stored: new Set(),
61
+ failures: await readStoreFailures(stateDir),
62
+ failuresDirty: false,
63
+ uploadedThisRun: 0,
64
+ confirmedThisRun: 0,
65
+ storingMs: 0,
66
+ unavailable: null,
67
+ maxUploads: overrides.maxUploads ?? IMAGE_STORE_MAX_PER_RUN,
68
+ maxConfirms: overrides.maxConfirms ?? IMAGE_STORE_MAX_CONFIRMS_PER_RUN,
69
+ maxRunMs: overrides.maxRunMs ?? IMAGE_STORE_MAX_RUN_MS,
70
+ };
71
+ }
72
+ export const storedKey = (sha256, sessionId) => `${sha256}|${sessionId}`;
73
+ export function isStoredThisRun(store, sha256, sessionId) {
74
+ return store.stored.has(storedKey(sha256, sessionId));
75
+ }
76
+ /**
77
+ * Could a prepare pass still store at least one of these images right now? False when the sync's upload or time budget
78
+ * is spent, Tower was unreachable this sync, or every one is backing off after a refusal. While true, a remembered
79
+ * transcript whose store failed is rebuilt, so a closed session whose store failed is stored on the next tick with room.
80
+ */
81
+ export function hasStoreRoom(store, sha256s, now) {
82
+ if (store.unavailable)
83
+ return false;
84
+ if (store.uploadedThisRun >= store.maxUploads || store.storingMs > store.maxRunMs)
85
+ return false;
86
+ return sha256s.some((sha256) => !isBackingOff(store.failures, storeFailureKey(sha256, "", false), now));
87
+ }
88
+ function pointerFor(operatorId, image) {
89
+ const objectKey = imageObjectKey(operatorId, image.sha256, image.mediaType);
90
+ return {
91
+ raw_evidence_pointer_id: objectKey,
92
+ privacy_classification: "remote_durable_raw_evidence",
93
+ retention_policy: { mode: "remote_durable", privacy_classification: "remote_durable_raw_evidence" },
94
+ storage_scope: "remote_object",
95
+ storage_bucket: "ambient-raw-evidence",
96
+ object_key: objectKey,
97
+ content_hash_sha256: image.sha256,
98
+ byte_size: image.byteSize,
99
+ media_type: image.mediaType,
100
+ };
101
+ }
102
+ function* groupsOf(items, size) {
103
+ for (let at = 0; at < items.length; at += size)
104
+ yield items.slice(at, at + size);
105
+ }
106
+ export async function storeImages(store, input) {
107
+ const counts = { settled_earlier: 0, too_large_to_store: 0, backing_off: 0, over_budget: 0, unavailable: 0, stored_existing: 0, stored_new: 0, failed: 0 };
108
+ if (input.images.length === 0)
109
+ return counts;
110
+ if (!input.provenance) {
111
+ console.error(`${TAG} nothing stored, images stay inline`, JSON.stringify({ reason: "store_provenance_missing", images: input.images.length }));
112
+ counts.unavailable = input.images.length;
113
+ return counts;
114
+ }
115
+ // The server records the session by this id (the id forget keys on); one it would refuse is not worth a request.
116
+ if (!IMAGE_USE_SESSION_ID.test(input.sessionId)) {
117
+ console.error(`${TAG} nothing stored, images stay inline`, JSON.stringify({ reason: "store_session_id_unusable", images: input.images.length }));
118
+ counts.unavailable = input.images.length;
119
+ return counts;
120
+ }
121
+ const provenance = input.provenance;
122
+ const startedAt = input.now();
123
+ const options = {
124
+ fetchImpl: input.fetchImpl, dashboardUrl: input.dashboardUrl, deviceToken: input.deviceToken, provenance,
125
+ generatedAt: new Date(startedAt).toISOString(), files: [], ...(input.sleep ? { sleep: input.sleep } : {}),
126
+ };
127
+ const use = { session_id: input.sessionId, source: input.source };
128
+ const candidates = selectCandidates(store, input, counts);
129
+ const needUpload = await probeAll(store, input, options, use, candidates, counts);
130
+ await uploadAll(store, input, options, use, needUpload, counts, startedAt);
131
+ store.storingMs += input.now() - startedAt;
132
+ if (store.failuresDirty) {
133
+ try {
134
+ await writeStoreFailures(input.stateDir, store.failures);
135
+ store.failuresDirty = false;
136
+ }
137
+ catch (error) {
138
+ console.error(`${TAG} could not record store refusals; they are forgotten at the end of this sync`, JSON.stringify({ reason: "image_store_failures_write_failed", error_name: error instanceof Error ? error.name : typeof error }));
139
+ }
140
+ }
141
+ console.error(`${TAG} transcript stored`, JSON.stringify({ source: input.source, images: input.images.length, ...counts, storing_ms: input.now() - startedAt }));
142
+ return counts;
143
+ }
144
+ function selectCandidates(store, input, counts) {
145
+ const candidates = [];
146
+ for (const image of input.images) {
147
+ if (isStoredThisRun(store, image.sha256, input.sessionId)) {
148
+ counts.settled_earlier += 1;
149
+ continue;
150
+ }
151
+ if (image.byteSize > IMAGE_STORE_MAX_BYTES) {
152
+ counts.too_large_to_store += 1;
153
+ continue;
154
+ }
155
+ if (store.unavailable) {
156
+ counts.unavailable += 1;
157
+ continue;
158
+ }
159
+ const imageKey = storeFailureKey(image.sha256, input.sessionId, false);
160
+ const sessionKey = storeFailureKey(image.sha256, input.sessionId, true);
161
+ if (isBackingOff(store.failures, imageKey, input.now()) || isBackingOff(store.failures, sessionKey, input.now())) {
162
+ counts.backing_off += 1;
163
+ continue;
164
+ }
165
+ if (store.confirmedThisRun >= store.maxConfirms || store.storingMs > store.maxRunMs) {
166
+ counts.over_budget += 1;
167
+ continue;
168
+ }
169
+ store.confirmedThisRun += 1;
170
+ candidates.push(image);
171
+ }
172
+ return candidates;
173
+ }
174
+ /** Ask Tower about every candidate; the ones it already holds are confirmed here, the rest come back to be uploaded. */
175
+ async function probeAll(store, input, options, use, candidates, counts) {
176
+ const needUpload = [];
177
+ const operatorId = options.provenance.operator_id;
178
+ for (const batch of groupsOf(candidates, RAW_EVIDENCE_UPLOAD_MAX_OBJECTS_PER_BEGIN)) {
179
+ if (store.unavailable) {
180
+ counts.unavailable += batch.length;
181
+ continue;
182
+ }
183
+ const response = await requestJson(options, BEGIN_PATH, {
184
+ schema_version: "ambient-raw-evidence-upload-begin.v1",
185
+ generated_at: options.generatedAt,
186
+ provenance: options.provenance,
187
+ objects: batch.map((image) => ({
188
+ pointer: pointerFor(operatorId, image),
189
+ chunk_size_bytes: RAW_EVIDENCE_UPLOAD_DEFAULT_CHUNK_BYTES,
190
+ chunk_count: Math.max(1, Math.ceil(image.byteSize / RAW_EVIDENCE_UPLOAD_DEFAULT_CHUNK_BYTES)),
191
+ image_use: use,
192
+ })),
193
+ });
194
+ const parsed = response.ok ? RawEvidenceUploadBeginResponseSchema.safeParse(response.body) : null;
195
+ if (!parsed?.success) {
196
+ settleUnreachable(store, response.status, safeFailureDetail(response.body), response.ok ? "begin_invalid_response" : null, batch, counts);
197
+ continue;
198
+ }
199
+ const byKey = new Map(parsed.data.objects.map((object) => [object.object_key, object]));
200
+ for (const image of batch) {
201
+ const answer = byKey.get(imageObjectKey(operatorId, image.sha256, image.mediaType));
202
+ if (!answer) {
203
+ fail(store, input, image, "begin_missing_disposition", counts);
204
+ continue;
205
+ }
206
+ if (answer.disposition === "already_committed") {
207
+ store.stored.add(storedKey(image.sha256, input.sessionId));
208
+ counts.stored_existing += 1;
209
+ }
210
+ else if (answer.disposition === "conflict") {
211
+ fail(store, input, image, answer.reason ?? "upload_conflict", counts);
212
+ }
213
+ else {
214
+ needUpload.push(image);
215
+ }
216
+ }
217
+ }
218
+ return needUpload;
219
+ }
220
+ /**
221
+ * A whole `begin` request that did not come back as an answer (unreachable, a 5xx, a 4xx, a reply this CLI cannot read)
222
+ * says nothing about any one picture: a 400 is a request one end could not read (a server that does not know this CLI's
223
+ * fields, say), and remembering it against every picture for a week would punish the pictures for the wiring. Leave the
224
+ * rest of this sync's images inline, once, saying why; the next tick asks again.
225
+ */
226
+ function settleUnreachable(store, status, detail, label, batch, counts) {
227
+ const reason = label ?? `begin_failed_http_${status}${detail ? `_${detail}` : ""}`;
228
+ if (!store.unavailable) {
229
+ store.unavailable = reason;
230
+ console.error(`${TAG} Tower cannot take images right now; every image stays inline for the rest of this sync`, JSON.stringify({ reason, http_status: status, server_reason: detail ?? "none" }));
231
+ }
232
+ counts.unavailable += batch.length;
233
+ }
234
+ /** What a refusal is about decides whether it is remembered, for how long, and against what. */
235
+ function fail(store, input, image, reason, counts) {
236
+ counts.failed += 1;
237
+ const verdict = classifyStoreFailure(reason);
238
+ if (verdict.remember) {
239
+ const key = storeFailureKey(image.sha256, input.sessionId, verdict.aboutSession);
240
+ const record = rememberStoreFailure(store.failures, key, `store:${reason}`.slice(0, 90), verdict.permanent, input.now());
241
+ store.failuresDirty = true;
242
+ console.error(`${TAG} image not stored, will not be re-sent until later`, JSON.stringify({ reason, attempts: record.attempts, retry_in_hours: Math.round((record.retry_after - input.now()) / 3_600_000), sha256_prefix: image.sha256.slice(0, 16) }));
243
+ }
244
+ else {
245
+ console.error(`${TAG} image not stored this tick, retried on the next`, JSON.stringify({ reason, sha256_prefix: image.sha256.slice(0, 16) }));
246
+ }
247
+ }
248
+ export function classifyStoreFailure(reason) {
249
+ if (/image_session_forgotten/.test(reason))
250
+ return { remember: true, permanent: true, aboutSession: true };
251
+ if (SERVER_SIDE_TRANSIENT.test(reason))
252
+ return { remember: false, permanent: false, aboutSession: false };
253
+ const status = Number(/http_(\d{3})/.exec(reason)?.[1] ?? Number.NaN);
254
+ if (Number.isNaN(status)) {
255
+ // A conflict named by the server (no HTTP status in the label) is about these bytes: leave it alone for a while.
256
+ // A local fault (a file that could not be read or staged) says nothing about the image and is not remembered.
257
+ const local = /^(file_|staged_|image_source_changed|image_stage_|empty_file|upload_request_|legacy_)/.test(reason);
258
+ return { remember: !local, permanent: !local, aboutSession: false };
259
+ }
260
+ if (status === 0 || status === 401 || status === 429)
261
+ return { remember: false, permanent: false, aboutSession: false };
262
+ if (status >= 500)
263
+ return { remember: true, permanent: false, aboutSession: false };
264
+ return { remember: true, permanent: true, aboutSession: false };
265
+ }
266
+ async function uploadAll(store, input, options, use, needUpload, counts, startedAt) {
267
+ if (needUpload.length === 0)
268
+ return;
269
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), IMAGE_STORE_TEMP_PREFIX));
270
+ const operatorId = options.provenance.operator_id;
271
+ try {
272
+ for (const group of groupsOf(needUpload, UPLOAD_GROUP)) {
273
+ if (store.unavailable) {
274
+ counts.unavailable += group.length;
275
+ continue;
276
+ }
277
+ const room = store.maxUploads - store.uploadedThisRun;
278
+ const timeUp = store.storingMs + (input.now() - startedAt) > store.maxRunMs;
279
+ const take = timeUp ? [] : group.slice(0, Math.max(0, room));
280
+ counts.over_budget += group.length - take.length;
281
+ if (take.length === 0)
282
+ continue;
283
+ store.uploadedThisRun += take.length;
284
+ const files = [];
285
+ const imageOf = new Map();
286
+ for (const image of take) {
287
+ const staged = await stageDecodedImage(dir, image, input.blocksOf(image));
288
+ if (!staged.ok) {
289
+ fail(store, input, image, staged.reason, counts);
290
+ continue;
291
+ }
292
+ files.push({ pointer: pointerFor(operatorId, image), local_path: staged.localPath, kind: "image_store_object", codex_session_id: input.sessionId, image_use: use });
293
+ imageOf.set(image.sha256, image);
294
+ }
295
+ if (files.length === 0)
296
+ continue;
297
+ const result = await uploadRawEvidenceFilesChunked({ ...options, files });
298
+ if (result.used_legacy_fallback) {
299
+ for (const image of imageOf.values())
300
+ fail(store, input, image, "legacy_route_cannot_store_images", counts);
301
+ continue;
302
+ }
303
+ for (const outcome of result.outcomes)
304
+ settleOutcome(store, input, outcome, imageOf, counts);
305
+ for (const file of files)
306
+ await fs.rm(file.local_path, { force: true }).catch(() => undefined);
307
+ }
308
+ }
309
+ finally {
310
+ await fs.rm(dir, { recursive: true, force: true }).catch(() => undefined);
311
+ }
312
+ }
313
+ function settleOutcome(store, input, outcome, imageOf, counts) {
314
+ const image = imageOf.get(outcome.pointer.content_hash_sha256 ?? "");
315
+ if (!image)
316
+ return;
317
+ // Only the server's own "committed" / "already_committed" counts; both are sent after the session is recorded.
318
+ // `reused_existing` for any other reason (a 409 that says someone else committed) is NOT a confirmation for us.
319
+ const confirmed = outcome.upload_state === "uploaded" || (outcome.upload_state === "reused_existing" && outcome.reason === "already_committed");
320
+ if (confirmed) {
321
+ store.stored.add(storedKey(image.sha256, input.sessionId));
322
+ counts.stored_new += 1;
323
+ return;
324
+ }
325
+ fail(store, input, image, outcome.reason ?? "upload_failed", counts);
326
+ }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Decode one embedded image from its base64 text into a private temp file, one block at a time (BLI-23329).
3
+ *
4
+ * The upload client sends files by offset, so the image has to exist as bytes on disk. It is decoded in blocks of
5
+ * 48 Ki characters (a multiple of 4: each block is whole base64 quanta), straight into a sha256 and the file, so
6
+ * neither the base64 text of a large image nor its decoded bytes are ever held whole. The result is checked
7
+ * against what the census saw: a source that no longer decodes to the same sha256 and size (the transcript changed
8
+ * between the passes) is refused by name and the file removed. The file is mode 0600, in a directory this run made,
9
+ * and the caller removes the directory when the run is done.
10
+ */
11
+ import { createHash } from "node:crypto";
12
+ import fs from "node:fs/promises";
13
+ import path from "node:path";
14
+ /** Characters per block. A multiple of 4. */
15
+ export const IMAGE_STORE_BLOCK_CHARS = 48 * 1024;
16
+ /** The base64 blocks of an image held in a string: slices of the run, each a whole number of quanta. */
17
+ export function* blocksOfText(text, image) {
18
+ for (let at = image.base64Start; at < image.base64End; at += IMAGE_STORE_BLOCK_CHARS) {
19
+ yield text.slice(at, Math.min(image.base64End, at + IMAGE_STORE_BLOCK_CHARS));
20
+ }
21
+ }
22
+ export async function stageDecodedImage(dir, image, blocks) {
23
+ const localPath = path.join(dir, `${image.sha256}.bin`);
24
+ const hash = createHash("sha256");
25
+ let byteSize = 0;
26
+ let handle;
27
+ try {
28
+ handle = await fs.open(localPath, "wx", 0o600);
29
+ for await (const block of blocks) {
30
+ const bytes = Buffer.from(block, "base64");
31
+ hash.update(bytes);
32
+ byteSize += bytes.byteLength;
33
+ await handle.write(bytes);
34
+ }
35
+ await handle.close();
36
+ handle = undefined;
37
+ }
38
+ catch (error) {
39
+ await handle?.close().catch(() => undefined);
40
+ await fs.rm(localPath, { force: true }).catch(() => undefined);
41
+ // A source that cannot be read to the end (the transcript changed or vanished) is a changed source; a disk that
42
+ // cannot be written is the other label. Neither says anything about the image itself.
43
+ const code = error?.code;
44
+ const writeFault = code === "ENOSPC" || code === "EACCES" || code === "EPERM" || code === "EEXIST" || code === "EROFS";
45
+ console.error("[image-store] staging an image failed", JSON.stringify({ reason: writeFault ? "image_stage_write_failed" : "image_source_changed", code: code ?? null }));
46
+ return { ok: false, reason: writeFault ? "image_stage_write_failed" : "image_source_changed" };
47
+ }
48
+ if (hash.digest("hex") !== image.sha256 || byteSize !== image.byteSize) {
49
+ await fs.rm(localPath, { force: true }).catch(() => undefined);
50
+ console.error("[image-store] decoded image is not the one the census saw", JSON.stringify({ reason: "image_source_changed", expected_bytes: image.byteSize, decoded_bytes: byteSize }));
51
+ return { ok: false, reason: "image_source_changed" };
52
+ }
53
+ return { ok: true, localPath, byteSize };
54
+ }