@bli-cockpit/cli 0.2.266 → 0.2.268
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/pinned-transcript.js +143 -0
- package/dist/adapters/raw-evidence-budget-admission.js +19 -6
- package/dist/adapters/raw-evidence-manifest.js +6 -3
- package/dist/adapters/raw-evidence-sanitize-held.js +17 -9
- package/dist/adapters/raw-evidence-snapshot-supersede.js +55 -5
- package/dist/adapters/raw-evidence-stage-file.js +43 -0
- package/dist/adapters/raw-evidence-transcript-body.js +8 -1
- package/dist/adapters/raw-evidence-transcript-file.js +56 -73
- package/dist/adapters/raw-evidence-transcript-stage.js +84 -0
- package/dist/adapters/raw-evidence-transcript-stream.js +3 -3
- package/dist/adapters/staged-output.js +128 -0
- package/dist/adapters/transcript-text-stream.js +9 -91
- package/dist/commands/public-root.js +1 -1
- package/dist/image-byte-scan.js +185 -0
- package/dist/image-census-stream.js +33 -23
- package/dist/image-description-send.js +80 -0
- package/dist/image-description-session.js +47 -104
- package/dist/image-description-state.js +17 -72
- package/dist/slim/slim-stream.js +84 -41
- package/dist/staging-gate.js +36 -16
- package/package.json +4 -4
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { postImageToDescribeDoor } from "./image-describe-client.js";
|
|
2
|
+
import { imageRetryDelayMs, imageStateKey } from "./image-description-state.js";
|
|
3
|
+
const TAG = "[image-descriptions]";
|
|
4
|
+
const CONCURRENCY = 3;
|
|
5
|
+
function markDescribed(run, sha256) {
|
|
6
|
+
const { described, orderedKeys } = run;
|
|
7
|
+
const key = imageStateKey(sha256);
|
|
8
|
+
if (described.has(key))
|
|
9
|
+
return;
|
|
10
|
+
described.add(key);
|
|
11
|
+
orderedKeys.push(key);
|
|
12
|
+
}
|
|
13
|
+
async function sendOne(run, image, payload, input) {
|
|
14
|
+
const { options, fetchImpl, settledThisRun, failedImages } = run;
|
|
15
|
+
// Counted before the first await: the budget check that let this send through ran in the same tick.
|
|
16
|
+
run.sentThisRun += 1;
|
|
17
|
+
const base64 = await payload;
|
|
18
|
+
const outcome = await postImageToDescribeDoor({ fetchImpl, dashboardUrl: options.dashboardUrl, deviceToken: options.deviceToken, sleep: options.sleep }, {
|
|
19
|
+
schema_version: "image-describe.v1",
|
|
20
|
+
sha256: image.sha256,
|
|
21
|
+
media_type: image.mediaType,
|
|
22
|
+
byte_size: image.byteSize,
|
|
23
|
+
content_base64: base64,
|
|
24
|
+
source: input.source,
|
|
25
|
+
session_id: input.sessionId.slice(0, 200),
|
|
26
|
+
});
|
|
27
|
+
const key = imageStateKey(image.sha256);
|
|
28
|
+
if (outcome.ok) {
|
|
29
|
+
markDescribed(run, image.sha256);
|
|
30
|
+
settledThisRun.set(image.sha256, outcome.status);
|
|
31
|
+
if (failedImages[key]) {
|
|
32
|
+
delete failedImages[key];
|
|
33
|
+
run.stateDirty = true;
|
|
34
|
+
}
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
settledThisRun.set(image.sha256, `failed:${outcome.reason}`);
|
|
38
|
+
rememberFailure(run, key, outcome);
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Remember image-specific HTTP failures for backoff. Transport failures, 401
|
|
42
|
+
* and 429 say nothing about the image and must not be remembered.
|
|
43
|
+
*/
|
|
44
|
+
function rememberFailure(run, key, outcome) {
|
|
45
|
+
const { failedImages, now } = run;
|
|
46
|
+
if (outcome.httpStatus < 400 || outcome.httpStatus === 401 || outcome.httpStatus === 429)
|
|
47
|
+
return;
|
|
48
|
+
const attempts = (failedImages[key]?.attempts ?? 0) + 1;
|
|
49
|
+
const delay = imageRetryDelayMs(attempts, outcome.permanent);
|
|
50
|
+
failedImages[key] = { reason: outcome.reason, attempts, retry_after: now() + delay };
|
|
51
|
+
run.stateDirty = true;
|
|
52
|
+
console.error(TAG + " image not described, will not be re-sent until later", JSON.stringify({ reason: outcome.reason, http_status: outcome.httpStatus, attempts, retry_in_hours: Math.round(delay / 3_600_000), sha256_prefix: key.slice(0, 16) }));
|
|
53
|
+
}
|
|
54
|
+
export async function sendPendingImages(run, toSend, counts, input, base64For) {
|
|
55
|
+
const { now, maxPerRun, maxRunMs } = run;
|
|
56
|
+
const callStartedAt = now();
|
|
57
|
+
let next = 0;
|
|
58
|
+
let aborted = false;
|
|
59
|
+
const worker = async () => {
|
|
60
|
+
while (next < toSend.length && !aborted) {
|
|
61
|
+
const image = toSend[next++];
|
|
62
|
+
const timeUp = run.describingMs + (now() - callStartedAt) > maxRunMs;
|
|
63
|
+
if (run.sentThisRun >= maxPerRun || timeUp) {
|
|
64
|
+
counts.over_budget += 1;
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
try {
|
|
68
|
+
// The payload is read from the source only now, for this one image (BLI-23317).
|
|
69
|
+
await sendOne(run, image, base64For(image), input);
|
|
70
|
+
}
|
|
71
|
+
catch (error) {
|
|
72
|
+
// A source that changed under the passes ends the whole prepare: the other workers send nothing more.
|
|
73
|
+
aborted = true;
|
|
74
|
+
throw error;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
await Promise.all(Array.from({ length: Math.min(CONCURRENCY, toSend.length) }, worker));
|
|
79
|
+
run.describingMs += now() - callStartedAt;
|
|
80
|
+
}
|
|
@@ -3,16 +3,20 @@
|
|
|
3
3
|
* Describe each distinct image once. Keep images without stored descriptions;
|
|
4
4
|
* only describe_and_drop replaces described images and then runs the slim rules.
|
|
5
5
|
* Failures never fail the transcript. Logs contain metadata, never image bytes
|
|
6
|
-
* or descriptions.
|
|
6
|
+
* or descriptions. The sends themselves (three at a time, budgets, backoff) are in
|
|
7
|
+
* `image-description-send.ts`.
|
|
7
8
|
*/
|
|
8
9
|
import { IMAGE_DESCRIBE_MAX_BYTES, IMAGE_HANDLING_MODE, base64Of, findEmbeddedImages, formatImageMarker, replaceImagesWithMarkers, } from "@bli-cockpit/telemetry-core";
|
|
9
|
-
import {
|
|
10
|
+
import { sendPendingImages } from "./image-description-send.js";
|
|
10
11
|
import { resolveImageHandlingMode } from "./image-mode-state.js";
|
|
11
12
|
import { censusOfLines } from "./image-census-stream.js";
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
13
|
+
import { sha256OfBase64Run } from "./image-byte-scan.js";
|
|
14
|
+
import { openPinnedTranscript } from "./adapters/pinned-transcript.js";
|
|
15
|
+
import { openStagedOutput, readStagedText } from "./adapters/staged-output.js";
|
|
16
|
+
import { TranscriptNotStreamable } from "./adapters/transcript-text-stream.js";
|
|
17
|
+
import { LineBytesSink, slimLineStream } from "./slim/slim-stream.js";
|
|
14
18
|
import { logSlimmed, slimOrKeep, slimPassOrNull, slimTranscript } from "./slim/slim-transcript.js";
|
|
15
|
-
import { imageStateKey,
|
|
19
|
+
import { imageStateKey, readDescribedImageKeys, readFailedImages, writeImageDescriptionState, } from "./image-description-state.js";
|
|
16
20
|
/** New images sent to the door in one sync (all worktrees together); the count never refills within a tick. */
|
|
17
21
|
export const IMAGE_DESCRIBE_MAX_PER_RUN = 100;
|
|
18
22
|
/**
|
|
@@ -22,7 +26,6 @@ export const IMAGE_DESCRIBE_MAX_PER_RUN = 100;
|
|
|
22
26
|
* spent the budget on that work, so almost nothing was described (BLI-22789: 1 of 1,874, then 10).
|
|
23
27
|
*/
|
|
24
28
|
export const IMAGE_DESCRIBE_MAX_RUN_MS = 120_000;
|
|
25
|
-
const CONCURRENCY = 3;
|
|
26
29
|
const TAG = "[image-descriptions]";
|
|
27
30
|
export async function openImageDescriptions(options) {
|
|
28
31
|
const mode = options.mode ?? IMAGE_HANDLING_MODE;
|
|
@@ -76,15 +79,18 @@ function findsOfText(text) {
|
|
|
76
79
|
swapped: (replace) => countImageSwaps({ images, replace }),
|
|
77
80
|
};
|
|
78
81
|
}
|
|
79
|
-
function findsOfCensus(census) {
|
|
82
|
+
function findsOfCensus(census, source) {
|
|
80
83
|
return {
|
|
81
84
|
total: census.total,
|
|
82
85
|
unique: census.unique,
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
86
|
+
// The one payload about to be sent is read from its offsets in the pinned file and re-hashed: bytes that
|
|
87
|
+
// no longer decode to the image the census saw mean the file changed under us, and a request carrying
|
|
88
|
+
// them would be refused by the door and remembered as a failure of the IMAGE.
|
|
89
|
+
base64For: async (image) => {
|
|
90
|
+
const payload = await source.read(image.base64Start, image.base64End - image.base64Start);
|
|
91
|
+
if (sha256OfBase64Run(payload, 0, payload.length) !== image.sha256)
|
|
92
|
+
throw new TranscriptNotStreamable("file_changed_during_pass");
|
|
93
|
+
return payload.toString("latin1");
|
|
88
94
|
},
|
|
89
95
|
swapped: (replace) => {
|
|
90
96
|
let replacedCount = 0;
|
|
@@ -102,33 +108,35 @@ function findsOfCensus(census) {
|
|
|
102
108
|
}
|
|
103
109
|
async function prepareFileInTwoPasses(run, input) {
|
|
104
110
|
const startedAt = Date.now();
|
|
105
|
-
//
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
//
|
|
111
|
-
const
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
throw
|
|
130
|
-
|
|
131
|
-
|
|
111
|
+
// The file is opened once and its length pinned: the census offsets, the describe payload reads and the swap
|
|
112
|
+
// pass all read these bytes, and a source that stopped being them is set aside (`verifyUnchanged`).
|
|
113
|
+
const source = await openPinnedTranscript(input.filePath, { maxFileBytes: input.maxFileBytes });
|
|
114
|
+
let writer;
|
|
115
|
+
try {
|
|
116
|
+
// Pass one: discover. Nothing of the text is kept; only where each image is and what it is.
|
|
117
|
+
const census = await censusOfLines(source.lines());
|
|
118
|
+
// Describe attempts finish and the swap decisions are settled before a single line is swapped, as with the whole text.
|
|
119
|
+
const { replace, finds: _finds, ...afterImages } = await describeAndReplace(run, input, findsOfCensus(census, source));
|
|
120
|
+
// Pass two: swap and slim over exactly the bytes pass one saw, writing the result to staging as it goes.
|
|
121
|
+
writer = input.stageDir ? await openStagedOutput(input.stageDir) : undefined;
|
|
122
|
+
const memory = writer ? undefined : new LineBytesSink(source.length);
|
|
123
|
+
const slim = await slimLineStream(source.lines(), input.source, census.occurrences, replace, writer ?? memory);
|
|
124
|
+
if (slim.replacedCount !== afterImages.replacedCount)
|
|
125
|
+
throw new TranscriptNotStreamable("images_changed_between_passes");
|
|
126
|
+
await source.verifyUnchanged();
|
|
127
|
+
const staged = await writer?.finish();
|
|
128
|
+
// The sanitizer reads the whole text: from the staged file when one was written, else from the sink's buffer.
|
|
129
|
+
const text = staged ? await readStagedText(staged) : memory.text();
|
|
130
|
+
logSlimmed(input.source, input.sessionId, slim.stats, startedAt);
|
|
131
|
+
return { ...afterImages, text, changed: slim.changed, slim: slim.stats, readBytes: source.length, ...(staged ? { staged } : {}) };
|
|
132
|
+
}
|
|
133
|
+
catch (error) {
|
|
134
|
+
await writer?.abandon();
|
|
135
|
+
throw error;
|
|
136
|
+
}
|
|
137
|
+
finally {
|
|
138
|
+
await source.close();
|
|
139
|
+
}
|
|
132
140
|
}
|
|
133
141
|
function describeRoomLeft(run, sha256s) {
|
|
134
142
|
if (run.sentThisRun >= run.maxPerRun)
|
|
@@ -143,53 +151,6 @@ function describeRoomLeft(run, sha256s) {
|
|
|
143
151
|
return !(failure && failure.retry_after > run.now());
|
|
144
152
|
});
|
|
145
153
|
}
|
|
146
|
-
function markDescribed(run, sha256) {
|
|
147
|
-
const { described, orderedKeys } = run;
|
|
148
|
-
const key = imageStateKey(sha256);
|
|
149
|
-
if (described.has(key))
|
|
150
|
-
return;
|
|
151
|
-
described.add(key);
|
|
152
|
-
orderedKeys.push(key);
|
|
153
|
-
}
|
|
154
|
-
async function sendOne(run, image, base64, input) {
|
|
155
|
-
const { options, fetchImpl, settledThisRun, failedImages } = run;
|
|
156
|
-
run.sentThisRun += 1;
|
|
157
|
-
const outcome = await postImageToDescribeDoor({ fetchImpl, dashboardUrl: options.dashboardUrl, deviceToken: options.deviceToken, sleep: options.sleep }, {
|
|
158
|
-
schema_version: "image-describe.v1",
|
|
159
|
-
sha256: image.sha256,
|
|
160
|
-
media_type: image.mediaType,
|
|
161
|
-
byte_size: image.byteSize,
|
|
162
|
-
content_base64: base64,
|
|
163
|
-
source: input.source,
|
|
164
|
-
session_id: input.sessionId.slice(0, 200),
|
|
165
|
-
});
|
|
166
|
-
const key = imageStateKey(image.sha256);
|
|
167
|
-
if (outcome.ok) {
|
|
168
|
-
markDescribed(run, image.sha256);
|
|
169
|
-
settledThisRun.set(image.sha256, outcome.status);
|
|
170
|
-
if (failedImages[key]) {
|
|
171
|
-
delete failedImages[key];
|
|
172
|
-
run.stateDirty = true;
|
|
173
|
-
}
|
|
174
|
-
return;
|
|
175
|
-
}
|
|
176
|
-
settledThisRun.set(image.sha256, `failed:${outcome.reason}`);
|
|
177
|
-
rememberFailure(run, key, outcome);
|
|
178
|
-
}
|
|
179
|
-
/**
|
|
180
|
-
* Remember image-specific HTTP failures for backoff. Transport failures, 401
|
|
181
|
-
* and 429 say nothing about the image and must not be remembered.
|
|
182
|
-
*/
|
|
183
|
-
function rememberFailure(run, key, outcome) {
|
|
184
|
-
const { failedImages, now } = run;
|
|
185
|
-
if (outcome.httpStatus < 400 || outcome.httpStatus === 401 || outcome.httpStatus === 429)
|
|
186
|
-
return;
|
|
187
|
-
const attempts = (failedImages[key]?.attempts ?? 0) + 1;
|
|
188
|
-
const delay = imageRetryDelayMs(attempts, outcome.permanent);
|
|
189
|
-
failedImages[key] = { reason: outcome.reason, attempts, retry_after: now() + delay };
|
|
190
|
-
run.stateDirty = true;
|
|
191
|
-
console.error(TAG + " image not described, will not be re-sent until later", JSON.stringify({ reason: outcome.reason, http_status: outcome.httpStatus, attempts, retry_in_hours: Math.round(delay / 3_600_000), sha256_prefix: key.slice(0, 16) }));
|
|
192
|
-
}
|
|
193
154
|
async function describeAndReplace(run, input, finds) {
|
|
194
155
|
const { mode, described, options, orderedKeys, failedImages } = run;
|
|
195
156
|
const replace = (image) => described.has(imageStateKey(image.sha256));
|
|
@@ -276,24 +237,6 @@ function selectImagesToDescribe(run, unique, counts) {
|
|
|
276
237
|
}
|
|
277
238
|
return toSend;
|
|
278
239
|
}
|
|
279
|
-
async function sendPendingImages(run, toSend, counts, input, base64For) {
|
|
280
|
-
const { now, maxPerRun, maxRunMs } = run;
|
|
281
|
-
const callStartedAt = now();
|
|
282
|
-
let next = 0;
|
|
283
|
-
const worker = async () => {
|
|
284
|
-
while (next < toSend.length) {
|
|
285
|
-
const image = toSend[next++];
|
|
286
|
-
const timeUp = run.describingMs + (now() - callStartedAt) > maxRunMs;
|
|
287
|
-
if (run.sentThisRun >= maxPerRun || timeUp) {
|
|
288
|
-
counts.over_budget += 1;
|
|
289
|
-
continue;
|
|
290
|
-
}
|
|
291
|
-
await sendOne(run, image, base64For(image), input);
|
|
292
|
-
}
|
|
293
|
-
};
|
|
294
|
-
await Promise.all(Array.from({ length: Math.min(CONCURRENCY, toSend.length) }, worker));
|
|
295
|
-
run.describingMs += now() - callStartedAt;
|
|
296
|
-
}
|
|
297
240
|
function countImageOutcomes(run, toSend, counts) {
|
|
298
241
|
const { settledThisRun } = run;
|
|
299
242
|
const failedReasons = {};
|
|
@@ -2,87 +2,32 @@
|
|
|
2
2
|
* Which images this machine has already sent to the describe door (BLI-16977).
|
|
3
3
|
*
|
|
4
4
|
* One small file in the collector state directory. It is how "each distinct image is sent once"
|
|
5
|
-
* survives from one 15-minute sync to the next.
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
5
|
+
* survives from one 15-minute sync to the next. The file holds keys only, never a description, never
|
|
6
|
+
* a path, never a byte. If it is lost or corrupt the worst case is the images are sent again and the
|
|
7
|
+
* server answers "known" without calling a model.
|
|
8
|
+
*
|
|
9
|
+
* BLI-23328: the BLI Memory daemon writes the same file when it describes an image at turn end, so
|
|
10
|
+
* the format, the keys, the backoff steps and the locked read-merge-write live in telemetry-core
|
|
11
|
+
* (`image-description-store.ts`), shared by both writers. This module keeps the collector's names.
|
|
9
12
|
*/
|
|
10
|
-
import
|
|
11
|
-
|
|
12
|
-
import { readJsonFile, writeJsonFileAtomic } from "./local-state-files.js";
|
|
13
|
-
export const IMAGE_DESCRIPTION_STATE_FILENAME = "image-descriptions.json";
|
|
14
|
-
/** Oldest keys leave first past this many (about 5 MB on disk). */
|
|
15
|
-
export const IMAGE_DESCRIPTION_STATE_MAX_KEYS = 150_000;
|
|
16
|
-
const KEY_CHARS = 32;
|
|
17
|
-
export function imageStateKey(sha256) {
|
|
18
|
-
return sha256.slice(0, KEY_CHARS);
|
|
19
|
-
}
|
|
20
|
-
export function imageDescriptionStatePath(stateDir) {
|
|
21
|
-
return path.join(stateDir, IMAGE_DESCRIPTION_STATE_FILENAME);
|
|
22
|
-
}
|
|
13
|
+
import { IMAGE_DESCRIPTION_STATE_FILENAME, IMAGE_DESCRIPTION_STATE_MAX_KEYS, IMAGE_FAILED_STATE_MAX_KEYS, imageDescriptionStatePath, imageRetryDelayMs, imageStateKey, mergeImageDescriptionStates, readImageDescriptionState, updateImageDescriptionState, } from "@bli-cockpit/telemetry-core";
|
|
14
|
+
export { IMAGE_DESCRIPTION_STATE_FILENAME, IMAGE_DESCRIPTION_STATE_MAX_KEYS, IMAGE_FAILED_STATE_MAX_KEYS, imageDescriptionStatePath, imageRetryDelayMs, imageStateKey, };
|
|
23
15
|
/** The keys already described, oldest first. A missing or unreadable file is an empty list, said out loud when unreadable. */
|
|
24
16
|
export async function readDescribedImageKeys(stateDir) {
|
|
25
|
-
|
|
26
|
-
const raw = (await readJsonFile(imageDescriptionStatePath(stateDir)));
|
|
27
|
-
if (!Array.isArray(raw?.described))
|
|
28
|
-
return [];
|
|
29
|
-
return raw.described.filter((key) => typeof key === "string" && key.length === KEY_CHARS);
|
|
30
|
-
}
|
|
31
|
-
catch (error) {
|
|
32
|
-
if (!isMissingFileFailure(error)) {
|
|
33
|
-
console.error("[image-descriptions] state file unreadable, starting from an empty list", JSON.stringify({ reason: "image_state_unreadable" }));
|
|
34
|
-
}
|
|
35
|
-
return [];
|
|
36
|
-
}
|
|
37
|
-
}
|
|
38
|
-
/** Oldest failures leave first past this many. */
|
|
39
|
-
export const IMAGE_FAILED_STATE_MAX_KEYS = 5_000;
|
|
40
|
-
const HOUR_MS = 3_600_000;
|
|
41
|
-
/** A refusal that repeats for the same bytes (a 4xx): a week, then a month. */
|
|
42
|
-
const PERMANENT_BACKOFF_MS = [7 * 24 * HOUR_MS, 30 * 24 * HOUR_MS];
|
|
43
|
-
/** A failure that may pass (the door or the model was down): 1 h, 6 h, 24 h, then 72 h. */
|
|
44
|
-
const TRANSIENT_BACKOFF_MS = [HOUR_MS, 6 * HOUR_MS, 24 * HOUR_MS, 72 * HOUR_MS];
|
|
45
|
-
/** How long to leave an image alone after its Nth failed attempt. */
|
|
46
|
-
export function imageRetryDelayMs(attempts, permanent) {
|
|
47
|
-
const steps = permanent ? PERMANENT_BACKOFF_MS : TRANSIENT_BACKOFF_MS;
|
|
48
|
-
return steps[Math.min(Math.max(attempts, 1), steps.length) - 1];
|
|
17
|
+
return (await readImageDescriptionState(stateDir)).described;
|
|
49
18
|
}
|
|
50
19
|
/** The failures on record, key to record. A missing or unreadable file is an empty map, said out loud when unreadable. */
|
|
51
20
|
export async function readFailedImages(stateDir) {
|
|
52
|
-
|
|
53
|
-
const raw = (await readJsonFile(imageDescriptionStatePath(stateDir)));
|
|
54
|
-
if (!raw?.failed || typeof raw.failed !== "object" || Array.isArray(raw.failed))
|
|
55
|
-
return {};
|
|
56
|
-
const kept = {};
|
|
57
|
-
for (const [key, value] of Object.entries(raw.failed)) {
|
|
58
|
-
const record = value;
|
|
59
|
-
if (key.length !== KEY_CHARS || !record)
|
|
60
|
-
continue;
|
|
61
|
-
if (typeof record.reason !== "string" || typeof record.attempts !== "number" || typeof record.retry_after !== "number")
|
|
62
|
-
continue;
|
|
63
|
-
kept[key] = { reason: record.reason, attempts: record.attempts, retry_after: record.retry_after };
|
|
64
|
-
}
|
|
65
|
-
return kept;
|
|
66
|
-
}
|
|
67
|
-
catch (error) {
|
|
68
|
-
if (!isMissingFileFailure(error)) {
|
|
69
|
-
console.error("[image-descriptions] failed-image list unreadable, starting from an empty list", JSON.stringify({ reason: "image_failed_state_unreadable" }));
|
|
70
|
-
}
|
|
71
|
-
return {};
|
|
72
|
-
}
|
|
21
|
+
return (await readImageDescriptionState(stateDir)).failed;
|
|
73
22
|
}
|
|
23
|
+
/**
|
|
24
|
+
* Write this sync's view, merged with whatever the file holds now: the memory daemon may have
|
|
25
|
+
* described images since the tick opened its copy, and those entries must survive.
|
|
26
|
+
*/
|
|
74
27
|
export async function writeImageDescriptionState(stateDir, state) {
|
|
75
|
-
|
|
76
|
-
const kept = keys.length > IMAGE_DESCRIPTION_STATE_MAX_KEYS ? keys.slice(keys.length - IMAGE_DESCRIPTION_STATE_MAX_KEYS) : keys;
|
|
77
|
-
const failedEntries = Object.entries(state.failed).sort((a, b) => a[1].retry_after - b[1].retry_after);
|
|
78
|
-
const failed = Object.fromEntries(failedEntries.slice(Math.max(0, failedEntries.length - IMAGE_FAILED_STATE_MAX_KEYS)));
|
|
79
|
-
await writeJsonFileAtomic(imageDescriptionStatePath(stateDir), {
|
|
80
|
-
schema_version: "cockpit-image-descriptions.v1",
|
|
81
|
-
described: kept,
|
|
82
|
-
failed,
|
|
83
|
-
});
|
|
28
|
+
await updateImageDescriptionState(stateDir, (disk) => mergeImageDescriptionStates({ described: [...state.described], failed: { ...state.failed } }, disk));
|
|
84
29
|
}
|
|
85
30
|
/** Writes the described list and keeps whatever failures are on record. */
|
|
86
31
|
export async function writeDescribedImageKeys(stateDir, keys) {
|
|
87
|
-
await writeImageDescriptionState(stateDir, { described: keys, failed:
|
|
32
|
+
await writeImageDescriptionState(stateDir, { described: keys, failed: {} });
|
|
88
33
|
}
|
package/dist/slim/slim-stream.js
CHANGED
|
@@ -1,29 +1,50 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Pass two of the streamed transcript: image swap and slimming over lines as they arrive
|
|
2
|
+
* Pass two of the streamed transcript: image swap and slimming over byte lines as they arrive
|
|
3
|
+
* (BLI-23317).
|
|
3
4
|
*
|
|
4
5
|
* The same per-line work as `slimPass` (swap the chosen images for markers inside the line, then the
|
|
5
|
-
* slim rules), with the lines coming from a
|
|
6
|
-
* and the slimmed text are never both whole in memory. Output is byte-identical to `slimPass` on
|
|
7
|
-
* joined input; `raw-evidence-transcript-stream.test.ts` and the frozen goldens hold it to that.
|
|
6
|
+
* slim rules), with the lines coming from a pinned file instead of a decoded string, so the original
|
|
7
|
+
* text and the slimmed text are never both whole in memory. Output is byte-identical to `slimPass` on
|
|
8
|
+
* the joined input; `raw-evidence-transcript-stream.test.ts` and the frozen goldens hold it to that.
|
|
8
9
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
10
|
+
* Stage D: the images were found and hashed ONCE, by the census, and arrive here as occurrences with
|
|
11
|
+
* absolute byte offsets. A line with no chosen occurrence is decoded as it is; a line with one is
|
|
12
|
+
* rebuilt from the bytes around it and the marker's bytes, so a described image's base64 is never
|
|
13
|
+
* decoded to a string, let alone matched by a regex. A line that keeps an image (undescribed, too large)
|
|
14
|
+
* is decoded whole, as before.
|
|
15
|
+
*
|
|
16
|
+
* Each slimmed line goes to a `LineSink` and nowhere else: the staging file (`staged-output.ts`, hashing
|
|
17
|
+
* as it writes) when the gate allows one, else `LineBytesSink` below, one byte buffer in memory. No
|
|
18
|
+
* array of line strings is kept: with a non-Latin-1 character in most lines, the strings are two bytes a
|
|
19
|
+
* character, so they would cost as much as the text they build. The caller takes the text from the sink
|
|
20
|
+
* once the pass is over, for the sanitizer, which still reads the whole text: redaction is file-global
|
|
21
|
+
* (the first 200 ranges, absolute offsets, the residual fallback) and is not changed here.
|
|
15
22
|
*/
|
|
16
|
-
import {
|
|
17
|
-
import {
|
|
23
|
+
import { formatImageMarker } from "@bli-cockpit/telemetry-core";
|
|
24
|
+
import { TextDecoder } from "node:util";
|
|
25
|
+
import { TranscriptNotStreamable } from "../adapters/transcript-text-stream.js";
|
|
18
26
|
import { createSlimmer } from "./slim-transcript.js";
|
|
19
27
|
const NEWLINE = 0x0a;
|
|
20
|
-
/**
|
|
21
|
-
|
|
28
|
+
/**
|
|
29
|
+
* UTF-8 bytes of the lines so far, in a buffer that starts at the input's size (pages never written are
|
|
30
|
+
* never resident) and grows only if the output outgrows it. The sink when nothing is staged to disk.
|
|
31
|
+
*/
|
|
32
|
+
export class LineBytesSink {
|
|
22
33
|
buffer;
|
|
23
34
|
length = 0;
|
|
24
35
|
constructor(capacity) {
|
|
25
36
|
this.buffer = Buffer.allocUnsafe(Math.max(capacity, 1024));
|
|
26
37
|
}
|
|
38
|
+
async write(text) {
|
|
39
|
+
if (text === "\n") {
|
|
40
|
+
this.reserve(1);
|
|
41
|
+
this.buffer[this.length++] = NEWLINE;
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
const size = Buffer.byteLength(text);
|
|
45
|
+
this.reserve(size);
|
|
46
|
+
this.length += this.buffer.write(text, this.length, size, "utf8");
|
|
47
|
+
}
|
|
27
48
|
reserve(extra) {
|
|
28
49
|
const needed = this.length + extra;
|
|
29
50
|
if (needed <= this.buffer.length)
|
|
@@ -32,46 +53,68 @@ class LineBytes {
|
|
|
32
53
|
this.buffer.copy(grown, 0, 0, this.length);
|
|
33
54
|
this.buffer = grown;
|
|
34
55
|
}
|
|
35
|
-
newline() {
|
|
36
|
-
this.reserve(1);
|
|
37
|
-
this.buffer[this.length++] = NEWLINE;
|
|
38
|
-
}
|
|
39
|
-
line(text) {
|
|
40
|
-
const size = Buffer.byteLength(text);
|
|
41
|
-
this.reserve(size);
|
|
42
|
-
this.length += this.buffer.write(text, this.length, size, "utf8");
|
|
43
|
-
}
|
|
44
56
|
text() {
|
|
45
57
|
return this.buffer.toString("utf8", 0, this.length);
|
|
46
58
|
}
|
|
47
59
|
}
|
|
48
|
-
export async function slimLineStream(lines, kind, replace,
|
|
60
|
+
export async function slimLineStream(lines, kind, occurrences, replace, sink) {
|
|
49
61
|
const slimmer = createSlimmer(kind);
|
|
50
|
-
|
|
62
|
+
// One decoder for the pass: fatal on invalid UTF-8, BOM kept as a character like `Buffer.toString`.
|
|
63
|
+
const decoder = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
|
|
64
|
+
const decode = (bytes) => {
|
|
65
|
+
try {
|
|
66
|
+
return decoder.decode(bytes);
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
throw new TranscriptNotStreamable("invalid_utf8");
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
let next = 0;
|
|
51
73
|
let replacedCount = 0;
|
|
52
74
|
let charsSaved = 0;
|
|
53
75
|
let changed = false;
|
|
54
76
|
let first = true;
|
|
55
|
-
for await (const
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
77
|
+
for await (const { start, bytes } of lines) {
|
|
78
|
+
const end = start + bytes.length;
|
|
79
|
+
// Occurrences sit inside one line; one that starts before this line was missed by the census's cut.
|
|
80
|
+
if (next < occurrences.length && occurrences[next].replaceStart < start)
|
|
81
|
+
throw new TranscriptNotStreamable("images_changed_between_passes");
|
|
82
|
+
const chosen = [];
|
|
83
|
+
for (; next < occurrences.length && occurrences[next].replaceStart < end; next += 1) {
|
|
84
|
+
const image = occurrences[next];
|
|
85
|
+
if (image.replaceEnd > end)
|
|
86
|
+
throw new TranscriptNotStreamable("images_changed_between_passes");
|
|
87
|
+
if (replace(image))
|
|
88
|
+
chosen.push(image);
|
|
89
|
+
}
|
|
90
|
+
let line;
|
|
91
|
+
if (chosen.length === 0) {
|
|
92
|
+
line = decode(bytes);
|
|
93
|
+
}
|
|
94
|
+
else {
|
|
95
|
+
const pieces = [];
|
|
96
|
+
let cursor = 0;
|
|
97
|
+
for (const image of chosen) {
|
|
98
|
+
const marker = formatImageMarker({ sha256: image.sha256, mediaType: image.mediaType, byteSize: image.byteSize });
|
|
99
|
+
pieces.push(bytes.subarray(cursor, image.replaceStart - start), Buffer.from(marker, "utf8"));
|
|
100
|
+
cursor = image.replaceEnd - start;
|
|
101
|
+
replacedCount += 1;
|
|
102
|
+
charsSaved += image.replaceEnd - image.replaceStart - marker.length;
|
|
66
103
|
}
|
|
104
|
+
pieces.push(bytes.subarray(cursor));
|
|
105
|
+
line = decode(Buffer.concat(pieces));
|
|
106
|
+
changed = true;
|
|
67
107
|
}
|
|
68
|
-
const
|
|
69
|
-
|
|
108
|
+
const slimmed = slimmer.slimLine(line);
|
|
109
|
+
// An unswapped line is its own decoding, so it is changed exactly when the slim rules rewrote it.
|
|
110
|
+
if (chosen.length === 0 && slimmed !== line)
|
|
70
111
|
changed = true;
|
|
71
112
|
if (!first)
|
|
72
|
-
|
|
113
|
+
await sink.write("\n");
|
|
73
114
|
first = false;
|
|
74
|
-
|
|
115
|
+
await sink.write(slimmed);
|
|
75
116
|
}
|
|
76
|
-
|
|
117
|
+
if (next < occurrences.length)
|
|
118
|
+
throw new TranscriptNotStreamable("images_changed_between_passes");
|
|
119
|
+
return { stats: slimmer.stats(), replacedCount, charsSaved, changed };
|
|
77
120
|
}
|
package/dist/staging-gate.js
CHANGED
|
@@ -172,13 +172,10 @@ export async function openStagingAllowance(stateDir, env, deps = {}) {
|
|
|
172
172
|
return { snapshot, admitted_bytes: 0, deferred: {} };
|
|
173
173
|
}
|
|
174
174
|
/**
|
|
175
|
-
*
|
|
176
|
-
*
|
|
177
|
-
* costs nothing and must be passed as `alreadyStaged` so it is never refused.
|
|
175
|
+
* The gate's decision for a file of this size, with nothing charged, counted or logged: the one rule
|
|
176
|
+
* `admitToStaging` applies and `roomToStageUpTo` asks about beforehand.
|
|
178
177
|
*/
|
|
179
|
-
|
|
180
|
-
if (options.alreadyStaged)
|
|
181
|
-
return null;
|
|
178
|
+
function decideAdmission(allowance, byteLength) {
|
|
182
179
|
const { snapshot } = allowance;
|
|
183
180
|
const payloadCap = Math.floor(snapshot.cap_bytes * (1 - STAGING_MANIFEST_RESERVE_FRACTION));
|
|
184
181
|
const spent = allowance.admitted_bytes;
|
|
@@ -189,16 +186,6 @@ export function admitToStaging(allowance, byteLength, options = {}) {
|
|
|
189
186
|
snapshot.floor_bytes !== null &&
|
|
190
187
|
freeNow - byteLength < snapshot.floor_bytes;
|
|
191
188
|
const smallForFree = freeNow !== null && freeNow > 0 && byteLength <= freeNow * LOW_DISK_SMALL_FILE_FRACTION;
|
|
192
|
-
if (belowFloor && smallForFree) {
|
|
193
|
-
console.error("[staging-gate] low-disk floor waived, file is under 1% of free space", JSON.stringify({
|
|
194
|
-
event: STAGING_DEFERRED,
|
|
195
|
-
reason: "low_disk_waived_small_file",
|
|
196
|
-
kind: options.kind ?? null,
|
|
197
|
-
file_bytes: byteLength,
|
|
198
|
-
free_bytes: freeNow,
|
|
199
|
-
floor_bytes: snapshot.floor_bytes,
|
|
200
|
-
}));
|
|
201
|
-
}
|
|
202
189
|
if (belowFloor && !smallForFree) {
|
|
203
190
|
reason = "low_disk";
|
|
204
191
|
}
|
|
@@ -209,6 +196,39 @@ export function admitToStaging(allowance, byteLength, options = {}) {
|
|
|
209
196
|
(stagedNow > 0 && stagedNow + byteLength > payloadCap)) {
|
|
210
197
|
reason = "staging_at_cap";
|
|
211
198
|
}
|
|
199
|
+
return { reason, waivedSmallFile: belowFloor && smallForFree, freeNow };
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* Would the gate admit a file of up to this size right now? A read-only question (BLI-23317 stage D): the
|
|
203
|
+
* streamed transcript pass writes its output to staging while it is made, BEFORE the real size is known
|
|
204
|
+
* and the real admission runs, so it asks first with the source file's size as the bound. A "no" means no
|
|
205
|
+
* byte is written ahead of the gate; the ordinary path then refuses the file exactly as it always did.
|
|
206
|
+
*/
|
|
207
|
+
export function roomToStageUpTo(allowance, byteLength) {
|
|
208
|
+
if (!allowance)
|
|
209
|
+
return null;
|
|
210
|
+
return decideAdmission(allowance, byteLength).reason;
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* May a file of this size be copied into staging now? Returns the reason it may
|
|
214
|
+
* not, or null after charging the allowance. A copy that already exists on disk
|
|
215
|
+
* costs nothing and must be passed as `alreadyStaged` so it is never refused.
|
|
216
|
+
*/
|
|
217
|
+
export function admitToStaging(allowance, byteLength, options = {}) {
|
|
218
|
+
if (options.alreadyStaged)
|
|
219
|
+
return null;
|
|
220
|
+
const { snapshot } = allowance;
|
|
221
|
+
const { reason, waivedSmallFile, freeNow } = decideAdmission(allowance, byteLength);
|
|
222
|
+
if (waivedSmallFile) {
|
|
223
|
+
console.error("[staging-gate] low-disk floor waived, file is under 1% of free space", JSON.stringify({
|
|
224
|
+
event: STAGING_DEFERRED,
|
|
225
|
+
reason: "low_disk_waived_small_file",
|
|
226
|
+
kind: options.kind ?? null,
|
|
227
|
+
file_bytes: byteLength,
|
|
228
|
+
free_bytes: freeNow,
|
|
229
|
+
floor_bytes: snapshot.floor_bytes,
|
|
230
|
+
}));
|
|
231
|
+
}
|
|
212
232
|
if (!reason) {
|
|
213
233
|
allowance.admitted_bytes += byteLength;
|
|
214
234
|
return null;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bli-cockpit/cli",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.268",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -27,9 +27,9 @@
|
|
|
27
27
|
"test": "node dist/cli.js --help && node ../../scripts/assert-public-cli-routing.mjs && node ../../scripts/assert-public-cli-verb-help.mjs && node ../../scripts/assert-public-cli-surface-doors.mjs && node ../../scripts/assert-public-cli-runtime-files.mjs && node ../../scripts/assert-public-cli-exit-contract.mjs && node ../../scripts/assert-public-cli-no-fleet-posts.mjs && node ../../scripts/assert-public-cli-doctor-json.mjs && node ../../scripts/assert-public-package-pack.mjs --workspace=@bli-cockpit/cli"
|
|
28
28
|
},
|
|
29
29
|
"dependencies": {
|
|
30
|
-
"@bli-cockpit/memory-mcp": "0.1.
|
|
31
|
-
"@bli-cockpit/mcp": "0.1.
|
|
32
|
-
"@bli-cockpit/telemetry-core": "0.1.
|
|
30
|
+
"@bli-cockpit/memory-mcp": "0.1.79",
|
|
31
|
+
"@bli-cockpit/mcp": "0.1.163",
|
|
32
|
+
"@bli-cockpit/telemetry-core": "0.1.86"
|
|
33
33
|
},
|
|
34
34
|
"repository": {
|
|
35
35
|
"type": "git",
|