@bli-cockpit/cli 0.2.292 → 0.2.294
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/claude-attribution-discovery.js +22 -0
- package/dist/adapters/claude-attribution-memoized-reads.js +3 -3
- package/dist/adapters/claude-attribution.js +5 -2
- package/dist/adapters/codex-attribution-memoized-reads.js +3 -3
- package/dist/adapters/codex-attribution.js +51 -12
- package/dist/adapters/codex-segment-discovery.js +75 -0
- package/dist/adapters/codex-session-revision.js +194 -0
- package/dist/adapters/codex-session-signals.js +2 -2
- package/dist/adapters/local-sources.js +1 -0
- package/dist/adapters/opencode-store.js +201 -0
- package/dist/adapters/pi-session-facts.js +28 -0
- package/dist/adapters/pi-session-reader.js +132 -0
- package/dist/adapters/raw-evidence-attribution-gaps.js +5 -0
- package/dist/adapters/raw-evidence-codex-reader.js +39 -7
- package/dist/adapters/raw-evidence-transcript-body.js +9 -1
- package/dist/adapters/raw-evidence-transcript-file.js +46 -4
- package/dist/adapters/raw-evidence-transcript-stream.js +36 -1
- package/dist/adapters/raw-evidence.js +13 -0
- package/dist/adapters/t3-projection-source.js +96 -0
- package/dist/collection-scope.js +2 -0
- package/dist/commands/backfill-checkpoint.js +3 -1
- package/dist/commands/backfill-history-scan.js +38 -0
- package/dist/commands/backfill-issues.js +13 -20
- package/dist/commands/backfill-output.js +6 -0
- package/dist/commands/backfill-report.js +3 -3
- package/dist/commands/backfill-scan.js +20 -40
- package/dist/commands/backfill-scope.js +2 -0
- package/dist/commands/backfill-upload.js +4 -2
- package/dist/commands/backfill.js +20 -3
- package/dist/commands/codex-revision-facts.js +64 -0
- package/dist/commands/collection-report.js +6 -0
- package/dist/commands/local-args-collector-status.js +2 -2
- package/dist/commands/local-args-collector-work.js +2 -2
- package/dist/commands/local-args-notes-targets.js +35 -0
- package/dist/commands/local-args-tower-notes.js +7 -18
- package/dist/commands/local-help-commands-tower.js +2 -2
- package/dist/commands/local-help-notes.js +2 -1
- package/dist/commands/local-help.js +3 -3
- package/dist/commands/notes-writes.js +24 -0
- package/dist/commands/notes.js +3 -2
- package/dist/commands/opencode-sync.js +163 -0
- package/dist/commands/public-root.js +1 -1
- package/dist/commands/session-sync-failures.js +4 -0
- package/dist/commands/session-sync-record.js +3 -1
- package/dist/commands/session-sync-scan.js +14 -6
- package/dist/commands/session-sync-shelf.js +2 -0
- package/dist/commands/session-sync-upload.js +3 -1
- package/dist/commands/session-sync.js +18 -1
- package/dist/commands/sessions.js +27 -8
- package/dist/commands/sources-check.js +54 -47
- package/dist/commands/sources-discovery.js +94 -14
- package/dist/commands/sources-reconciliation.js +7 -5
- package/dist/commands/sources-verdict.js +5 -2
- package/dist/cursors/raw-evidence-cursor.js +1 -0
- package/dist/image-description-session.js +4 -2
- package/dist/local-state-status.js +18 -1
- package/dist/native-session-roots.js +31 -0
- package/dist/opencode-inventory.js +203 -0
- package/dist/opencode-policy.js +29 -0
- package/dist/slim/slim-transcript.js +3 -2
- package/dist/source-opt-out.js +2 -1
- package/dist/spool/local-spool-parse.js +1 -1
- package/dist/state-dir-audit.js +2 -0
- package/dist/sync-health-class.js +2 -0
- package/dist/t3-native-homes.js +64 -0
- package/dist/upload-envelope-build.js +1 -0
- package/dist/upload-evidence-delivery-summary.js +10 -0
- package/dist/upload-session-reports-wire.js +15 -8
- package/package.json +4 -4
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
/** Read native OpenCode sessions without changing the live store or losing its WAL. */
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { access } from "node:fs/promises";
|
|
4
|
+
import { createHash } from "node:crypto";
|
|
5
|
+
export class OpenCodeReadError extends Error {
|
|
6
|
+
reason;
|
|
7
|
+
constructor(reason) { super(reason); this.reason = reason; }
|
|
8
|
+
}
|
|
9
|
+
/** Resolve only the documented data home; never look in the sibling auth file. */
|
|
10
|
+
export function openCodeDatabasePath(homeDir, xdgDataHome) {
|
|
11
|
+
return path.join(xdgDataHome || path.join(homeDir, ".local", "share"), "opencode", "opencode.db");
|
|
12
|
+
}
|
|
13
|
+
/** An absent store is distinct from an unreadable or incompatible one. */
|
|
14
|
+
export async function openOpenCodeStore(databasePath) {
|
|
15
|
+
try {
|
|
16
|
+
await access(databasePath);
|
|
17
|
+
}
|
|
18
|
+
catch (error) {
|
|
19
|
+
if (error.code === "ENOENT")
|
|
20
|
+
return null;
|
|
21
|
+
throw new OpenCodeReadError("opencode_store_unreadable");
|
|
22
|
+
}
|
|
23
|
+
let database;
|
|
24
|
+
try {
|
|
25
|
+
// Dynamic import keeps older CLI runtimes usable for their existing sources.
|
|
26
|
+
// They name this source's runtime gap rather than pretending it has no sessions.
|
|
27
|
+
const sqliteModule = "node:sqlite";
|
|
28
|
+
const { DatabaseSync } = await import(sqliteModule);
|
|
29
|
+
database = new DatabaseSync(databasePath, { readOnly: true, timeout: 1000 });
|
|
30
|
+
}
|
|
31
|
+
catch (error) {
|
|
32
|
+
const code = error.code;
|
|
33
|
+
throw new OpenCodeReadError(code === "ERR_UNKNOWN_BUILTIN_MODULE" || code === "ERR_MODULE_NOT_FOUND" ? "opencode_sqlite_runtime_unavailable" : "opencode_sqlite_open_failed");
|
|
34
|
+
}
|
|
35
|
+
return new OpenCodeStore(database);
|
|
36
|
+
}
|
|
37
|
+
export class OpenCodeStore {
|
|
38
|
+
database;
|
|
39
|
+
constructor(database) { this.database = database; }
|
|
40
|
+
close() { this.database.close(); }
|
|
41
|
+
/** Keyset inventory uses the same native identities for scheduled, historical and reconciliation reads. */
|
|
42
|
+
list(afterId = "", limit = 100) {
|
|
43
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > 500)
|
|
44
|
+
throw new OpenCodeReadError("opencode_page_limit_invalid");
|
|
45
|
+
try {
|
|
46
|
+
return this.database.prepare(`select id, directory, parent_id, version, time_created, time_updated
|
|
47
|
+
from session where id > ? order by id limit ?`).all(afterId, limit).map(sessionMetadata);
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
throw new OpenCodeReadError("opencode_inventory_read_failed");
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
/** Stream native records at one WAL snapshot so total history size never becomes a buffered-session limit. */
|
|
54
|
+
*records(sessionId) {
|
|
55
|
+
try {
|
|
56
|
+
this.database.exec("BEGIN");
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
throw new OpenCodeReadError("opencode_snapshot_begin_failed");
|
|
60
|
+
}
|
|
61
|
+
try {
|
|
62
|
+
const session = this.database.prepare("select * from session where id = ?").get(sessionId);
|
|
63
|
+
if (!session)
|
|
64
|
+
throw new OpenCodeReadError("opencode_session_disappeared");
|
|
65
|
+
yield { type: "opencode_session", info: session, timestamp: new Date(Number(session.time_created)).toISOString() };
|
|
66
|
+
let afterMessage = "";
|
|
67
|
+
let parts = 0;
|
|
68
|
+
while (true) {
|
|
69
|
+
const messages = this.database.prepare("select id from message where session_id = ? and id > ? order by id limit 100").all(sessionId, afterMessage);
|
|
70
|
+
if (!messages.length)
|
|
71
|
+
break;
|
|
72
|
+
for (const message of messages) {
|
|
73
|
+
const raw = this.database.prepare("select * from message where session_id = ? and id = ?").get(sessionId, String(message.id));
|
|
74
|
+
const info = { ...parseNativeObject(raw.data), id: message.id, sessionID: sessionId };
|
|
75
|
+
yield { type: "opencode_message", info, storage: nativeStorageMetadata(raw), timestamp: nativeTimestamp(info.time) };
|
|
76
|
+
let afterPart = "";
|
|
77
|
+
while (true) {
|
|
78
|
+
const batch = this.database.prepare("select id from part where session_id = ? and message_id = ? and id > ? order by id limit 100").all(sessionId, String(message.id), afterPart);
|
|
79
|
+
if (!batch.length)
|
|
80
|
+
break;
|
|
81
|
+
for (const part of batch) {
|
|
82
|
+
const rawPart = this.database.prepare("select * from part where session_id = ? and id = ?").get(sessionId, String(part.id));
|
|
83
|
+
yield { type: "opencode_part", storage: nativeStorageMetadata(rawPart), role: info.role, timestamp: nativeTimestamp(parseNativeObject(rawPart.data).time) ?? nativeTimestamp(info.time), part: { ...parseNativeObject(rawPart.data), id: part.id, messageID: message.id, sessionID: sessionId } };
|
|
84
|
+
afterPart = String(part.id);
|
|
85
|
+
parts += 1;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
afterMessage = String(message.id);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
if (Number(this.database.prepare("select count(*) as count from part where session_id = ?").get(sessionId)?.count) !== parts)
|
|
92
|
+
throw new OpenCodeReadError("opencode_orphan_parts");
|
|
93
|
+
}
|
|
94
|
+
catch (error) {
|
|
95
|
+
if (error instanceof OpenCodeReadError)
|
|
96
|
+
throw error;
|
|
97
|
+
throw new OpenCodeReadError("opencode_snapshot_read_failed");
|
|
98
|
+
}
|
|
99
|
+
finally {
|
|
100
|
+
this.database.exec("ROLLBACK");
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
/** A read transaction sees the session, messages and parts at one WAL snapshot. No source files are copied. */
|
|
104
|
+
snapshot(sessionId, maxBytes = Number.MAX_SAFE_INTEGER) {
|
|
105
|
+
if (!Number.isSafeInteger(maxBytes) || maxBytes < 1)
|
|
106
|
+
throw new OpenCodeReadError("opencode_byte_limit_invalid");
|
|
107
|
+
try {
|
|
108
|
+
this.database.exec("BEGIN");
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
throw new OpenCodeReadError("opencode_snapshot_begin_failed");
|
|
112
|
+
}
|
|
113
|
+
try {
|
|
114
|
+
const session = this.database.prepare("select * from session where id = ?").get(sessionId);
|
|
115
|
+
if (!session)
|
|
116
|
+
throw new OpenCodeReadError("opencode_session_disappeared");
|
|
117
|
+
const lines = [JSON.stringify({ type: "opencode_session", info: session }) + "\n"];
|
|
118
|
+
let bytes = Buffer.byteLength(lines[0]);
|
|
119
|
+
let afterId = "";
|
|
120
|
+
let messages = 0;
|
|
121
|
+
let parts = 0;
|
|
122
|
+
while (true) {
|
|
123
|
+
// Bound the bytes selected BEFORE decoding content. Oversized messages remain pending, never truncated.
|
|
124
|
+
const rows = this.database.prepare(`select id, length(cast(data as blob)) as bytes from message
|
|
125
|
+
where session_id = ? and id > ? order by id limit 100`).all(sessionId, afterId);
|
|
126
|
+
if (!rows.length)
|
|
127
|
+
break;
|
|
128
|
+
for (const row of rows) {
|
|
129
|
+
if (bytes + Number(row.bytes) > maxBytes)
|
|
130
|
+
throw new OpenCodeReadError("opencode_session_byte_cap");
|
|
131
|
+
const message = this.database.prepare("select * from message where session_id = ? and id = ?").get(sessionId, String(row.id));
|
|
132
|
+
const info = { ...parseNativeObject(message.data), id: row.id, sessionID: sessionId };
|
|
133
|
+
const nativeParts = [];
|
|
134
|
+
const partStorage = {};
|
|
135
|
+
const priorBytes = bytes;
|
|
136
|
+
let afterPart = "";
|
|
137
|
+
while (true) {
|
|
138
|
+
const batch = this.database.prepare(`select id, length(cast(data as blob)) as bytes from part
|
|
139
|
+
where session_id = ? and message_id = ? and id > ? order by id limit 100`).all(sessionId, String(row.id), afterPart);
|
|
140
|
+
if (!batch.length)
|
|
141
|
+
break;
|
|
142
|
+
for (const part of batch) {
|
|
143
|
+
bytes += Number(part.bytes);
|
|
144
|
+
if (bytes + Number(row.bytes) > maxBytes)
|
|
145
|
+
throw new OpenCodeReadError("opencode_session_byte_cap");
|
|
146
|
+
const native = this.database.prepare("select * from part where session_id = ? and id = ?").get(sessionId, String(part.id));
|
|
147
|
+
partStorage[String(part.id)] = nativeStorageMetadata(native);
|
|
148
|
+
nativeParts.push({ ...parseNativeObject(native.data), id: part.id, sessionID: sessionId, messageID: row.id });
|
|
149
|
+
afterPart = String(part.id);
|
|
150
|
+
parts += 1;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
const line = JSON.stringify({ type: "opencode_message", info, parts: nativeParts, storage: { message: nativeStorageMetadata(message), parts: partStorage } }) + "\n";
|
|
154
|
+
// Account for wrapper/identity bytes too, without counting native parts twice.
|
|
155
|
+
bytes = priorBytes + Buffer.byteLength(line);
|
|
156
|
+
if (bytes > maxBytes)
|
|
157
|
+
throw new OpenCodeReadError("opencode_session_byte_cap");
|
|
158
|
+
lines.push(line);
|
|
159
|
+
messages += 1;
|
|
160
|
+
afterId = String(row.id);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
const storedParts = this.database.prepare("select count(*) as count from part where session_id = ?").get(sessionId);
|
|
164
|
+
if (Number(storedParts?.count) !== parts)
|
|
165
|
+
throw new OpenCodeReadError("opencode_orphan_parts");
|
|
166
|
+
const jsonl = lines.join("");
|
|
167
|
+
return { session: sessionMetadata(session), jsonl, revision: createHash("sha256").update(jsonl).digest("hex"), messages, parts };
|
|
168
|
+
}
|
|
169
|
+
catch (error) {
|
|
170
|
+
if (error instanceof OpenCodeReadError)
|
|
171
|
+
throw error;
|
|
172
|
+
throw new OpenCodeReadError("opencode_snapshot_read_failed");
|
|
173
|
+
}
|
|
174
|
+
finally {
|
|
175
|
+
this.database.exec("ROLLBACK");
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
function sessionMetadata(row) {
|
|
180
|
+
if (typeof row.id !== "string" || typeof row.directory !== "string" || typeof row.version !== "string" || !Number.isFinite(row.time_updated)) {
|
|
181
|
+
throw new OpenCodeReadError("opencode_session_schema_unknown");
|
|
182
|
+
}
|
|
183
|
+
return { id: row.id, directory: row.directory, version: row.version, parent_id: typeof row.parent_id === "string" ? row.parent_id : null, updated_at_ms: Number(row.time_updated), created_at_ms: Number(row.time_created ?? row.time_updated) };
|
|
184
|
+
}
|
|
185
|
+
function parseNativeObject(raw) {
|
|
186
|
+
if (typeof raw !== "string")
|
|
187
|
+
throw new OpenCodeReadError("opencode_record_invalid");
|
|
188
|
+
const parsed = JSON.parse(raw);
|
|
189
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
|
|
190
|
+
throw new OpenCodeReadError("opencode_record_invalid");
|
|
191
|
+
return parsed;
|
|
192
|
+
}
|
|
193
|
+
function nativeTimestamp(value) {
|
|
194
|
+
const time = value && typeof value === "object" ? value : {};
|
|
195
|
+
const ms = time.created ?? time.start ?? time.end ?? time.completed;
|
|
196
|
+
return typeof ms === "number" && Number.isFinite(ms) ? new Date(ms).toISOString() : null;
|
|
197
|
+
}
|
|
198
|
+
/** Keep physical native row metadata without duplicating the JSON content body. */
|
|
199
|
+
function nativeStorageMetadata(row) {
|
|
200
|
+
return Object.fromEntries(Object.entries(row).filter(([key]) => key !== "data"));
|
|
201
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/** Unknown fields stay in the reader's raw evidence; these facts never replace it. */
|
|
2
|
+
export function piRecordFacts(value) {
|
|
3
|
+
const entry = object(value);
|
|
4
|
+
const message = object(entry?.message);
|
|
5
|
+
const content = Array.isArray(message?.content) ? message.content : [];
|
|
6
|
+
return {
|
|
7
|
+
type: text(entry?.type), id: text(entry?.id), parentId: text(entry?.parentId),
|
|
8
|
+
timestamp: typeof entry?.timestamp === 'string' || typeof entry?.timestamp === 'number' ? entry.timestamp : null,
|
|
9
|
+
role: text(message?.role), provider: text(message?.provider) ?? text(entry?.provider),
|
|
10
|
+
model: text(message?.model) ?? text(entry?.modelId), usage: object(message?.usage) ?? object(entry?.usage),
|
|
11
|
+
toolCallIds: content.flatMap(block => {
|
|
12
|
+
const call = object(block);
|
|
13
|
+
return call?.type === 'toolCall' && typeof call.id === 'string' ? [call.id] : [];
|
|
14
|
+
}),
|
|
15
|
+
toolResultId: text(message?.toolCallId),
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
/** Native headers retain the explicit parent path, including Windows separators. */
|
|
19
|
+
export function piSessionHeader(value) {
|
|
20
|
+
const entry = object(value);
|
|
21
|
+
if (entry?.type !== 'session' || typeof entry.id !== 'string' || typeof entry.version !== 'number' || typeof entry.cwd !== 'string')
|
|
22
|
+
return null;
|
|
23
|
+
return { id: entry.id, version: entry.version, cwd: entry.cwd, parentSession: text(entry.parentSession) };
|
|
24
|
+
}
|
|
25
|
+
function object(value) {
|
|
26
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value) ? value : null;
|
|
27
|
+
}
|
|
28
|
+
function text(value) { return typeof value === 'string' ? value : null; }
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/** Read-only Pi JSONL access. Callers must redact raw bytes before staging or uploading. */
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { open, readdir, stat } from 'node:fs/promises';
|
|
4
|
+
import { createHash } from 'node:crypto';
|
|
5
|
+
export class PiReadError extends Error {
|
|
6
|
+
reason;
|
|
7
|
+
constructor(reason) { super(reason); this.reason = reason; }
|
|
8
|
+
}
|
|
9
|
+
/** Explicit CLI/session roots supplement the default home so retained history remains discoverable. */
|
|
10
|
+
export function piSessionRoots(homeDir, options = {}) {
|
|
11
|
+
const paths = options.platform === 'win32' ? path.win32 : path;
|
|
12
|
+
const expand = (root) => root === '~' ? homeDir : root.startsWith('~/') || root.startsWith('~\\') ? paths.join(homeDir, root.slice(2)) : root;
|
|
13
|
+
return [...new Set([paths.join(homeDir, '.pi', 'agent', 'sessions'),
|
|
14
|
+
...(options.agentDir ? [paths.join(expand(options.agentDir), 'sessions')] : []),
|
|
15
|
+
...(options.sessionDir ? [expand(options.sessionDir)] : []), ...(options.additionalRoots ?? []).map(expand)].map(root => paths.resolve(root)))];
|
|
16
|
+
}
|
|
17
|
+
/** Bounded keyset pages enumerate every retained JSONL; errors never masquerade as an empty source. */
|
|
18
|
+
export async function listPiSessions(root, after = '', limit = 100) {
|
|
19
|
+
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 1000)
|
|
20
|
+
throw new PiReadError('pi_page_limit_invalid');
|
|
21
|
+
const files = [];
|
|
22
|
+
try {
|
|
23
|
+
const rootInfo = await stat(root);
|
|
24
|
+
if (!rootInfo.isDirectory())
|
|
25
|
+
throw new PiReadError('pi_root_not_directory');
|
|
26
|
+
}
|
|
27
|
+
catch (error) {
|
|
28
|
+
if (error.code === 'ENOENT')
|
|
29
|
+
return { state: 'missing', files, next: null, requested: limit, returned: 0, unique: 0 };
|
|
30
|
+
throw error instanceof PiReadError ? error : new PiReadError('pi_inventory_unreadable');
|
|
31
|
+
}
|
|
32
|
+
try {
|
|
33
|
+
for await (const key of walkPiFiles(root)) {
|
|
34
|
+
if (key <= after)
|
|
35
|
+
continue;
|
|
36
|
+
if (files.length === limit)
|
|
37
|
+
return { state: 'more', files, next: files.at(-1).key, requested: limit, returned: files.length, unique: files.length };
|
|
38
|
+
files.push({ key, filePath: path.join(root, ...key.split('/')) });
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
catch (error) {
|
|
42
|
+
throw error instanceof PiReadError ? error : new PiReadError('pi_inventory_unreadable');
|
|
43
|
+
}
|
|
44
|
+
return { state: 'complete', files, next: null, requested: limit, returned: files.length, unique: files.length };
|
|
45
|
+
}
|
|
46
|
+
async function* walkPiFiles(root, prefix = '') {
|
|
47
|
+
const entries = await readdir(path.join(root, prefix), { withFileTypes: true });
|
|
48
|
+
const key = (entry) => entry.name + (entry.isDirectory() ? '/' : '');
|
|
49
|
+
entries.sort((a, b) => key(a) < key(b) ? -1 : key(a) > key(b) ? 1 : 0);
|
|
50
|
+
for (const entry of entries) {
|
|
51
|
+
const relative = prefix + entry.name;
|
|
52
|
+
if (entry.isDirectory())
|
|
53
|
+
yield* walkPiFiles(root, relative + '/');
|
|
54
|
+
else if (entry.isFile() && entry.name.endsWith('.jsonl'))
|
|
55
|
+
yield relative;
|
|
56
|
+
// Symlinks are not followed: otherwise aliases/cycles could duplicate or escape this root.
|
|
57
|
+
else if (entry.isSymbolicLink())
|
|
58
|
+
throw new PiReadError('pi_inventory_symlink');
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
/** Stream a bounded observed file revision, rehashing its prefix to detect edits, rotation and truncation. */
|
|
62
|
+
export async function* readPiSession(filePath, previousRevision) {
|
|
63
|
+
let file;
|
|
64
|
+
try {
|
|
65
|
+
file = await open(filePath, 'r');
|
|
66
|
+
}
|
|
67
|
+
catch (error) {
|
|
68
|
+
throw new PiReadError(error.code === 'ENOENT' ? 'pi_session_missing' : 'pi_session_unreadable');
|
|
69
|
+
}
|
|
70
|
+
try {
|
|
71
|
+
const before = await file.stat();
|
|
72
|
+
if (!before.isFile())
|
|
73
|
+
throw new PiReadError('pi_session_not_file');
|
|
74
|
+
const hash = createHash('sha256');
|
|
75
|
+
let position = 0, offset = 0, records = 0, invalidRecords = 0;
|
|
76
|
+
let fragments = [];
|
|
77
|
+
let fragmentBytes = 0;
|
|
78
|
+
// Bound each read to the initial size. Growing files remain pending for the next observation.
|
|
79
|
+
while (position < before.size) {
|
|
80
|
+
const buffer = Buffer.allocUnsafe(Math.min(64 * 1024, before.size - position));
|
|
81
|
+
const { bytesRead } = await file.read(buffer, 0, buffer.length, position);
|
|
82
|
+
if (!bytesRead)
|
|
83
|
+
break;
|
|
84
|
+
const chunk = buffer.subarray(0, bytesRead);
|
|
85
|
+
hash.update(chunk);
|
|
86
|
+
position += bytesRead;
|
|
87
|
+
let start = 0;
|
|
88
|
+
for (let index = 0; index < chunk.length; index++) {
|
|
89
|
+
if (chunk[index] !== 10)
|
|
90
|
+
continue;
|
|
91
|
+
const raw = Buffer.concat([...fragments, chunk.subarray(start, index + 1)], fragmentBytes + index + 1 - start);
|
|
92
|
+
let value = null, valid = true;
|
|
93
|
+
try {
|
|
94
|
+
value = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(raw));
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
valid = false;
|
|
98
|
+
invalidRecords++;
|
|
99
|
+
}
|
|
100
|
+
records++;
|
|
101
|
+
yield { kind: 'record', offset, raw, value, valid };
|
|
102
|
+
offset += raw.length;
|
|
103
|
+
fragments = [];
|
|
104
|
+
fragmentBytes = 0;
|
|
105
|
+
start = index + 1;
|
|
106
|
+
}
|
|
107
|
+
if (start < chunk.length) {
|
|
108
|
+
fragments.push(chunk.subarray(start));
|
|
109
|
+
fragmentBytes += chunk.length - start;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
if (fragmentBytes)
|
|
113
|
+
yield { kind: 'partial', offset, raw: Buffer.concat(fragments, fragmentBytes) };
|
|
114
|
+
const after = await file.stat();
|
|
115
|
+
let current;
|
|
116
|
+
try {
|
|
117
|
+
current = await stat(filePath);
|
|
118
|
+
}
|
|
119
|
+
catch {
|
|
120
|
+
current = null;
|
|
121
|
+
}
|
|
122
|
+
const stable = position === before.size && before.size === after.size && before.mtimeMs === after.mtimeMs && before.ctimeMs === after.ctimeMs && current?.ino === before.ino && current?.dev === before.dev && current?.size === before.size && current?.mtimeMs === before.mtimeMs && current?.ctimeMs === before.ctimeMs;
|
|
123
|
+
const revision = hash.digest('hex');
|
|
124
|
+
yield { kind: 'revision', revision, bytes: position, records, invalidRecords, partialBytes: fragmentBytes, stable, changed: previousRevision === undefined ? null : previousRevision !== revision };
|
|
125
|
+
}
|
|
126
|
+
catch (error) {
|
|
127
|
+
throw error instanceof PiReadError ? error : new PiReadError('pi_session_read_failed');
|
|
128
|
+
}
|
|
129
|
+
finally {
|
|
130
|
+
await file.close();
|
|
131
|
+
}
|
|
132
|
+
}
|
|
@@ -7,6 +7,11 @@ import { recordScanned, recordSkipCount, recordTruncationCount, } from "./raw-ev
|
|
|
7
7
|
export function recordCodexAttributionCompleteness(ledger, scan, selectedPaths) {
|
|
8
8
|
recordScanned(ledger, "codex_attribution", scan.scanned_file_count);
|
|
9
9
|
ledger.caps.push(...codexAttributionCaps(scan));
|
|
10
|
+
if (scan.projection_failure_reason)
|
|
11
|
+
recordSkipCount(ledger, "codex_attribution", scan.projection_failure_reason, 1);
|
|
12
|
+
for (const result of scan.results)
|
|
13
|
+
if (result.t3_projection_failure_reason)
|
|
14
|
+
recordSkipCount(ledger, "codex_attribution", result.t3_projection_failure_reason, 1);
|
|
10
15
|
recordSkipCount(ledger, "codex_attribution", "session_limit_overflow", Math.max(0, scan.discovered_file_count - scan.scanned_file_count));
|
|
11
16
|
recordSkipCount(ledger, "codex_attribution", "directory_read_failed", scan.directory_read_failed_count);
|
|
12
17
|
recordSkipCount(ledger, "codex_attribution", "file_stat_failed", scan.stat_failed_count);
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
* A session is a transcript first and its attached images second, never the
|
|
11
11
|
* reverse — images are only collected once the transcript itself was accepted.
|
|
12
12
|
*/
|
|
13
|
+
import { openCodexSessionRevision } from "./codex-session-revision.js";
|
|
13
14
|
import fs from "node:fs/promises";
|
|
14
15
|
import os from "node:os";
|
|
15
16
|
import path from "node:path";
|
|
@@ -20,6 +21,7 @@ import { isSecretLikePath, safeKeySegment, shortHash, } from "./raw-evidence-key
|
|
|
20
21
|
import { recordScanned } from "./raw-evidence-completeness.js";
|
|
21
22
|
import { collectOneEvidenceFile } from "./raw-evidence-transcript-file.js";
|
|
22
23
|
import { collectAgentImagesFromTranscript } from "./raw-evidence-image-reader.js";
|
|
24
|
+
import { TranscriptNotStreamable, TranscriptReadError } from "./transcript-text-stream.js";
|
|
23
25
|
export async function collectCodexJsonlFiles(collection, options) {
|
|
24
26
|
for (const candidate of await chooseCodexCandidates(collection, options)) {
|
|
25
27
|
recordScanned(collection, "codex_jsonl");
|
|
@@ -37,6 +39,7 @@ async function chooseCodexCandidates(collection, options) {
|
|
|
37
39
|
? options.codexSessionFiles.map((file) => ({
|
|
38
40
|
filePath: file.local_path,
|
|
39
41
|
codexSessionId: file.codex_session_id,
|
|
42
|
+
segmentPaths: file.segment_paths, revisionHash: file.revision_hash, revisionBytes: file.revision_bytes, t3Projection: file.t3_projection,
|
|
40
43
|
}))
|
|
41
44
|
: (await walkRecentCodexJsonlFiles(collection, options)).map((filePath) => ({ filePath, codexSessionId: null }));
|
|
42
45
|
collection.caps.push({
|
|
@@ -59,18 +62,47 @@ async function collectOneCodexSession(collection, candidate) {
|
|
|
59
62
|
// Guard before read/toString: Codex files can exceed the buffered commit
|
|
60
63
|
// ceiling just like Claude mains.
|
|
61
64
|
maxFileBytes: RAW_EVIDENCE_UPLOAD_MAX_FILE_BYTES,
|
|
65
|
+
...(candidate.segmentPaths ? { sourceProvider: async () => {
|
|
66
|
+
const revisionOptions = { t3Projection: candidate.t3Projection, maxFileBytes: RAW_EVIDENCE_UPLOAD_MAX_FILE_BYTES, expectedHash: candidate.revisionHash };
|
|
67
|
+
const openSource = () => openCodexSessionRevision(candidate.segmentPaths, revisionOptions);
|
|
68
|
+
let byteLength = candidate.revisionBytes;
|
|
69
|
+
// Direct native callers may omit attribution's observed revision. Pin a
|
|
70
|
+
// complete observation before admission, then reopen only that revision;
|
|
71
|
+
// estimates from native file sizes omit provenance/projection overhead.
|
|
72
|
+
if (byteLength === undefined || !revisionOptions.expectedHash) {
|
|
73
|
+
let source;
|
|
74
|
+
try {
|
|
75
|
+
source = await openSource();
|
|
76
|
+
byteLength = source.length;
|
|
77
|
+
revisionOptions.expectedHash = source.sha256;
|
|
78
|
+
}
|
|
79
|
+
catch (error) {
|
|
80
|
+
if (error instanceof TranscriptNotStreamable)
|
|
81
|
+
throw new TranscriptReadError(error.reason, error);
|
|
82
|
+
throw error;
|
|
83
|
+
}
|
|
84
|
+
finally {
|
|
85
|
+
await source?.close();
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return { byteLength, source: { bytes: null, stream: {
|
|
89
|
+
filePath: candidate.filePath, inputBytes: byteLength, maxFileBytes: RAW_EVIDENCE_UPLOAD_MAX_FILE_BYTES,
|
|
90
|
+
openSource,
|
|
91
|
+
} } };
|
|
92
|
+
} } : {}),
|
|
62
93
|
redactedSummary: "Raw Codex JSONL transcript with prompts, responses, tool arguments, and tool outputs preserved locally.",
|
|
63
94
|
contentAddress: (hash16) => `codex/${safeKeySegment(codexSessionId)}/${hash16}.jsonl`,
|
|
64
95
|
});
|
|
65
96
|
if (!transcriptAccepted)
|
|
66
97
|
return;
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
98
|
+
for (const filePath of candidate.segmentPaths ?? [candidate.filePath])
|
|
99
|
+
await collectAgentImagesFromTranscript(collection, {
|
|
100
|
+
filePath,
|
|
101
|
+
source: "codex",
|
|
102
|
+
sessionId: codexSessionId,
|
|
103
|
+
kind: "codex_image_attachment",
|
|
104
|
+
contentAddress: (hash16, extension) => `codex/${safeKeySegment(codexSessionId)}/images/${hash16}.${extension}`,
|
|
105
|
+
});
|
|
74
106
|
}
|
|
75
107
|
/** The unattributed fallback: every recent Codex transcript on this machine. */
|
|
76
108
|
function walkRecentCodexJsonlFiles(collection, options) {
|
|
@@ -10,6 +10,8 @@
|
|
|
10
10
|
* The order stays image replacement, slim, redact.
|
|
11
11
|
*/
|
|
12
12
|
import { isUtf8 } from "node:buffer";
|
|
13
|
+
import { openPinnedTranscript } from "./pinned-transcript.js";
|
|
14
|
+
import { TranscriptReadError } from "./transcript-text-stream.js";
|
|
13
15
|
import { mightHoldEmbeddedImages, modeDropsImages, modeStoresImages } from "@bli-cockpit/telemetry-core";
|
|
14
16
|
import { describeError } from "../health-detail.js";
|
|
15
17
|
import { makeCaptureProvenance } from "./common.js";
|
|
@@ -17,7 +19,7 @@ import { roomToStreamToStaging } from "./raw-evidence-budget-admission.js";
|
|
|
17
19
|
import { readTranscriptWhole, streamTranscriptBody, volatilityOf } from "./raw-evidence-transcript-stream.js";
|
|
18
20
|
/** Which agent's transcript rules apply to this evidence kind; null for anything the image step does not know. */
|
|
19
21
|
export function agentOfKind(kind) {
|
|
20
|
-
return kind.startsWith("codex") ? "codex" : kind.startsWith("claude") ? "claude_code" : null;
|
|
22
|
+
return kind === "opencode_jsonl" ? "opencode" : kind.startsWith("codex") ? "codex" : kind.startsWith("claude") ? "claude_code" : null;
|
|
21
23
|
}
|
|
22
24
|
/** Decode once and release the bytes, unless the decoding was lossy and the bytes are the only exact copy. */
|
|
23
25
|
export function holdDecodedText(source) {
|
|
@@ -54,6 +56,10 @@ function releaseBackingStore(bytes) {
|
|
|
54
56
|
* the caller turns into the `file_read_failed` and `file_too_large` skips.
|
|
55
57
|
*/
|
|
56
58
|
export async function prepareTranscriptBody(collection, file) {
|
|
59
|
+
if (file.kind === "opencode_jsonl" && file.source.stream) {
|
|
60
|
+
const source = file.source.stream;
|
|
61
|
+
source.openSource ??= () => openPinnedTranscript(source.filePath, { maxFileBytes: source.maxFileBytes });
|
|
62
|
+
}
|
|
57
63
|
const session = collection.budget.images;
|
|
58
64
|
const agent = agentOfKind(file.kind);
|
|
59
65
|
// BLI-23329: `store_and_drop` talks to the upload doors for the images of this transcript, as this sync.
|
|
@@ -67,6 +73,8 @@ export async function prepareTranscriptBody(collection, file) {
|
|
|
67
73
|
if (refusal) {
|
|
68
74
|
console.error("[raw-evidence] streamed output is not written ahead of the staging gate", JSON.stringify({ reason: "stream_to_staging_declined", gate: refusal, kind: file.kind, input_bytes: file.source.stream.inputBytes ?? null }));
|
|
69
75
|
}
|
|
76
|
+
if (refusal && file.source.stream.openSource)
|
|
77
|
+
throw new TranscriptReadError("composite_staging_declined", new Error(refusal));
|
|
70
78
|
const streamed = await streamTranscriptBody(session, agent, { ...file.source.stream, sessionId: file.sessionId, kind: file.kind, stageDir: refusal ? undefined : collection.filesDir, ...(provenance ? { provenance } : {}) });
|
|
71
79
|
if (streamed) {
|
|
72
80
|
file.source.readBytes = streamed.readBytes;
|
|
@@ -56,11 +56,27 @@ export async function collectOneEvidenceFile(collection, options) {
|
|
|
56
56
|
}
|
|
57
57
|
// BLI-3936: stat BEFORE the read. The same stamp, taken again after the work, proves the file
|
|
58
58
|
// did not move underneath it; only then is the result remembered.
|
|
59
|
-
const stamp = await statSourceForMemo(options.filePath);
|
|
59
|
+
const stamp = options.sourceProvider ? null : await statSourceForMemo(options.filePath);
|
|
60
60
|
const recipe = prepareRecipe({ imageMode: collection.budget.images?.mode, kind: options.kind });
|
|
61
61
|
if (await reuseUnchangedTranscript(collection, options, names, stamp, recipe))
|
|
62
62
|
return true;
|
|
63
|
-
|
|
63
|
+
let read;
|
|
64
|
+
try {
|
|
65
|
+
read = options.sourceProvider
|
|
66
|
+
? { status: "ok", ...await options.sourceProvider() }
|
|
67
|
+
: await readEvidenceFileWithinCap(options.filePath, options.maxFileBytes, streamMinBytesFor(collection, options.kind));
|
|
68
|
+
}
|
|
69
|
+
catch (error) {
|
|
70
|
+
if (error instanceof TranscriptTooLargeError) {
|
|
71
|
+
markCapApplied(collection, options.kind, "max_file_bytes");
|
|
72
|
+
return skip("file_too_large");
|
|
73
|
+
}
|
|
74
|
+
const reason = error instanceof TranscriptReadError ? `file_read_failed:${error.stage}` : "file_read_failed";
|
|
75
|
+
console.error("[raw-evidence] revision source unavailable", JSON.stringify({ reason, ...describeError(error) }));
|
|
76
|
+
return skip(reason);
|
|
77
|
+
}
|
|
78
|
+
if (read.status === "ok" && options.maxFileBytes && read.byteLength > options.maxFileBytes)
|
|
79
|
+
read = { status: "too_large" };
|
|
64
80
|
if (read.status === "read_failed")
|
|
65
81
|
return skip("file_read_failed");
|
|
66
82
|
if (read.status === "too_large") {
|
|
@@ -80,8 +96,15 @@ export async function collectOneEvidenceFile(collection, options) {
|
|
|
80
96
|
}
|
|
81
97
|
catch (error) {
|
|
82
98
|
// BLI-23317: a streamed or fallback read that fails ends exactly as a whole-file read that failed.
|
|
83
|
-
if (error instanceof TranscriptReadError)
|
|
84
|
-
|
|
99
|
+
if (error instanceof TranscriptReadError) {
|
|
100
|
+
const deferred = error.stage === "composite_staging_declined" && error.underlying instanceof Error ? error.underlying.message : null;
|
|
101
|
+
if (deferred && /^(deferred_(byte|object)_budget|staging_deferred:[a-z_]+)$/.test(deferred)) {
|
|
102
|
+
markBudgetCapApplied(collection, deferred);
|
|
103
|
+
return skip(deferred);
|
|
104
|
+
}
|
|
105
|
+
const detail = options.sourceProvider ? `:${error.stage}` : "";
|
|
106
|
+
return skip(`file_read_failed${detail}`);
|
|
107
|
+
}
|
|
85
108
|
if (error instanceof TranscriptTooLargeError) {
|
|
86
109
|
markCapApplied(collection, options.kind, "max_file_bytes");
|
|
87
110
|
return skip("file_too_large");
|
|
@@ -149,6 +172,23 @@ async function prepareTranscriptForUpload(collection, options, names, source) {
|
|
|
149
172
|
await discardStagedOutput(body.held.staged, "mask_threw");
|
|
150
173
|
throw error;
|
|
151
174
|
}
|
|
175
|
+
if (options.priorRedaction?.status === "sanitized" && (sanitized.status === "clean" || sanitized.completenessLabel === "secret_content_masked")) {
|
|
176
|
+
const prior = options.priorRedaction;
|
|
177
|
+
const final = sanitized.redaction;
|
|
178
|
+
const rules = new Map(prior.rule_counts.map(rule => [rule.rule_id, { ...rule }]));
|
|
179
|
+
for (const rule of final.rule_counts) {
|
|
180
|
+
const combined = rules.get(rule.rule_id) ?? { rule_id: rule.rule_id, match_count: 0, redacted_char_count: 0 };
|
|
181
|
+
combined.match_count += rule.match_count;
|
|
182
|
+
combined.redacted_char_count += rule.redacted_char_count;
|
|
183
|
+
rules.set(rule.rule_id, combined);
|
|
184
|
+
}
|
|
185
|
+
sanitized.redaction = { ...prior, ...final, status: "sanitized", mode: "deterministic_text_replacement",
|
|
186
|
+
applied_by: [...new Set([...prior.applied_by, ...final.applied_by])], rule_counts: [...rules.values()],
|
|
187
|
+
secret_like_match_count: prior.secret_like_match_count + final.secret_like_match_count,
|
|
188
|
+
redacted_fields: [...new Set([...prior.redacted_fields, ...final.redacted_fields])] };
|
|
189
|
+
collection.redacted.splice(redactedBefore);
|
|
190
|
+
collection.redacted.push({ kind: options.kind, label: names.evidenceLabel, redaction: sanitized.redaction, completenessLabel: "secret_content_masked" });
|
|
191
|
+
}
|
|
152
192
|
// Only a clean file IS the spool. Anything the mask rewrote has new bytes (a new staged file, or the crash stub's), so the spool is of no use.
|
|
153
193
|
if (body.held.staged && !("staged" in sanitized && sanitized.staged === body.held.staged))
|
|
154
194
|
await discardStagedOutput(body.held.staged, "redaction_rewrote_the_bytes");
|
|
@@ -271,6 +311,8 @@ function logDeliveryBackoffHold(kind, sourceKey) {
|
|
|
271
311
|
}
|
|
272
312
|
/** The size from which this pass streams a transcript instead of reading it whole; null when its image session cannot (BLI-23317). */
|
|
273
313
|
function streamMinBytesFor(collection, kind) {
|
|
314
|
+
if (kind === "opencode_jsonl")
|
|
315
|
+
return 1024 * 1024;
|
|
274
316
|
const session = collection.budget.images;
|
|
275
317
|
if (!session?.prepareTranscriptFile || !agentOfKind(kind))
|
|
276
318
|
return null;
|
|
@@ -12,6 +12,8 @@
|
|
|
12
12
|
* read gives (`file_read_failed`, `file_too_large`), never a shorter file.
|
|
13
13
|
*/
|
|
14
14
|
import fs from "node:fs/promises";
|
|
15
|
+
import { copyLinesStream } from "../slim/keep-line-copy.js";
|
|
16
|
+
import { openStagedOutput } from "./staged-output.js";
|
|
15
17
|
import { modeDropsImages } from "@bli-cockpit/telemetry-core";
|
|
16
18
|
import { describeError } from "../health-detail.js";
|
|
17
19
|
import { TranscriptNotStreamable, TranscriptReadError, TranscriptTooLargeError, } from "./transcript-text-stream.js";
|
|
@@ -32,11 +34,13 @@ export function volatilityOf(mode, prepared) {
|
|
|
32
34
|
}
|
|
33
35
|
export async function streamTranscriptBody(session, agent, file) {
|
|
34
36
|
if (!session?.prepareTranscriptFile || !agent) {
|
|
37
|
+
if (file.openSource)
|
|
38
|
+
return streamCompositeWithoutImages(file);
|
|
35
39
|
console.error(TAG + " streamed read not available, reading the transcript whole", JSON.stringify({ reason: "stream_unavailable", kind: file.kind }));
|
|
36
40
|
return null;
|
|
37
41
|
}
|
|
38
42
|
try {
|
|
39
|
-
const prepared = await session.prepareTranscriptFile({ filePath: file.filePath, source: agent, sessionId: file.sessionId, maxFileBytes: file.maxFileBytes, stageDir: file.stageDir, omitText: file.stageDir !== undefined, ...(file.provenance ? { provenance: file.provenance } : {}) });
|
|
43
|
+
const prepared = await session.prepareTranscriptFile({ filePath: file.filePath, ...(file.openSource ? { openSource: file.openSource } : {}), source: agent, sessionId: file.sessionId, maxFileBytes: file.maxFileBytes, stageDir: file.stageDir, omitText: file.stageDir !== undefined, ...(file.provenance ? { provenance: file.provenance } : {}) });
|
|
40
44
|
const { volatile, pendingImages } = volatilityOf(session.mode, prepared);
|
|
41
45
|
console.error(TAG + " transcript streamed through image swap and slim", JSON.stringify({ kind: file.kind, mode: session.mode, byte_size: prepared.readBytes, changed: prepared.changed, images: prepared.imageCount, volatile, staged_bytes: prepared.staged?.byteLength ?? null }));
|
|
42
46
|
// BLI-23344: with a spool the text is not read back; the sanitizer reads the spool line by line.
|
|
@@ -45,12 +49,18 @@ export async function streamTranscriptBody(session, agent, file) {
|
|
|
45
49
|
catch (error) {
|
|
46
50
|
if (error instanceof TranscriptReadError || error instanceof TranscriptTooLargeError)
|
|
47
51
|
throw error;
|
|
52
|
+
if (file.openSource) {
|
|
53
|
+
console.error(TAG + " composite revision remains pending", JSON.stringify({ reason: "composite_prepare_failed", kind: file.kind, ...describeError(error) }));
|
|
54
|
+
throw new TranscriptReadError(error instanceof TranscriptNotStreamable ? error.reason : "composite_prepare_failed", error);
|
|
55
|
+
}
|
|
48
56
|
console.error(TAG + " streamed read set aside, reading the transcript whole", JSON.stringify({ reason: error instanceof TranscriptNotStreamable ? error.reason : "stream_step_failed", kind: file.kind, ...(error instanceof TranscriptNotStreamable ? {} : describeError(error)) }));
|
|
49
57
|
return null;
|
|
50
58
|
}
|
|
51
59
|
}
|
|
52
60
|
/** The whole-file read the streamed path falls back to: same failures, same labels, as the reader that did not stream. */
|
|
53
61
|
export async function readTranscriptWhole(file) {
|
|
62
|
+
if (file.openSource)
|
|
63
|
+
throw new TranscriptReadError("composite_buffering_refused", new Error("composite_buffering_refused"));
|
|
54
64
|
let bytes;
|
|
55
65
|
try {
|
|
56
66
|
bytes = await fs.readFile(file.filePath);
|
|
@@ -62,4 +72,29 @@ export async function readTranscriptWhole(file) {
|
|
|
62
72
|
if (file.maxFileBytes && bytes.byteLength > file.maxFileBytes)
|
|
63
73
|
throw new TranscriptTooLargeError(file.maxFileBytes);
|
|
64
74
|
return bytes;
|
|
75
|
+
}
|
|
76
|
+
/** Keep mode when image preparation is unavailable: same spool/mask/ack, no representative read or whole buffer. */
|
|
77
|
+
async function streamCompositeWithoutImages(file) {
|
|
78
|
+
if (!file.stageDir)
|
|
79
|
+
throw new TranscriptReadError("composite_staging_unavailable", new Error("composite_staging_unavailable"));
|
|
80
|
+
let source;
|
|
81
|
+
let writer;
|
|
82
|
+
try {
|
|
83
|
+
source = await file.openSource();
|
|
84
|
+
writer = await openStagedOutput(file.stageDir);
|
|
85
|
+
await copyLinesStream(source.lines(), writer);
|
|
86
|
+
await source.verifyUnchanged();
|
|
87
|
+
const staged = await writer.finish();
|
|
88
|
+
console.error(TAG + " composite revision kept without image preparation", JSON.stringify({ reason: "composite_images_unavailable_kept", kind: file.kind, byte_size: source.length }));
|
|
89
|
+
return { held: { text: null, exactBytes: null, staged }, volatile: true, pendingImages: null, readBytes: source.length };
|
|
90
|
+
}
|
|
91
|
+
catch (error) {
|
|
92
|
+
await writer?.abandon().catch(() => console.error(TAG + " composite cleanup failed", JSON.stringify({ reason: "composite_staging_cleanup_failed", kind: file.kind })));
|
|
93
|
+
if (error instanceof TranscriptReadError || error instanceof TranscriptTooLargeError)
|
|
94
|
+
throw error;
|
|
95
|
+
throw new TranscriptReadError(error instanceof TranscriptNotStreamable ? error.reason : "composite_prepare_failed", error);
|
|
96
|
+
}
|
|
97
|
+
finally {
|
|
98
|
+
await source?.close().catch(() => console.error(TAG + " composite source cleanup failed", JSON.stringify({ reason: "file_close_failed", kind: file.kind })));
|
|
99
|
+
}
|
|
65
100
|
}
|