@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.
@@ -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 { postImageToDescribeDoor } from "./image-describe-client.js";
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 { openTranscriptLines, TranscriptNotStreamable, TranscriptTooLargeError } from "./adapters/transcript-text-stream.js";
13
- import { slimLineStream } from "./slim/slim-stream.js";
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, imageRetryDelayMs, readDescribedImageKeys, readFailedImages, writeImageDescriptionState, } from "./image-description-state.js";
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
- base64For: (image) => {
84
- const kept = census.base64.get(image.sha256);
85
- if (!kept)
86
- throw new Error("image_base64_not_kept");
87
- return kept.toString("latin1");
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
- // Pass one: discover. Nothing of the text is kept; only the images and, for those that may still be sent, their base64.
106
- const room = Math.max(0, run.maxPerRun - run.sentThisRun);
107
- let wanted = 0;
108
- const first = openTranscriptLines(input.filePath, { stopAfter: input.maxFileBytes ? input.maxFileBytes + 1 : undefined });
109
- const census = await censusOfLines(first.lines, (image) => {
110
- // The same filters `selectImagesToDescribe` applies, so what is kept is what may be sent.
111
- const key = imageStateKey(image.sha256);
112
- const failure = run.failedImages[key];
113
- if (wanted >= room || run.described.has(key) || run.settledThisRun.has(image.sha256) || image.byteSize > IMAGE_DESCRIBE_MAX_BYTES)
114
- return false;
115
- if (failure && failure.retry_after > run.now())
116
- return false;
117
- wanted += 1;
118
- return true;
119
- });
120
- const readBytes = first.bytesRead();
121
- if (input.maxFileBytes && readBytes > input.maxFileBytes)
122
- throw new TranscriptTooLargeError(input.maxFileBytes);
123
- // Describe attempts finish and the swap decisions are settled before a single line is swapped, as with the whole text.
124
- const { replace, finds: _finds, ...afterImages } = await describeAndReplace(run, input, findsOfCensus(census));
125
- // Pass two: swap and slim over exactly the bytes pass one saw.
126
- const second = openTranscriptLines(input.filePath, { byteLimit: readBytes });
127
- const slim = await slimLineStream(second.lines, input.source, replace, readBytes);
128
- if (slim.replacedCount !== afterImages.replacedCount)
129
- throw new TranscriptNotStreamable("images_changed_between_passes");
130
- logSlimmed(input.source, input.sessionId, slim.stats, startedAt);
131
- return { ...afterImages, text: slim.text, changed: slim.changed, slim: slim.stats, readBytes };
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. A key is the first 32 hex characters of the
6
- * image's sha256 (128 bits: no two images on one laptop will ever share one); the file holds keys
7
- * only, never a description, never a path, never a byte. If it is lost or corrupt the worst case
8
- * is the images are sent again and the server answers "known" without calling a model.
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 path from "node:path";
11
- import { isMissingFileFailure } from "./health-detail.js";
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
- try {
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
- try {
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
- const keys = state.described;
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: await readFailedImages(stateDir) });
32
+ await writeImageDescriptionState(stateDir, { described: keys, failed: {} });
88
33
  }
@@ -1,29 +1,50 @@
1
1
  /**
2
- * Pass two of the streamed transcript: image swap and slimming over lines as they arrive (BLI-23317).
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 stream instead of a decoded string, so the original text
6
- * and the slimmed text are never both whole in memory. Output is byte-identical to `slimPass` on the
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
- * The result is ONE string, because the sanitizer after it takes one. Slimmed lines are written as
10
- * UTF-8 into one byte buffer sized to the file (off the JS heap, one byte per ASCII character, no
11
- * per-line garbage waiting on the collector) and decoded once at the end, so the last step holds the
12
- * bytes beside the string they make, not a second copy of the string. Text that came from valid
13
- * UTF-8 survives encode and decode unchanged; the slim rules re-escape lone surrogates, so none is
14
- * ever written.
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 { findEmbeddedImages, replaceImagesWithMarkers } from "@bli-cockpit/telemetry-core";
17
- import { lineMayHoldImage } from "../image-census-stream.js";
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
- /** UTF-8 bytes of the lines so far, in a buffer that starts at the input's size and grows only if the output outgrows it. */
21
- class LineBytes {
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, inputBytes) {
60
+ export async function slimLineStream(lines, kind, occurrences, replace, sink) {
49
61
  const slimmer = createSlimmer(kind);
50
- const output = new LineBytes(inputBytes);
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 original of lines) {
56
- let line = original;
57
- if (lineMayHoldImage(line)) {
58
- const found = findEmbeddedImages(line);
59
- if (found.length > 0) {
60
- const replaced = replaceImagesWithMarkers(line, found, replace);
61
- if (replaced.replacedCount > 0) {
62
- line = replaced.text;
63
- replacedCount += replaced.replacedCount;
64
- charsSaved += replaced.charsSaved;
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 out = slimmer.slimLine(line);
69
- if (line !== original || out !== original)
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
- output.newline();
113
+ await sink.write("\n");
73
114
  first = false;
74
- output.line(out);
115
+ await sink.write(slimmed);
75
116
  }
76
- return { text: output.text(), stats: slimmer.stats(), replacedCount, charsSaved, changed };
117
+ if (next < occurrences.length)
118
+ throw new TranscriptNotStreamable("images_changed_between_passes");
119
+ return { stats: slimmer.stats(), replacedCount, charsSaved, changed };
77
120
  }
@@ -172,13 +172,10 @@ export async function openStagingAllowance(stateDir, env, deps = {}) {
172
172
  return { snapshot, admitted_bytes: 0, deferred: {} };
173
173
  }
174
174
  /**
175
- * May a file of this size be copied into staging now? Returns the reason it may
176
- * not, or null after charging the allowance. A copy that already exists on disk
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
- export function admitToStaging(allowance, byteLength, options = {}) {
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.266",
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.78",
31
- "@bli-cockpit/mcp": "0.1.162",
32
- "@bli-cockpit/telemetry-core": "0.1.85"
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",