@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.
- package/dist/adapters/codex-session-signals.js +17 -9
- package/dist/adapters/raw-evidence-facts.js +3 -0
- package/dist/adapters/raw-evidence-snapshot-supersede.js +7 -3
- package/dist/adapters/raw-evidence-transcript-body.js +23 -4
- package/dist/adapters/raw-evidence-transcript-stream.js +7 -6
- package/dist/adapters/raw-evidence-volatile-reuse.js +10 -0
- package/dist/commands/ai-spend.js +32 -0
- package/dist/commands/image.js +36 -2
- package/dist/commands/local-args-tower-image.js +11 -3
- package/dist/commands/local-args-tower.js +3 -1
- package/dist/commands/local-args.js +3 -1
- package/dist/commands/local-help-commands-tower-ops.js +24 -0
- package/dist/commands/local-help.js +6 -0
- package/dist/commands/local-run-ops.js +4 -0
- package/dist/commands/local.js +2 -0
- package/dist/commands/pr-output.js +79 -0
- package/dist/commands/public-root.js +1 -1
- package/dist/commands/sources-verdict.js +2 -2
- package/dist/evidence-upload-direct.js +3 -1
- package/dist/evidence-upload-object.js +63 -8
- package/dist/evidence-upload-plan.js +75 -9
- package/dist/evidence-upload-staged.js +74 -7
- package/dist/evidence-upload-terminal.js +6 -9
- package/dist/image-description-session.js +49 -15
- package/dist/image-mode-state.js +5 -5
- package/dist/image-store-send.js +326 -0
- package/dist/image-store-stage.js +54 -0
- package/dist/image-store-state.js +78 -0
- package/dist/slim/keep-line-copy.js +34 -0
- package/package.json +4 -4
|
@@ -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,
|
|
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,
|
|
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
|
|
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
|
-
|
|
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
|
-
/**
|
|
15
|
-
|
|
16
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
|
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
|
|
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
|
|
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:
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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) =>
|
|
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
|
|
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
|
|
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)));
|
package/dist/image-mode-state.js
CHANGED
|
@@ -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
|
|
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
|
|
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 }));
|