@bli-cockpit/cli 0.2.277 → 0.2.279

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.
@@ -1,10 +1,46 @@
1
1
  import { putSignedChunk } from "./evidence-upload-direct.js";
2
2
  import { compressEvidenceBytes } from "@bli-cockpit/telemetry-core/evidence-storage-encoding";
3
3
  import { chooseUploadEncoding } from "./evidence-upload-encoding.js";
4
- import { COMMIT_CRASHED_PLATFORM, RawEvidenceUploadCommitResponseSchema, isPermanentUploadFailure, } from "@bli-cockpit/telemetry-core";
4
+ import { COMMIT_CRASHED_PLATFORM, EXTEND_BASE_UNAVAILABLE, RawEvidenceUploadCommitResponseSchema, isPermanentUploadFailure, } from "@bli-cockpit/telemetry-core";
5
5
  import { StagedFileError, readVerifiedChunk } from "./evidence-upload-staged.js";
6
6
  import { beginOneObject } from "./evidence-upload-terminal.js";
7
7
  import { isNonJsonResponseBody, requestJson, safeFailureDetail, } from "./evidence-upload-transport.js";
8
+ /**
9
+ * The chunk plan this `begin` answer commits us to (BLI-23345): the tail plan when the server accepted
10
+ * exactly the one we offered, the whole file otherwise. A tail plan the server states differently from
11
+ * ours is not guessed at; the caller re-offers the whole object, which resets the row.
12
+ */
13
+ function planForDisposition(entry, disposition) {
14
+ const accepted = disposition.extend;
15
+ const tail = entry.tail;
16
+ if (!accepted) {
17
+ if (tail) {
18
+ console.error("[evidence-upload] tail upload not used; sending the whole object", JSON.stringify({
19
+ reason: disposition.extend_refused ? `extend_refused:${disposition.extend_refused}` : "extend_not_answered",
20
+ upload_id: disposition.upload_id, byte_size: entry.byteSize,
21
+ }));
22
+ }
23
+ return { plan: "whole", view: entry };
24
+ }
25
+ if (!tail || accepted.base_object_key !== tail.base.object_key || accepted.base_content_hash_sha256 !== tail.base.content_hash_sha256
26
+ || accepted.base_byte_size !== tail.base.byte_size || accepted.tail_offset !== tail.offset
27
+ || accepted.tail_size !== tail.byteSize || accepted.chunk_count !== tail.chunkCount) {
28
+ return { plan: "mismatch" };
29
+ }
30
+ return { plan: "tail", view: { ...entry, byteSize: tail.byteSize, chunkHashes: tail.chunkHashes, chunkCount: tail.chunkCount, readOffset: tail.offset } };
31
+ }
32
+ /**
33
+ * Re-offer the whole object from the same staged file: begin without the tail plan resets the server's
34
+ * row to a whole plan, so nothing is lost and nothing is double-counted.
35
+ */
36
+ async function reofferWhole(options, entry, chunkSizeBytes, restageAttempted, reason, uploadId) {
37
+ console.error("[evidence-upload] re-offering the whole object", JSON.stringify({ reason, upload_id: uploadId, byte_size: entry.byteSize }));
38
+ const wholeEntry = { ...entry, tail: undefined, readOffset: undefined, duplicates: [] };
39
+ const restarted = await beginOneObject(options, wholeEntry, chunkSizeBytes);
40
+ if (!restarted)
41
+ return failedOutcome(entry.file, `${reason}:begin_failed`);
42
+ return uploadOneObject(options, wholeEntry, restarted, chunkSizeBytes, restageAttempted);
43
+ }
8
44
  export async function uploadOneObject(options, entry, disposition, chunkSizeBytes, restageAttempted = false) {
9
45
  const objectKey = entry.file.pointer.object_key ?? "";
10
46
  if (disposition.disposition === "already_committed") {
@@ -15,11 +51,19 @@ export async function uploadOneObject(options, entry, disposition, chunkSizeByte
15
51
  if (disposition.disposition === "conflict" || !disposition.upload_id) {
16
52
  return failedOutcome(entry.file, disposition.reason ?? "upload_conflict");
17
53
  }
54
+ const picked = planForDisposition(entry, disposition);
55
+ if (picked.plan === "mismatch")
56
+ return reofferWhole(options, entry, chunkSizeBytes, restageAttempted, "extend_plan_mismatch", disposition.upload_id);
57
+ const view = picked.view;
18
58
  const directChunks = [];
19
- const chunked = await uploadObjectChunks(options, entry, disposition, chunkSizeBytes, objectKey, directChunks);
59
+ const chunked = await uploadObjectChunks(options, view, disposition, chunkSizeBytes, objectKey, directChunks, picked.plan, entry.byteSize);
20
60
  if (!chunked.ok)
21
61
  return chunked.outcome;
22
- const committed = await commitUploadedObject(options, entry, disposition, objectKey, chunked.uploadedChunks, directChunks);
62
+ const committed = await commitUploadedObject(options, view, disposition, objectKey, chunked.uploadedChunks, directChunks);
63
+ if (picked.plan === "tail" && committed.reason === `commit_failed_http_409_${EXTEND_BASE_UNAVAILABLE}`) {
64
+ // The base was deleted (supersede) between begin and commit. The staged whole file is intact.
65
+ return reofferWhole(options, entry, chunkSizeBytes, restageAttempted, EXTEND_BASE_UNAVAILABLE, disposition.upload_id);
66
+ }
23
67
  if (committed.reason !== "commit_failed_http_409_staging_missing_between_passes")
24
68
  return committed;
25
69
  if (restageAttempted)
@@ -36,9 +80,10 @@ export async function uploadOneObject(options, entry, disposition, chunkSizeByte
36
80
  }
37
81
  return retried;
38
82
  }
39
- async function uploadObjectChunks(options, entry, disposition, chunkSizeBytes, objectKey, directChunks) {
83
+ async function uploadObjectChunks(options, entry, disposition, chunkSizeBytes, objectKey, directChunks, plan, wholeByteSize) {
40
84
  const received = new Set(disposition.received_chunk_indexes);
41
85
  let uploadedChunks = 0;
86
+ const meter = { wireBytes: 0 };
42
87
  if (disposition.disposition === "already_committed" || disposition.commit_ready) {
43
88
  return { ok: true, uploadedChunks };
44
89
  }
@@ -55,7 +100,7 @@ async function uploadObjectChunks(options, entry, disposition, chunkSizeBytes, o
55
100
  try {
56
101
  // Re-read from disk by offset and check against the plan-time hash; the
57
102
  // buffer lives only for this iteration (BLI-23317).
58
- chunk = await readVerifiedChunk(entry.file.local_path, index * chunkSizeBytes, Math.min(chunkSizeBytes, entry.byteSize - index * chunkSizeBytes), entry.chunkHashes[index]);
103
+ chunk = await readVerifiedChunk(entry.file.local_path, (entry.readOffset ?? 0) + index * chunkSizeBytes, Math.min(chunkSizeBytes, entry.byteSize - index * chunkSizeBytes), entry.chunkHashes[index]);
59
104
  }
60
105
  catch (error) {
61
106
  const reason = error instanceof StagedFileError ? error.reason : "file_read_failed";
@@ -67,7 +112,7 @@ async function uploadObjectChunks(options, entry, disposition, chunkSizeBytes, o
67
112
  }
68
113
  const offer = disposition.signed_uploads?.find(upload => upload.chunk_index === index);
69
114
  const direct = offer
70
- ? await putSignedChunk(options, offer.signed_url, chunk, disposition.upload_id, index, encoding.codec)
115
+ ? await putSignedChunk(options, offer.signed_url, chunk, disposition.upload_id, index, encoding.codec, meter)
71
116
  : "refused";
72
117
  if (direct === "deadline") {
73
118
  // BLI-22801: the chunk route would push the same bytes over the same slow link.
@@ -82,19 +127,27 @@ async function uploadObjectChunks(options, entry, disposition, chunkSizeBytes, o
82
127
  uploadedChunks += 1;
83
128
  continue;
84
129
  }
85
- const failure = await uploadChunkThroughEndpoint(options, entry, disposition, objectKey, index, chunk, uploadedChunks, Boolean(offer), encoding.codec);
130
+ const failure = await uploadChunkThroughEndpoint(options, entry, disposition, objectKey, index, chunk, uploadedChunks, Boolean(offer), encoding.codec, meter);
86
131
  if (failure)
87
132
  return failure;
88
133
  uploadedChunks += 1;
89
134
  }
135
+ // The number the ticket is measured by: compressed bytes that actually crossed the wire for this object,
136
+ // against the whole object it replaced (sizes and labels only).
137
+ console.error("[evidence-upload] object bytes sent", JSON.stringify({
138
+ reason: "object_bytes_sent", upload_id: disposition.upload_id, plan,
139
+ whole_byte_size: wholeByteSize, planned_byte_size: entry.byteSize, chunks_sent: uploadedChunks,
140
+ wire_bytes_sent: meter.wireBytes, chunk_encoding: encoding.codec,
141
+ }));
90
142
  return { ok: true, uploadedChunks };
91
143
  }
92
- async function uploadChunkThroughEndpoint(options, entry, disposition, objectKey, index, chunk, uploadedChunks, hadSignedOffer, codec) {
144
+ async function uploadChunkThroughEndpoint(options, entry, disposition, objectKey, index, chunk, uploadedChunks, hadSignedOffer, codec, meter) {
93
145
  console.error("[evidence-upload] using chunk route", JSON.stringify({
94
146
  reason: hadSignedOffer ? "signed_upload_refused_fallback" : "signed_upload_not_offered",
95
147
  upload_id: disposition.upload_id, chunk_index: index,
96
148
  }));
97
149
  const encodedChunk = compressEvidenceBytes(chunk, codec);
150
+ meter.wireBytes += encodedChunk.length;
98
151
  console.error("[evidence-upload] chunk encoded", JSON.stringify({
99
152
  upload_id: disposition.upload_id, chunk_index: index, chunk_encoding: codec,
100
153
  bytes_before: chunk.length, bytes_after: encodedChunk.length,
@@ -152,6 +205,8 @@ async function commitUploadedObject(options, entry, disposition, objectKey, uplo
152
205
  upload_id: disposition.upload_id,
153
206
  object_key: objectKey,
154
207
  ...(disposition.signed_uploads ? { direct_chunks: directChunks } : {}),
208
+ // BLI-23329: the commit of an image object records this session as a user of the image.
209
+ ...(entry.file.image_use ? { image_use: entry.file.image_use } : {}),
155
210
  });
156
211
  if (!commit.ok)
157
212
  return rejectCommit(commit, entry, disposition, uploadedChunks);
@@ -4,12 +4,13 @@ import { requestJson, safeFailureDetail } from "./evidence-upload-transport.js";
4
4
  import { failedOutcome, uploadOneObject } from "./evidence-upload-object.js";
5
5
  import { duplicateOutcome, rekeyConflictedEntry, reportAbandonedUpload, } from "./evidence-upload-terminal.js";
6
6
  import { describeError } from "./health-detail.js";
7
- import { StagedFileError, fingerprintStagedFile } from "./evidence-upload-staged.js";
7
+ import { StagedFileError, fingerprintStagedFile, fingerprintStagedTail, } from "./evidence-upload-staged.js";
8
8
  export async function loadPlannedEntries(options, chunkSizeBytes, outcomes) {
9
9
  const loaded = [];
10
10
  const loadedByObjectKey = new Map();
11
11
  for (const file of options.files) {
12
- const measured = await measureStagedUpload(file, chunkSizeBytes, outcomes);
12
+ const base = tailBaseFor(file, chunkSizeBytes);
13
+ const measured = await measureStagedUpload(file, chunkSizeBytes, outcomes, base?.byte_size);
13
14
  if (!measured)
14
15
  continue;
15
16
  // Content-addressed keys collide for byte-identical files (e.g. a resumed
@@ -28,6 +29,7 @@ export async function loadPlannedEntries(options, chunkSizeBytes, outcomes) {
28
29
  objectSha256: measured.objectSha256,
29
30
  chunkHashes: measured.chunkHashes,
30
31
  chunkCount: measured.chunkHashes.length,
32
+ ...(base ? await planTail(file, base, measured, chunkSizeBytes) : {}),
31
33
  duplicates: [],
32
34
  };
33
35
  loaded.push(entry);
@@ -37,7 +39,62 @@ export async function loadPlannedEntries(options, chunkSizeBytes, outcomes) {
37
39
  }
38
40
  return loaded;
39
41
  }
40
- async function measureStagedUpload(file, chunkSizeBytes, outcomes) {
42
+ /** A transcript that extends a committed snapshot by at least one chunk's worth is worth a tail plan. */
43
+ function tailBaseFor(file, chunkSizeBytes) {
44
+ const base = file.extend_base;
45
+ if (!base || (file.pointer.byte_size ?? 0) <= base.byte_size)
46
+ return null;
47
+ if (base.byte_size < chunkSizeBytes)
48
+ return null;
49
+ return base;
50
+ }
51
+ /** The tail plan, or nothing: the base must still be exactly the staged file's first bytes. */
52
+ async function planTail(file, base, measured, chunkSizeBytes) {
53
+ if (measured.prefixSha256 !== base.content_hash_sha256) {
54
+ // The prepare step proved this prefix a moment ago; the file moved since. Whole upload, said out loud.
55
+ console.error("[evidence-upload] tail upload not planned; the staged file no longer starts with its base", JSON.stringify({
56
+ reason: "extend_prefix_changed", kind: file.kind ?? "unknown", byte_size: measured.byteSize, base_byte_size: base.byte_size,
57
+ }));
58
+ return {};
59
+ }
60
+ try {
61
+ const chunkHashes = await fingerprintStagedTail(file.local_path, measured.byteSize, base.byte_size, chunkSizeBytes);
62
+ console.error("[evidence-upload] tail planned", JSON.stringify({
63
+ reason: "extend_planned", kind: file.kind ?? "unknown", byte_size: measured.byteSize,
64
+ base_byte_size: base.byte_size, tail_byte_size: measured.byteSize - base.byte_size, tail_chunk_count: chunkHashes.length,
65
+ }));
66
+ return { tail: { base, offset: base.byte_size, byteSize: measured.byteSize - base.byte_size, chunkHashes, chunkCount: chunkHashes.length } };
67
+ }
68
+ catch (error) {
69
+ console.error("[evidence-upload] tail upload not planned; the staged file could not be read", JSON.stringify({
70
+ reason: "extend_tail_unreadable", byte_size: measured.byteSize,
71
+ detail: error instanceof StagedFileError ? error.reason : "read_error",
72
+ }));
73
+ return {};
74
+ }
75
+ }
76
+ /** One `begin` object. The tail plan rides only where `tailAllowed` (an old server answers 400 to the extra field). */
77
+ export function beginObjectFor(entry, chunkSizeBytes, tailAllowed) {
78
+ const tail = tailAllowed ? entry.tail : undefined;
79
+ return {
80
+ pointer: entry.file.pointer,
81
+ chunk_size_bytes: chunkSizeBytes,
82
+ chunk_count: tail ? tail.chunkCount : entry.chunkCount,
83
+ ...(tail ? { extend: {
84
+ base_object_key: tail.base.object_key,
85
+ base_content_hash_sha256: tail.base.content_hash_sha256,
86
+ base_byte_size: tail.base.byte_size,
87
+ } } : {}),
88
+ // BLI-23329: an image-store object names the session it is staged for.
89
+ ...(entry.file.image_use ? { image_use: entry.file.image_use } : {}),
90
+ };
91
+ }
92
+ /** Runs that learned the server does not know tail plans; the answer holds for the rest of the run. */
93
+ const serverWithoutTailPlans = new WeakSet();
94
+ export function tailPlansAllowed(options) {
95
+ return !serverWithoutTailPlans.has(options);
96
+ }
97
+ async function measureStagedUpload(file, chunkSizeBytes, outcomes, prefixAt) {
41
98
  let stat;
42
99
  try {
43
100
  stat = await fs.stat(file.local_path);
@@ -64,7 +121,7 @@ async function measureStagedUpload(file, chunkSizeBytes, outcomes) {
64
121
  return null;
65
122
  }
66
123
  try {
67
- const fingerprint = await fingerprintStagedFile(file.local_path, stat.size, chunkSizeBytes);
124
+ const fingerprint = await fingerprintStagedFile(file.local_path, stat.size, chunkSizeBytes, undefined, prefixAt);
68
125
  console.error("[evidence-upload] staged object measured", JSON.stringify({
69
126
  byte_size: fingerprint.byteSize,
70
127
  chunk_count: fingerprint.chunkHashes.length,
@@ -108,16 +165,23 @@ export function batches(items, size) {
108
165
  * so each branch stays a guard clause rather than nested conditionals.
109
166
  */
110
167
  export async function resolveBeginBatch(options, totalLoadedCount, batch, chunkSizeBytes, outcomes, resolvedEntries) {
168
+ const offersTails = tailPlansAllowed(options) && batch.some((entry) => entry.tail);
111
169
  const begin = await requestJson(options, "/api/ambient/evidence/upload/begin", {
112
170
  schema_version: "ambient-raw-evidence-upload-begin.v1",
113
171
  generated_at: options.generatedAt,
114
172
  provenance: options.provenance,
115
- objects: batch.map((entry) => ({
116
- pointer: entry.file.pointer,
117
- chunk_size_bytes: chunkSizeBytes,
118
- chunk_count: entry.chunkCount,
119
- })),
173
+ objects: batch.map((entry) => beginObjectFor(entry, chunkSizeBytes, tailPlansAllowed(options))),
120
174
  });
175
+ if (offersTails && begin.status === 400) {
176
+ // A server from before BLI-23345 rejects the extra field. Ask again as every collector always has,
177
+ // and remember for the run; server deploys first, so this is the rollout window only.
178
+ serverWithoutTailPlans.add(options);
179
+ console.error("[evidence-upload] server does not know tail plans; offering whole objects", JSON.stringify({
180
+ reason: "extend_not_supported", http_status: begin.status, server_reason: safeFailureDetail(begin.body) ?? "none",
181
+ objects_in_batch: batch.length,
182
+ }));
183
+ return resolveBeginBatch(options, totalLoadedCount, batch, chunkSizeBytes, outcomes, resolvedEntries);
184
+ }
121
185
  // 404 means an old dashboard without the chunk routes; a persistent 5xx
122
186
  // (after retries) covers a new dashboard whose ledger migration has not
123
187
  // been applied yet. Both still serve the legacy v1 route.
@@ -241,6 +305,8 @@ export function readBeginDispositions(body) {
241
305
  received_chunk_indexes: entry.received_chunk_indexes,
242
306
  commit_ready: entry.commit_ready === true,
243
307
  signed_uploads: entry.signed_uploads,
308
+ ...(entry.extend ? { extend: entry.extend } : {}),
309
+ ...(entry.extend_refused ? { extend_refused: entry.extend_refused } : {}),
244
310
  reason: entry.reason ?? null,
245
311
  raw_evidence_pointer_id: entry.raw_evidence_pointer_id,
246
312
  ...(entry.accepted_encodings ? { accepted_encodings: entry.accepted_encodings } : {}),
@@ -11,9 +11,14 @@ export class StagedFileError extends Error {
11
11
  this.detail = detail;
12
12
  }
13
13
  }
14
- /** Read exactly `length` bytes at `position`, looping over short reads. */
15
- export async function readExactly(handle, length, position) {
16
- const buffer = Buffer.allocUnsafe(length);
14
+ /**
15
+ * Read exactly `length` bytes at `position`, looping over short reads. With `into` (at least `length` bytes) the
16
+ * read lands in that buffer and the answer is a view of it, so a loop over a big file allocates once instead of
17
+ * once per chunk (BLI-23524: per-chunk buffers are garbage the collector reclaims lazily, and a 100 MB file
18
+ * left tens of MB of it resident).
19
+ */
20
+ export async function readExactly(handle, length, position, into) {
21
+ const buffer = into ? into.subarray(0, length) : Buffer.allocUnsafe(length);
17
22
  let filled = 0;
18
23
  while (filled < length) {
19
24
  const { bytesRead } = await handle.read(buffer, filled, length - filled, position + filled);
@@ -28,25 +33,87 @@ export async function readExactly(handle, length, position) {
28
33
  * one chunk. Throws StagedFileError when the file vanishes or its length
29
34
  * moves while it is being read.
30
35
  */
31
- export async function fingerprintStagedFile(localPath, expectedSize, chunkSizeBytes, openFile = fs.open) {
36
+ export async function fingerprintStagedFile(localPath, expectedSize, chunkSizeBytes, openFile = fs.open, prefixAt) {
32
37
  const handle = await openStaged(localPath, openFile);
33
38
  try {
34
39
  const whole = crypto.createHash("sha256");
35
40
  const chunkHashes = [];
41
+ // One buffer for every chunk: each is hashed before the next read, and none is kept.
42
+ const reused = Buffer.allocUnsafeSlow(Math.min(chunkSizeBytes, Math.max(expectedSize, 1)));
43
+ let prefixSha256;
36
44
  for (let offset = 0; offset < expectedSize; offset += chunkSizeBytes) {
37
45
  const want = Math.min(chunkSizeBytes, expectedSize - offset);
38
- const chunk = await readExactly(handle, want, offset);
46
+ const chunk = await readExactly(handle, want, offset, reused);
39
47
  if (chunk.length !== want) {
40
48
  throw new StagedFileError("staged_file_changed", "file shrank while planning");
41
49
  }
42
- whole.update(chunk);
50
+ if (prefixAt !== undefined && prefixAt > offset && prefixAt <= offset + want) {
51
+ const cut = prefixAt - offset;
52
+ whole.update(chunk.subarray(0, cut));
53
+ prefixSha256 = whole.copy().digest("hex");
54
+ whole.update(chunk.subarray(cut));
55
+ }
56
+ else {
57
+ whole.update(chunk);
58
+ }
43
59
  chunkHashes.push(crypto.createHash("sha256").update(chunk).digest("hex"));
44
60
  }
45
61
  const probe = await readExactly(handle, 1, expectedSize);
46
62
  if (probe.length !== 0) {
47
63
  throw new StagedFileError("staged_file_changed", "file grew while planning");
48
64
  }
49
- return { byteSize: expectedSize, objectSha256: whole.digest("hex"), chunkHashes };
65
+ return {
66
+ byteSize: expectedSize,
67
+ objectSha256: whole.digest("hex"),
68
+ chunkHashes,
69
+ ...(prefixSha256 !== undefined ? { prefixSha256 } : {}),
70
+ };
71
+ }
72
+ finally {
73
+ await handle.close();
74
+ }
75
+ }
76
+ /**
77
+ * The chunk hashes of the TAIL of a staged file, `[start, expectedSize)` cut into chunks that begin at
78
+ * `start` (BLI-23345). One pass over the tail only; memory is one chunk.
79
+ */
80
+ export async function fingerprintStagedTail(localPath, expectedSize, start, chunkSizeBytes, openFile = fs.open) {
81
+ const handle = await openStaged(localPath, openFile);
82
+ try {
83
+ const chunkHashes = [];
84
+ const reused = Buffer.allocUnsafeSlow(Math.min(chunkSizeBytes, Math.max(expectedSize - start, 1)));
85
+ for (let offset = start; offset < expectedSize; offset += chunkSizeBytes) {
86
+ const want = Math.min(chunkSizeBytes, expectedSize - offset);
87
+ const chunk = await readExactly(handle, want, offset, reused);
88
+ if (chunk.length !== want) {
89
+ throw new StagedFileError("staged_file_changed", "file shrank while planning the tail");
90
+ }
91
+ chunkHashes.push(crypto.createHash("sha256").update(chunk).digest("hex"));
92
+ }
93
+ return chunkHashes;
94
+ }
95
+ finally {
96
+ await handle.close();
97
+ }
98
+ }
99
+ /**
100
+ * The file's bytes in order, `chunkBytes` at a time, every piece a view of the SAME buffer: valid until the next
101
+ * piece is asked for, so a consumer must use it (hash it, count it) before looping on. Memory is one chunk
102
+ * whatever the file's size; the descriptor closes when the consumer stops, however it stops. A failed read throws,
103
+ * never ends the stream early (BLI-23524).
104
+ */
105
+ export async function* chunksOfFileReusingOneBuffer(localPath, chunkBytes) {
106
+ const handle = await openStaged(localPath, fs.open);
107
+ try {
108
+ const buffer = Buffer.allocUnsafeSlow(chunkBytes);
109
+ for (let position = 0;; position += chunkBytes) {
110
+ const { bytesRead } = await handle.read(buffer, 0, chunkBytes, position);
111
+ if (bytesRead === 0)
112
+ return;
113
+ yield buffer.subarray(0, bytesRead);
114
+ if (bytesRead < chunkBytes)
115
+ return;
116
+ }
50
117
  }
51
118
  finally {
52
119
  await handle.close();
@@ -1,5 +1,5 @@
1
- import { RawEvidenceRedactionMetadataSchema, RawEvidenceUploadBeginResponseSchema, isRekeyableUploadConflict, } from "@bli-cockpit/telemetry-core";
2
- import { readBeginDispositions } from "./evidence-upload-plan.js";
1
+ import { RawEvidenceRedactionMetadataSchema, RawEvidenceUploadBeginResponseSchema, isImageObjectKey, isRekeyableUploadConflict, } from "@bli-cockpit/telemetry-core";
2
+ import { beginObjectFor, readBeginDispositions, tailPlansAllowed } from "./evidence-upload-plan.js";
3
3
  import { rekeyedEvidencePointer } from "./evidence-upload-rekey.js";
4
4
  import { requestJson, safeFailureDetail } from "./evidence-upload-transport.js";
5
5
  /**
@@ -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) {
@@ -68,13 +71,7 @@ export async function beginOneObject(options, entry, chunkSizeBytes) {
68
71
  schema_version: "ambient-raw-evidence-upload-begin.v1",
69
72
  generated_at: options.generatedAt,
70
73
  provenance: options.provenance,
71
- objects: [
72
- {
73
- pointer: entry.file.pointer,
74
- chunk_size_bytes: chunkSizeBytes,
75
- chunk_count: entry.chunkCount,
76
- },
77
- ],
74
+ objects: [beginObjectFor(entry, chunkSizeBytes, tailPlansAllowed(options))],
78
75
  });
79
76
  if (!response.ok) {
80
77
  console.error("[evidence-rekey] begin refused the re-keyed object; keeping the original conflict", JSON.stringify({
@@ -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";
@@ -15,6 +17,7 @@ import { addPdfsToFinds, mergeOccurrences, pdfsOfText } from "./pdf-transcript-s
15
17
  import { openPinnedTranscript } from "./adapters/pinned-transcript.js";
16
18
  import { openStagedOutput, readStagedText } from "./adapters/staged-output.js";
17
19
  import { TranscriptNotStreamable } from "./adapters/transcript-text-stream.js";
20
+ import { copyLinesStream } from "./slim/keep-line-copy.js";
18
21
  import { LineBytesSink, slimLineStream } from "./slim/slim-stream.js";
19
22
  import { logSlimmed, slimOrKeep, slimPassOrNull, slimTranscript } from "./slim/slim-transcript.js";
20
23
  import { imageStateKey, readDescribedImageKeys, readFailedImages, writeImageDescriptionState, } from "./image-description-state.js";
@@ -40,21 +43,25 @@ export async function openImageDescriptions(options) {
40
43
  const maxPerRun = options.maxPerRun ?? IMAGE_DESCRIBE_MAX_PER_RUN;
41
44
  const maxRunMs = options.maxRunMs ?? IMAGE_DESCRIBE_MAX_RUN_MS;
42
45
  const sentThisRun = 0;
43
- const run = { options, mode, fetchImpl, described, orderedKeys, failedImages, stateDirty, settledThisRun, now, describingMs: 0, maxPerRun, maxRunMs, sentThisRun };
46
+ const store = modeStoresImages(mode)
47
+ ? await openImageStoreRun(options.stateDir, { ...(options.storeMaxUploads === undefined ? {} : { maxUploads: options.storeMaxUploads }), ...(options.storeMaxConfirms === undefined ? {} : { maxConfirms: options.storeMaxConfirms }), ...(options.storeMaxRunMs === undefined ? {} : { maxRunMs: options.storeMaxRunMs }) })
48
+ : undefined;
49
+ const run = { options, mode, fetchImpl, described, orderedKeys, failedImages, stateDirty, settledThisRun, now, describingMs: 0, maxPerRun, maxRunMs, sentThisRun, ...(store ? { store } : {}) };
44
50
  const streamMinBytes = options.streamMinBytes ?? STREAM_MIN_BYTES;
45
51
  return {
46
52
  mode,
47
53
  streamMinBytes,
48
54
  isDescribed: (sha256) => described.has(imageStateKey(sha256)),
49
55
  hasDescribeRoom: (sha256s) => describeRoomLeft(run, sha256s),
56
+ hasStoreRoom: (sha256s) => (run.store ? hasStoreRoom(run.store, sha256s, now()) : false),
50
57
  async prepareTranscript(input) {
51
- // BLI-23347: in drop mode each base64 PDF becomes a marker block (its text, or its described page images).
58
+ // 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
59
  const imageFinds = findsOfText(input.text);
53
- const finds0 = mode === "describe_and_drop"
60
+ const finds0 = modeDropsImages(mode)
54
61
  ? 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
62
  : imageFinds;
56
63
  const { replace, finds, ...afterImages } = await describeAndReplace(run, input, finds0);
57
- if (mode !== "describe_and_drop")
64
+ if (!modeDropsImages(mode))
58
65
  return { ...afterImages, text: input.text, changed: false, slim: null };
59
66
  // BLI-23317: the image swap and the slim rules are ONE pass over the lines (one joined string, no
60
67
  // intermediate copy). A pass that cannot run falls back to the two-step order, which is the same bytes.
@@ -66,7 +73,7 @@ export async function openImageDescriptions(options) {
66
73
  const slim = slimOrKeep(replaced.text, input.source, { sessionId: input.sessionId });
67
74
  return { ...afterImages, text: slim.text, changed: slim.text !== input.text, slim: slim.stats };
68
75
  },
69
- prepareTranscriptFile: mode === "describe_and_drop" ? (input) => prepareFileInTwoPasses(run, input) : undefined,
76
+ prepareTranscriptFile: (input) => prepareFileInTwoPasses(run, input),
70
77
  };
71
78
  }
72
79
  /** Files smaller than this are cheaper to read whole; the streamed path pays a second read. */
@@ -82,9 +89,17 @@ function findsOfText(text) {
82
89
  unique,
83
90
  images,
84
91
  base64For: (image) => base64Of(text, image),
92
+ blocksOf: (image) => blocksOfText(text, image),
85
93
  swapped: (replace) => countImageSwaps({ images, replace }),
86
94
  };
87
95
  }
96
+ /** The base64 of an image from the pinned file, in blocks of `IMAGE_STORE_BLOCK_CHARS`; each read is bounded, never the whole run. */
97
+ async function* blocksOfPinned(source, image) {
98
+ for (let at = image.base64Start; at < image.base64End; at += IMAGE_STORE_BLOCK_CHARS) {
99
+ const block = await source.read(at, Math.min(image.base64End, at + IMAGE_STORE_BLOCK_CHARS) - at);
100
+ yield block.toString("latin1");
101
+ }
102
+ }
88
103
  function findsOfCensus(census, source) {
89
104
  return {
90
105
  total: census.total,
@@ -98,6 +113,8 @@ function findsOfCensus(census, source) {
98
113
  throw new TranscriptNotStreamable("file_changed_during_pass");
99
114
  return payload.toString("latin1");
100
115
  },
116
+ // The same pinned bytes, in 48 Ki-character blocks, so a large image is decoded to a file without being held whole.
117
+ blocksOf: (image) => blocksOfPinned(source, image),
101
118
  swapped: (replace) => {
102
119
  let replacedCount = 0;
103
120
  let charsSaved = 0;
@@ -123,7 +140,9 @@ async function prepareFileInTwoPasses(run, input) {
123
140
  const census = await censusOfLines(source.lines());
124
141
  // Describe attempts finish and the swap decisions are settled before a single line is swapped, as with the whole text.
125
142
  // BLI-23347: the census also found the PDFs; they are converted here and swapped in the same pass as the images.
126
- const pdfFinds = await addPdfsToFinds(findsOfCensus(census, source), census.pdfs, pdfContext(run, {
143
+ const keeps = !modeDropsImages(run.mode); // BLI-23524: nothing is swapped, so no PDFs and pass two is a copy
144
+ const imageFinds = findsOfCensus(census, source);
145
+ const pdfFinds = keeps ? imageFinds : await addPdfsToFinds(imageFinds, census.pdfs, pdfContext(run, {
127
146
  stateDir: run.options.stateDir,
128
147
  read: (pdf) => source.read(pdf.base64Start, pdf.base64End - pdf.base64Start),
129
148
  changed: () => new TranscriptNotStreamable("file_changed_during_pass"),
@@ -132,14 +151,15 @@ async function prepareFileInTwoPasses(run, input) {
132
151
  // Pass two: swap and slim over exactly the bytes pass one saw, writing the result to staging as it goes.
133
152
  writer = input.stageDir ? await openStagedOutput(input.stageDir) : undefined;
134
153
  const memory = writer ? undefined : new LineBytesSink(source.length);
135
- const slim = await slimLineStream(source.lines(), input.source, mergeOccurrences(census.occurrences, finds.extraOccurrences), replace, writer ?? memory);
154
+ const slim = keeps ? await copyLinesStream(source.lines(), writer ?? memory) : await slimLineStream(source.lines(), input.source, mergeOccurrences(census.occurrences, finds.extraOccurrences), replace, writer ?? memory);
136
155
  if (slim.replacedCount !== afterImages.replacedCount)
137
156
  throw new TranscriptNotStreamable("images_changed_between_passes");
138
157
  await source.verifyUnchanged();
139
158
  const staged = await writer?.finish();
140
159
  // The sanitizer reads the whole text: from the staged file when one was written, else from the sink's buffer.
141
160
  const text = staged ? (input.omitText ? "" : await readStagedText(staged)) : memory.text();
142
- logSlimmed(input.source, input.sessionId, slim.stats, startedAt);
161
+ if (slim.stats)
162
+ logSlimmed(input.source, input.sessionId, slim.stats, startedAt);
143
163
  return { ...afterImages, text, changed: slim.changed, slim: slim.stats, readBytes: source.length, ...(staged ? { staged } : {}) };
144
164
  }
145
165
  catch (error) {
@@ -164,8 +184,9 @@ function describeRoomLeft(run, sha256s) {
164
184
  });
165
185
  }
166
186
  async function describeAndReplace(run, input, finds) {
167
- const { mode, described, options, orderedKeys, failedImages } = run;
168
- const replace = (image) => described.has(imageStateKey(image.sha256));
187
+ const { mode, described, options, orderedKeys, failedImages, store } = run;
188
+ // describe_and_drop replaces what has a description; store_and_drop replaces what Tower confirmed for THIS session this tick.
189
+ const replace = (image) => store ? isStoredThisRun(store, image.sha256, input.sessionId) : described.has(imageStateKey(image.sha256));
169
190
  const { unique } = finds;
170
191
  if (finds.total === 0)
171
192
  return { replace, finds, pendingImages: [], imageCount: 0, uniqueCount: 0, replacedCount: 0, charsSaved: 0, deferredOnly: false };
@@ -177,14 +198,26 @@ async function describeAndReplace(run, input, finds) {
177
198
  await writeImageDescriptionState(options.stateDir, { described: orderedKeys, failed: failedImages });
178
199
  run.stateDirty = false;
179
200
  }
201
+ // store_and_drop: after describing, before the swap. Whatever Tower does not confirm stays inline (BLI-23329).
202
+ const stored = store
203
+ ? await storeImages(store, {
204
+ fetchImpl: run.fetchImpl, dashboardUrl: options.dashboardUrl, deviceToken: options.deviceToken, stateDir: options.stateDir,
205
+ ...(options.sleep ? { sleep: options.sleep } : {}), now: run.now, source: input.source, sessionId: input.sessionId,
206
+ provenance: input.provenance, images: [...unique.values()], blocksOf: finds.blocksOf,
207
+ })
208
+ : null;
180
209
  const deferredOnly = counts.over_budget + counts.backing_off > 0
181
210
  && 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 };
211
+ const replaced = modeDropsImages(mode) ? finds.swapped(replace) : { replacedCount: 0, charsSaved: 0 };
183
212
  // BLI-23317: an image over the describe limit is never sent, so it can never gain a description and
184
213
  // never changes the output. Listing it made the describer look like it still had room, and the
185
214
  // transcript was rebuilt on every tick (rebuilt_describer_has_room). Counted in `too_large` above.
215
+ // store_and_drop: what is still pending is every image Tower did not confirm this tick (described or not), unless it is
216
+ // 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
217
  const pendingImages = [...unique.values()]
187
- .filter((image) => !described.has(imageStateKey(image.sha256)) && image.byteSize <= IMAGE_DESCRIBE_MAX_BYTES)
218
+ .filter((image) => store
219
+ ? !isStoredThisRun(store, image.sha256, input.sessionId) && image.byteSize <= IMAGE_STORE_MAX_BYTES
220
+ : !described.has(imageStateKey(image.sha256)) && image.byteSize <= IMAGE_DESCRIBE_MAX_BYTES)
188
221
  .map((image) => image.sha256);
189
222
  console.error(TAG + " transcript prepared", JSON.stringify({
190
223
  mode,
@@ -196,7 +229,8 @@ async function describeAndReplace(run, input, finds) {
196
229
  failed_reasons: failedReasons,
197
230
  replaced: replaced.replacedCount,
198
231
  chars_saved: replaced.charsSaved,
199
- kept_without_description: mode === "describe_and_drop" ? finds.total - replaced.replacedCount : 0,
232
+ kept_without_description: modeDropsImages(mode) ? finds.total - replaced.replacedCount : 0,
233
+ ...(stored ? { store: stored } : {}),
200
234
  }));
201
235
  return {
202
236
  replace,
@@ -281,7 +315,7 @@ function countImageOutcomes(run, toSend, counts) {
281
315
  * Drop mode replaces known images, then slims; keep mode preserves all text.
282
316
  */
283
317
  export function uploadFormOfTranscript(text, source, mode, describedKeys) {
284
- if (mode !== "describe_and_drop")
318
+ if (!modeDropsImages(mode))
285
319
  return text;
286
320
  const images = findEmbeddedImages(text);
287
321
  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 }));