@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
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The store step of `store_and_drop` (BLI-23329): put each distinct image of a transcript on Tower once, and say which
|
|
3
|
+
* ones Tower confirmed THIS tick, so only those are replaced by their marker.
|
|
4
|
+
*
|
|
5
|
+
* "Stored" means one thing here: Tower answered `already_committed` (begin) or `committed` / `already_committed`
|
|
6
|
+
* (commit) for this image AND this session, and the server records the session as a user of the image in the same step
|
|
7
|
+
* that answers (`apps/dashboard/src/lib/ambient/image-store/image-store-door.ts`). Nothing local is trusted: no list of
|
|
8
|
+
* "stored" keys survives the tick, because a forget on Tower can make it a lie. Everything else (offline, refused,
|
|
9
|
+
* over budget, a 5xx, a changed source) leaves the image inline, which is today's behaviour; an image is never lost.
|
|
10
|
+
*
|
|
11
|
+
* Order per transcript: a cheap begin probe for every candidate (25 per request); the ones already on Tower are done;
|
|
12
|
+
* the rest are decoded to a temp file block by block (`image-store-stage.ts`) and sent through the ordinary chunked
|
|
13
|
+
* upload client. Logs carry counts, reason labels and the first 16 characters of a hash; never bytes, paths or ids.
|
|
14
|
+
*/
|
|
15
|
+
import fs from "node:fs/promises";
|
|
16
|
+
import os from "node:os";
|
|
17
|
+
import path from "node:path";
|
|
18
|
+
import { IMAGE_STORE_MAX_BYTES, IMAGE_STORE_MAX_PER_RUN, IMAGE_USE_SESSION_ID, IMAGE_STORE_MAX_RUN_MS, RAW_EVIDENCE_UPLOAD_DEFAULT_CHUNK_BYTES, RAW_EVIDENCE_UPLOAD_MAX_OBJECTS_PER_BEGIN, RawEvidenceUploadBeginResponseSchema, imageObjectKey, } from "@bli-cockpit/telemetry-core";
|
|
19
|
+
import { uploadRawEvidenceFilesChunked } from "./evidence-upload-client.js";
|
|
20
|
+
import { requestJson, safeFailureDetail } from "./evidence-upload-transport.js";
|
|
21
|
+
import { stageDecodedImage } from "./image-store-stage.js";
|
|
22
|
+
import { isBackingOff, readStoreFailures, rememberStoreFailure, storeFailureKey, writeStoreFailures } from "./image-store-state.js";
|
|
23
|
+
const TAG = "[image-store]";
|
|
24
|
+
/** Distinct (image, session) pairs asked about per sync. Asking is cheap (one begin per 25); uploading is what the other budget bounds. */
|
|
25
|
+
export const IMAGE_STORE_MAX_CONFIRMS_PER_RUN = 2_000;
|
|
26
|
+
/** Images sent to the upload client together; the time budget is checked between groups. */
|
|
27
|
+
const UPLOAD_GROUP = 10;
|
|
28
|
+
const BEGIN_PATH = "/api/ambient/evidence/upload/begin";
|
|
29
|
+
/** What the server says when it, not the image, is the problem: retried next tick, never remembered against the image. */
|
|
30
|
+
const SERVER_SIDE_TRANSIENT = /image_store_unavailable|image_ledger_not_committed|image_bytes_missing/;
|
|
31
|
+
/** Temp directories of images being sent are named with this; a run that died leaves one behind. */
|
|
32
|
+
export const IMAGE_STORE_TEMP_PREFIX = "tower-image-store-";
|
|
33
|
+
const STALE_TEMP_MS = 6 * 3_600_000;
|
|
34
|
+
/** Remove staging directories older than six hours (a run that was killed mid-upload). Best effort, said out loud. */
|
|
35
|
+
export async function pruneStaleImageStoreTemp(now, tempRoot = os.tmpdir()) {
|
|
36
|
+
let removed = 0;
|
|
37
|
+
try {
|
|
38
|
+
for (const name of await fs.readdir(tempRoot)) {
|
|
39
|
+
if (!name.startsWith(IMAGE_STORE_TEMP_PREFIX))
|
|
40
|
+
continue;
|
|
41
|
+
const dir = path.join(tempRoot, name);
|
|
42
|
+
const info = await fs.stat(dir).catch(() => null);
|
|
43
|
+
if (!info?.isDirectory() || now - info.mtimeMs < STALE_TEMP_MS)
|
|
44
|
+
continue;
|
|
45
|
+
await fs.rm(dir, { recursive: true, force: true });
|
|
46
|
+
removed += 1;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
catch (error) {
|
|
50
|
+
console.error(`${TAG} could not prune old staging directories`, JSON.stringify({ reason: "image_store_temp_prune_failed", error_name: error instanceof Error ? error.name : typeof error }));
|
|
51
|
+
return removed;
|
|
52
|
+
}
|
|
53
|
+
if (removed > 0)
|
|
54
|
+
console.error(`${TAG} removed staging directories a killed run left behind`, JSON.stringify({ reason: "image_store_temp_pruned", removed }));
|
|
55
|
+
return removed;
|
|
56
|
+
}
|
|
57
|
+
export async function openImageStoreRun(stateDir, overrides = {}) {
|
|
58
|
+
await pruneStaleImageStoreTemp(Date.now());
|
|
59
|
+
return {
|
|
60
|
+
stored: new Set(),
|
|
61
|
+
failures: await readStoreFailures(stateDir),
|
|
62
|
+
failuresDirty: false,
|
|
63
|
+
uploadedThisRun: 0,
|
|
64
|
+
confirmedThisRun: 0,
|
|
65
|
+
storingMs: 0,
|
|
66
|
+
unavailable: null,
|
|
67
|
+
maxUploads: overrides.maxUploads ?? IMAGE_STORE_MAX_PER_RUN,
|
|
68
|
+
maxConfirms: overrides.maxConfirms ?? IMAGE_STORE_MAX_CONFIRMS_PER_RUN,
|
|
69
|
+
maxRunMs: overrides.maxRunMs ?? IMAGE_STORE_MAX_RUN_MS,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
export const storedKey = (sha256, sessionId) => `${sha256}|${sessionId}`;
|
|
73
|
+
export function isStoredThisRun(store, sha256, sessionId) {
|
|
74
|
+
return store.stored.has(storedKey(sha256, sessionId));
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Could a prepare pass still store at least one of these images right now? False when the sync's upload or time budget
|
|
78
|
+
* is spent, Tower was unreachable this sync, or every one is backing off after a refusal. While true, a remembered
|
|
79
|
+
* transcript whose store failed is rebuilt, so a closed session whose store failed is stored on the next tick with room.
|
|
80
|
+
*/
|
|
81
|
+
export function hasStoreRoom(store, sha256s, now) {
|
|
82
|
+
if (store.unavailable)
|
|
83
|
+
return false;
|
|
84
|
+
if (store.uploadedThisRun >= store.maxUploads || store.storingMs > store.maxRunMs)
|
|
85
|
+
return false;
|
|
86
|
+
return sha256s.some((sha256) => !isBackingOff(store.failures, storeFailureKey(sha256, "", false), now));
|
|
87
|
+
}
|
|
88
|
+
function pointerFor(operatorId, image) {
|
|
89
|
+
const objectKey = imageObjectKey(operatorId, image.sha256, image.mediaType);
|
|
90
|
+
return {
|
|
91
|
+
raw_evidence_pointer_id: objectKey,
|
|
92
|
+
privacy_classification: "remote_durable_raw_evidence",
|
|
93
|
+
retention_policy: { mode: "remote_durable", privacy_classification: "remote_durable_raw_evidence" },
|
|
94
|
+
storage_scope: "remote_object",
|
|
95
|
+
storage_bucket: "ambient-raw-evidence",
|
|
96
|
+
object_key: objectKey,
|
|
97
|
+
content_hash_sha256: image.sha256,
|
|
98
|
+
byte_size: image.byteSize,
|
|
99
|
+
media_type: image.mediaType,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
function* groupsOf(items, size) {
|
|
103
|
+
for (let at = 0; at < items.length; at += size)
|
|
104
|
+
yield items.slice(at, at + size);
|
|
105
|
+
}
|
|
106
|
+
export async function storeImages(store, input) {
|
|
107
|
+
const counts = { settled_earlier: 0, too_large_to_store: 0, backing_off: 0, over_budget: 0, unavailable: 0, stored_existing: 0, stored_new: 0, failed: 0 };
|
|
108
|
+
if (input.images.length === 0)
|
|
109
|
+
return counts;
|
|
110
|
+
if (!input.provenance) {
|
|
111
|
+
console.error(`${TAG} nothing stored, images stay inline`, JSON.stringify({ reason: "store_provenance_missing", images: input.images.length }));
|
|
112
|
+
counts.unavailable = input.images.length;
|
|
113
|
+
return counts;
|
|
114
|
+
}
|
|
115
|
+
// The server records the session by this id (the id forget keys on); one it would refuse is not worth a request.
|
|
116
|
+
if (!IMAGE_USE_SESSION_ID.test(input.sessionId)) {
|
|
117
|
+
console.error(`${TAG} nothing stored, images stay inline`, JSON.stringify({ reason: "store_session_id_unusable", images: input.images.length }));
|
|
118
|
+
counts.unavailable = input.images.length;
|
|
119
|
+
return counts;
|
|
120
|
+
}
|
|
121
|
+
const provenance = input.provenance;
|
|
122
|
+
const startedAt = input.now();
|
|
123
|
+
const options = {
|
|
124
|
+
fetchImpl: input.fetchImpl, dashboardUrl: input.dashboardUrl, deviceToken: input.deviceToken, provenance,
|
|
125
|
+
generatedAt: new Date(startedAt).toISOString(), files: [], ...(input.sleep ? { sleep: input.sleep } : {}),
|
|
126
|
+
};
|
|
127
|
+
const use = { session_id: input.sessionId, source: input.source };
|
|
128
|
+
const candidates = selectCandidates(store, input, counts);
|
|
129
|
+
const needUpload = await probeAll(store, input, options, use, candidates, counts);
|
|
130
|
+
await uploadAll(store, input, options, use, needUpload, counts, startedAt);
|
|
131
|
+
store.storingMs += input.now() - startedAt;
|
|
132
|
+
if (store.failuresDirty) {
|
|
133
|
+
try {
|
|
134
|
+
await writeStoreFailures(input.stateDir, store.failures);
|
|
135
|
+
store.failuresDirty = false;
|
|
136
|
+
}
|
|
137
|
+
catch (error) {
|
|
138
|
+
console.error(`${TAG} could not record store refusals; they are forgotten at the end of this sync`, JSON.stringify({ reason: "image_store_failures_write_failed", error_name: error instanceof Error ? error.name : typeof error }));
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
console.error(`${TAG} transcript stored`, JSON.stringify({ source: input.source, images: input.images.length, ...counts, storing_ms: input.now() - startedAt }));
|
|
142
|
+
return counts;
|
|
143
|
+
}
|
|
144
|
+
function selectCandidates(store, input, counts) {
|
|
145
|
+
const candidates = [];
|
|
146
|
+
for (const image of input.images) {
|
|
147
|
+
if (isStoredThisRun(store, image.sha256, input.sessionId)) {
|
|
148
|
+
counts.settled_earlier += 1;
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
if (image.byteSize > IMAGE_STORE_MAX_BYTES) {
|
|
152
|
+
counts.too_large_to_store += 1;
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
if (store.unavailable) {
|
|
156
|
+
counts.unavailable += 1;
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
const imageKey = storeFailureKey(image.sha256, input.sessionId, false);
|
|
160
|
+
const sessionKey = storeFailureKey(image.sha256, input.sessionId, true);
|
|
161
|
+
if (isBackingOff(store.failures, imageKey, input.now()) || isBackingOff(store.failures, sessionKey, input.now())) {
|
|
162
|
+
counts.backing_off += 1;
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
if (store.confirmedThisRun >= store.maxConfirms || store.storingMs > store.maxRunMs) {
|
|
166
|
+
counts.over_budget += 1;
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
store.confirmedThisRun += 1;
|
|
170
|
+
candidates.push(image);
|
|
171
|
+
}
|
|
172
|
+
return candidates;
|
|
173
|
+
}
|
|
174
|
+
/** Ask Tower about every candidate; the ones it already holds are confirmed here, the rest come back to be uploaded. */
|
|
175
|
+
async function probeAll(store, input, options, use, candidates, counts) {
|
|
176
|
+
const needUpload = [];
|
|
177
|
+
const operatorId = options.provenance.operator_id;
|
|
178
|
+
for (const batch of groupsOf(candidates, RAW_EVIDENCE_UPLOAD_MAX_OBJECTS_PER_BEGIN)) {
|
|
179
|
+
if (store.unavailable) {
|
|
180
|
+
counts.unavailable += batch.length;
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
const response = await requestJson(options, BEGIN_PATH, {
|
|
184
|
+
schema_version: "ambient-raw-evidence-upload-begin.v1",
|
|
185
|
+
generated_at: options.generatedAt,
|
|
186
|
+
provenance: options.provenance,
|
|
187
|
+
objects: batch.map((image) => ({
|
|
188
|
+
pointer: pointerFor(operatorId, image),
|
|
189
|
+
chunk_size_bytes: RAW_EVIDENCE_UPLOAD_DEFAULT_CHUNK_BYTES,
|
|
190
|
+
chunk_count: Math.max(1, Math.ceil(image.byteSize / RAW_EVIDENCE_UPLOAD_DEFAULT_CHUNK_BYTES)),
|
|
191
|
+
image_use: use,
|
|
192
|
+
})),
|
|
193
|
+
});
|
|
194
|
+
const parsed = response.ok ? RawEvidenceUploadBeginResponseSchema.safeParse(response.body) : null;
|
|
195
|
+
if (!parsed?.success) {
|
|
196
|
+
settleUnreachable(store, response.status, safeFailureDetail(response.body), response.ok ? "begin_invalid_response" : null, batch, counts);
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
const byKey = new Map(parsed.data.objects.map((object) => [object.object_key, object]));
|
|
200
|
+
for (const image of batch) {
|
|
201
|
+
const answer = byKey.get(imageObjectKey(operatorId, image.sha256, image.mediaType));
|
|
202
|
+
if (!answer) {
|
|
203
|
+
fail(store, input, image, "begin_missing_disposition", counts);
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
if (answer.disposition === "already_committed") {
|
|
207
|
+
store.stored.add(storedKey(image.sha256, input.sessionId));
|
|
208
|
+
counts.stored_existing += 1;
|
|
209
|
+
}
|
|
210
|
+
else if (answer.disposition === "conflict") {
|
|
211
|
+
fail(store, input, image, answer.reason ?? "upload_conflict", counts);
|
|
212
|
+
}
|
|
213
|
+
else {
|
|
214
|
+
needUpload.push(image);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
return needUpload;
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* A whole `begin` request that did not come back as an answer (unreachable, a 5xx, a 4xx, a reply this CLI cannot read)
|
|
222
|
+
* says nothing about any one picture: a 400 is a request one end could not read (a server that does not know this CLI's
|
|
223
|
+
* fields, say), and remembering it against every picture for a week would punish the pictures for the wiring. Leave the
|
|
224
|
+
* rest of this sync's images inline, once, saying why; the next tick asks again.
|
|
225
|
+
*/
|
|
226
|
+
function settleUnreachable(store, status, detail, label, batch, counts) {
|
|
227
|
+
const reason = label ?? `begin_failed_http_${status}${detail ? `_${detail}` : ""}`;
|
|
228
|
+
if (!store.unavailable) {
|
|
229
|
+
store.unavailable = reason;
|
|
230
|
+
console.error(`${TAG} Tower cannot take images right now; every image stays inline for the rest of this sync`, JSON.stringify({ reason, http_status: status, server_reason: detail ?? "none" }));
|
|
231
|
+
}
|
|
232
|
+
counts.unavailable += batch.length;
|
|
233
|
+
}
|
|
234
|
+
/** What a refusal is about decides whether it is remembered, for how long, and against what. */
|
|
235
|
+
function fail(store, input, image, reason, counts) {
|
|
236
|
+
counts.failed += 1;
|
|
237
|
+
const verdict = classifyStoreFailure(reason);
|
|
238
|
+
if (verdict.remember) {
|
|
239
|
+
const key = storeFailureKey(image.sha256, input.sessionId, verdict.aboutSession);
|
|
240
|
+
const record = rememberStoreFailure(store.failures, key, `store:${reason}`.slice(0, 90), verdict.permanent, input.now());
|
|
241
|
+
store.failuresDirty = true;
|
|
242
|
+
console.error(`${TAG} image not stored, will not be re-sent until later`, JSON.stringify({ reason, attempts: record.attempts, retry_in_hours: Math.round((record.retry_after - input.now()) / 3_600_000), sha256_prefix: image.sha256.slice(0, 16) }));
|
|
243
|
+
}
|
|
244
|
+
else {
|
|
245
|
+
console.error(`${TAG} image not stored this tick, retried on the next`, JSON.stringify({ reason, sha256_prefix: image.sha256.slice(0, 16) }));
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
export function classifyStoreFailure(reason) {
|
|
249
|
+
if (/image_session_forgotten/.test(reason))
|
|
250
|
+
return { remember: true, permanent: true, aboutSession: true };
|
|
251
|
+
if (SERVER_SIDE_TRANSIENT.test(reason))
|
|
252
|
+
return { remember: false, permanent: false, aboutSession: false };
|
|
253
|
+
const status = Number(/http_(\d{3})/.exec(reason)?.[1] ?? Number.NaN);
|
|
254
|
+
if (Number.isNaN(status)) {
|
|
255
|
+
// A conflict named by the server (no HTTP status in the label) is about these bytes: leave it alone for a while.
|
|
256
|
+
// A local fault (a file that could not be read or staged) says nothing about the image and is not remembered.
|
|
257
|
+
const local = /^(file_|staged_|image_source_changed|image_stage_|empty_file|upload_request_|legacy_)/.test(reason);
|
|
258
|
+
return { remember: !local, permanent: !local, aboutSession: false };
|
|
259
|
+
}
|
|
260
|
+
if (status === 0 || status === 401 || status === 429)
|
|
261
|
+
return { remember: false, permanent: false, aboutSession: false };
|
|
262
|
+
if (status >= 500)
|
|
263
|
+
return { remember: true, permanent: false, aboutSession: false };
|
|
264
|
+
return { remember: true, permanent: true, aboutSession: false };
|
|
265
|
+
}
|
|
266
|
+
async function uploadAll(store, input, options, use, needUpload, counts, startedAt) {
|
|
267
|
+
if (needUpload.length === 0)
|
|
268
|
+
return;
|
|
269
|
+
const dir = await fs.mkdtemp(path.join(os.tmpdir(), IMAGE_STORE_TEMP_PREFIX));
|
|
270
|
+
const operatorId = options.provenance.operator_id;
|
|
271
|
+
try {
|
|
272
|
+
for (const group of groupsOf(needUpload, UPLOAD_GROUP)) {
|
|
273
|
+
if (store.unavailable) {
|
|
274
|
+
counts.unavailable += group.length;
|
|
275
|
+
continue;
|
|
276
|
+
}
|
|
277
|
+
const room = store.maxUploads - store.uploadedThisRun;
|
|
278
|
+
const timeUp = store.storingMs + (input.now() - startedAt) > store.maxRunMs;
|
|
279
|
+
const take = timeUp ? [] : group.slice(0, Math.max(0, room));
|
|
280
|
+
counts.over_budget += group.length - take.length;
|
|
281
|
+
if (take.length === 0)
|
|
282
|
+
continue;
|
|
283
|
+
store.uploadedThisRun += take.length;
|
|
284
|
+
const files = [];
|
|
285
|
+
const imageOf = new Map();
|
|
286
|
+
for (const image of take) {
|
|
287
|
+
const staged = await stageDecodedImage(dir, image, input.blocksOf(image));
|
|
288
|
+
if (!staged.ok) {
|
|
289
|
+
fail(store, input, image, staged.reason, counts);
|
|
290
|
+
continue;
|
|
291
|
+
}
|
|
292
|
+
files.push({ pointer: pointerFor(operatorId, image), local_path: staged.localPath, kind: "image_store_object", codex_session_id: input.sessionId, image_use: use });
|
|
293
|
+
imageOf.set(image.sha256, image);
|
|
294
|
+
}
|
|
295
|
+
if (files.length === 0)
|
|
296
|
+
continue;
|
|
297
|
+
const result = await uploadRawEvidenceFilesChunked({ ...options, files });
|
|
298
|
+
if (result.used_legacy_fallback) {
|
|
299
|
+
for (const image of imageOf.values())
|
|
300
|
+
fail(store, input, image, "legacy_route_cannot_store_images", counts);
|
|
301
|
+
continue;
|
|
302
|
+
}
|
|
303
|
+
for (const outcome of result.outcomes)
|
|
304
|
+
settleOutcome(store, input, outcome, imageOf, counts);
|
|
305
|
+
for (const file of files)
|
|
306
|
+
await fs.rm(file.local_path, { force: true }).catch(() => undefined);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
finally {
|
|
310
|
+
await fs.rm(dir, { recursive: true, force: true }).catch(() => undefined);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
function settleOutcome(store, input, outcome, imageOf, counts) {
|
|
314
|
+
const image = imageOf.get(outcome.pointer.content_hash_sha256 ?? "");
|
|
315
|
+
if (!image)
|
|
316
|
+
return;
|
|
317
|
+
// Only the server's own "committed" / "already_committed" counts; both are sent after the session is recorded.
|
|
318
|
+
// `reused_existing` for any other reason (a 409 that says someone else committed) is NOT a confirmation for us.
|
|
319
|
+
const confirmed = outcome.upload_state === "uploaded" || (outcome.upload_state === "reused_existing" && outcome.reason === "already_committed");
|
|
320
|
+
if (confirmed) {
|
|
321
|
+
store.stored.add(storedKey(image.sha256, input.sessionId));
|
|
322
|
+
counts.stored_new += 1;
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
fail(store, input, image, outcome.reason ?? "upload_failed", counts);
|
|
326
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Decode one embedded image from its base64 text into a private temp file, one block at a time (BLI-23329).
|
|
3
|
+
*
|
|
4
|
+
* The upload client sends files by offset, so the image has to exist as bytes on disk. It is decoded in blocks of
|
|
5
|
+
* 48 Ki characters (a multiple of 4: each block is whole base64 quanta), straight into a sha256 and the file, so
|
|
6
|
+
* neither the base64 text of a large image nor its decoded bytes are ever held whole. The result is checked
|
|
7
|
+
* against what the census saw: a source that no longer decodes to the same sha256 and size (the transcript changed
|
|
8
|
+
* between the passes) is refused by name and the file removed. The file is mode 0600, in a directory this run made,
|
|
9
|
+
* and the caller removes the directory when the run is done.
|
|
10
|
+
*/
|
|
11
|
+
import { createHash } from "node:crypto";
|
|
12
|
+
import fs from "node:fs/promises";
|
|
13
|
+
import path from "node:path";
|
|
14
|
+
/** Characters per block. A multiple of 4. */
|
|
15
|
+
export const IMAGE_STORE_BLOCK_CHARS = 48 * 1024;
|
|
16
|
+
/** The base64 blocks of an image held in a string: slices of the run, each a whole number of quanta. */
|
|
17
|
+
export function* blocksOfText(text, image) {
|
|
18
|
+
for (let at = image.base64Start; at < image.base64End; at += IMAGE_STORE_BLOCK_CHARS) {
|
|
19
|
+
yield text.slice(at, Math.min(image.base64End, at + IMAGE_STORE_BLOCK_CHARS));
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
export async function stageDecodedImage(dir, image, blocks) {
|
|
23
|
+
const localPath = path.join(dir, `${image.sha256}.bin`);
|
|
24
|
+
const hash = createHash("sha256");
|
|
25
|
+
let byteSize = 0;
|
|
26
|
+
let handle;
|
|
27
|
+
try {
|
|
28
|
+
handle = await fs.open(localPath, "wx", 0o600);
|
|
29
|
+
for await (const block of blocks) {
|
|
30
|
+
const bytes = Buffer.from(block, "base64");
|
|
31
|
+
hash.update(bytes);
|
|
32
|
+
byteSize += bytes.byteLength;
|
|
33
|
+
await handle.write(bytes);
|
|
34
|
+
}
|
|
35
|
+
await handle.close();
|
|
36
|
+
handle = undefined;
|
|
37
|
+
}
|
|
38
|
+
catch (error) {
|
|
39
|
+
await handle?.close().catch(() => undefined);
|
|
40
|
+
await fs.rm(localPath, { force: true }).catch(() => undefined);
|
|
41
|
+
// A source that cannot be read to the end (the transcript changed or vanished) is a changed source; a disk that
|
|
42
|
+
// cannot be written is the other label. Neither says anything about the image itself.
|
|
43
|
+
const code = error?.code;
|
|
44
|
+
const writeFault = code === "ENOSPC" || code === "EACCES" || code === "EPERM" || code === "EEXIST" || code === "EROFS";
|
|
45
|
+
console.error("[image-store] staging an image failed", JSON.stringify({ reason: writeFault ? "image_stage_write_failed" : "image_source_changed", code: code ?? null }));
|
|
46
|
+
return { ok: false, reason: writeFault ? "image_stage_write_failed" : "image_source_changed" };
|
|
47
|
+
}
|
|
48
|
+
if (hash.digest("hex") !== image.sha256 || byteSize !== image.byteSize) {
|
|
49
|
+
await fs.rm(localPath, { force: true }).catch(() => undefined);
|
|
50
|
+
console.error("[image-store] decoded image is not the one the census saw", JSON.stringify({ reason: "image_source_changed", expected_bytes: image.byteSize, decoded_bytes: byteSize }));
|
|
51
|
+
return { ok: false, reason: "image_source_changed" };
|
|
52
|
+
}
|
|
53
|
+
return { ok: true, localPath, byteSize };
|
|
54
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which images Tower refused to store, and until when this machine leaves them alone (BLI-23329).
|
|
3
|
+
*
|
|
4
|
+
* Kept in its OWN file, apart from the described/failed record of the describe door
|
|
5
|
+
* (`image-descriptions.json`, shared with the BLI Memory daemon). Two reasons, both found in plan review:
|
|
6
|
+
* - the describe record's merge deletes a failure for every image that is described by now, whatever the failure
|
|
7
|
+
* was about (`mergeImageDescriptionStates`), so a refused store of an image the describer knows would be
|
|
8
|
+
* forgotten at the next write and retried on every tick;
|
|
9
|
+
* - the daemon rewrites that file from its own copy; a field it does not know can be dropped.
|
|
10
|
+
* This file is written by the sync tick alone (one writer under the sync lock), so neither can touch it.
|
|
11
|
+
*
|
|
12
|
+
* Holds keys, reason labels, attempt counts and times only. Never a path, a session id or a byte.
|
|
13
|
+
*/
|
|
14
|
+
import { createHash } from "node:crypto";
|
|
15
|
+
import path from "node:path";
|
|
16
|
+
import { imageRetryDelayMs, imageStateKey } from "./image-description-state.js";
|
|
17
|
+
import { isMissingFileError, readJsonFile, writeJsonFileAtomic } from "./local-state-files.js";
|
|
18
|
+
export const IMAGE_STORE_FAILURES_FILENAME = "image-store-failures.json";
|
|
19
|
+
export const IMAGE_STORE_FAILURES_SCHEMA = "cockpit-image-store-failures.v1";
|
|
20
|
+
/** Oldest records leave first past this many. */
|
|
21
|
+
export const IMAGE_STORE_FAILURES_MAX = 5_000;
|
|
22
|
+
const KEY_CHARS = 32;
|
|
23
|
+
const TAG = "[image-store]";
|
|
24
|
+
export function imageStoreFailuresPath(stateDir) {
|
|
25
|
+
return path.join(stateDir, IMAGE_STORE_FAILURES_FILENAME);
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* The key a refusal is remembered under. Most refusals are about the IMAGE (its bytes, its type): the image's key.
|
|
29
|
+
* A refusal that is about the SESSION (a founder forgot it) must not stop another session storing the same picture,
|
|
30
|
+
* so it is remembered under a key that mixes both in.
|
|
31
|
+
*/
|
|
32
|
+
export function storeFailureKey(sha256, sessionId, aboutSession) {
|
|
33
|
+
if (!aboutSession)
|
|
34
|
+
return imageStateKey(sha256);
|
|
35
|
+
return createHash("sha256").update(`${sessionId}|${sha256}`).digest("hex").slice(0, KEY_CHARS);
|
|
36
|
+
}
|
|
37
|
+
/** The failures on record. A missing file is empty; an unreadable or damaged one is empty and said out loud. */
|
|
38
|
+
export async function readStoreFailures(stateDir) {
|
|
39
|
+
let raw;
|
|
40
|
+
try {
|
|
41
|
+
raw = await readJsonFile(imageStoreFailuresPath(stateDir));
|
|
42
|
+
}
|
|
43
|
+
catch (error) {
|
|
44
|
+
if (!isMissingFileError(error)) {
|
|
45
|
+
console.error(TAG + " store failure record unreadable, starting from an empty one", JSON.stringify({ reason: "image_store_failures_unreadable" }));
|
|
46
|
+
}
|
|
47
|
+
return {};
|
|
48
|
+
}
|
|
49
|
+
const record = (raw && typeof raw === "object" ? raw : {});
|
|
50
|
+
const failed = {};
|
|
51
|
+
if (record.failed && typeof record.failed === "object" && !Array.isArray(record.failed)) {
|
|
52
|
+
for (const [key, value] of Object.entries(record.failed)) {
|
|
53
|
+
const entry = value;
|
|
54
|
+
if (key.length !== KEY_CHARS || !entry)
|
|
55
|
+
continue;
|
|
56
|
+
if (typeof entry.reason !== "string" || typeof entry.attempts !== "number" || typeof entry.retry_after !== "number")
|
|
57
|
+
continue;
|
|
58
|
+
failed[key] = { reason: entry.reason, attempts: entry.attempts, retry_after: entry.retry_after };
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return failed;
|
|
62
|
+
}
|
|
63
|
+
export async function writeStoreFailures(stateDir, failures) {
|
|
64
|
+
const entries = Object.entries(failures).sort((a, b) => a[1].retry_after - b[1].retry_after);
|
|
65
|
+
const kept = Object.fromEntries(entries.slice(Math.max(0, entries.length - IMAGE_STORE_FAILURES_MAX)));
|
|
66
|
+
await writeJsonFileAtomic(imageStoreFailuresPath(stateDir), { schema_version: IMAGE_STORE_FAILURES_SCHEMA, failed: kept });
|
|
67
|
+
}
|
|
68
|
+
/** Remember one more refusal of this image and push its next try out along the same ladder the describe door uses. */
|
|
69
|
+
export function rememberStoreFailure(failures, key, reason, permanent, now) {
|
|
70
|
+
const attempts = (failures[key]?.attempts ?? 0) + 1;
|
|
71
|
+
const record = { reason, attempts, retry_after: now + imageRetryDelayMs(attempts, permanent) };
|
|
72
|
+
failures[key] = record;
|
|
73
|
+
return record;
|
|
74
|
+
}
|
|
75
|
+
export function isBackingOff(failures, key, now) {
|
|
76
|
+
const record = failures[key];
|
|
77
|
+
return Boolean(record && record.retry_after > now);
|
|
78
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pass two of the streamed transcript when the machine keeps its images (`describe_and_keep`, BLI-23524).
|
|
3
|
+
*
|
|
4
|
+
* Keep mode never swaps an image and never slims, so the upload is the file itself. The drop modes' pass two
|
|
5
|
+
* (`slim-stream.ts`) rebuilds each line; this one copies each line, in order, to the same `LineSink`, so the file
|
|
6
|
+
* never sits in memory beside its decoded text and its re-encoded output (a 500 MB cap meant 2 to 3 copies).
|
|
7
|
+
*
|
|
8
|
+
* Byte identity with the whole-file read is by construction: the lines are cut at 0x0a (the same cut as splitting
|
|
9
|
+
* the decoded text at "\n" for valid UTF-8), joined again with "\n", and decoded with the same rules as
|
|
10
|
+
* `Buffer.toString("utf8")` (a BOM stays a character). A file that is not valid UTF-8 is not copied at all: the
|
|
11
|
+
* whole-file path uploads such a file's original bytes, and only a whole read can keep them, so the caller is told
|
|
12
|
+
* `invalid_utf8` and reads it whole. (The census pass has already refused such a file before any image was sent;
|
|
13
|
+
* this check is the same one at the copy, so a file rewritten between the passes cannot slip through.)
|
|
14
|
+
*/
|
|
15
|
+
import { TextDecoder } from "node:util";
|
|
16
|
+
import { TranscriptNotStreamable } from "../adapters/transcript-text-stream.js";
|
|
17
|
+
export async function copyLinesStream(lines, sink) {
|
|
18
|
+
const decoder = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
|
|
19
|
+
let count = 0;
|
|
20
|
+
for await (const { bytes } of lines) {
|
|
21
|
+
let line;
|
|
22
|
+
try {
|
|
23
|
+
line = decoder.decode(bytes);
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
throw new TranscriptNotStreamable("invalid_utf8");
|
|
27
|
+
}
|
|
28
|
+
if (count > 0)
|
|
29
|
+
await sink.write("\n");
|
|
30
|
+
count += 1;
|
|
31
|
+
await sink.write(line);
|
|
32
|
+
}
|
|
33
|
+
return { stats: null, replacedCount: 0, charsSaved: 0, changed: false, lines: count };
|
|
34
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bli-cockpit/cli",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.279",
|
|
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.86",
|
|
31
|
+
"@bli-cockpit/mcp": "0.1.170",
|
|
32
|
+
"@bli-cockpit/telemetry-core": "0.1.93",
|
|
33
33
|
"unpdf": "1.8.1"
|
|
34
34
|
},
|
|
35
35
|
"repository": {
|