throughline 0.6.0 → 0.6.2
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/CHANGELOG.md +33 -0
- package/README.md +46 -5
- package/bin/throughline.mjs +41 -0
- package/docs/00_overview.md +2 -0
- package/docs/04_public_release_plan.md +2 -0
- package/docs/12_desktop_clear_handoff_plan.md +3 -3
- package/docs/13_native_factory_diagnostics_plan.md +46 -0
- package/docs/BUGHUB_RUNTIME_ERROR_STORE_PLAN.md +52 -0
- package/package.json +2 -2
- package/src/auditor-context.mjs +330 -0
- package/src/auditor-context.test.mjs +303 -0
- package/src/cli/auditor-context.mjs +141 -0
- package/src/cli/auditor-context.test.mjs +148 -0
- package/src/cli/codex-hook.mjs +27 -4
- package/src/cli/codex-hook.test.mjs +4 -0
- package/src/cli/codex-restore-smoke.mjs +2 -1
- package/src/cli/codex-restore-source-audit.mjs +1 -1
- package/src/cli/doctor.mjs +5 -1
- package/src/cli/factory-diagnostics.mjs +246 -0
- package/src/cli/factory-diagnostics.test.mjs +201 -0
- package/src/cli/runtime-errors.mjs +85 -0
- package/src/cli/runtime-errors.test.mjs +75 -0
- package/src/cli/trim.mjs +4 -4
- package/src/codex-handoff-model-smoke.mjs +2 -3
- package/src/codex-sidecar-cli.test.mjs +19 -8
- package/src/codex-sidecar.mjs +2 -5
- package/src/codex-sidecar.test.mjs +17 -9
- package/src/codex-thread-index.mjs +17 -2
- package/src/db.mjs +1 -1
- package/src/factory-diagnostics.mjs +118 -0
- package/src/factory-diagnostics.test.mjs +97 -0
- package/src/haiku-summarizer.mjs +3 -5
- package/src/haiku-summarizer.test.mjs +52 -47
- package/src/hook-entrypoints.test.mjs +3 -1
- package/src/phase0-spotter-contract.test.mjs +279 -0
- package/src/portable-spawn-sync.mjs +58 -0
- package/src/portable-spawn-sync.test.mjs +40 -0
- package/src/prompt-submit.mjs +2 -0
- package/src/runtime-error-hook.test.mjs +106 -0
- package/src/runtime-error-observer.mjs +8 -0
- package/src/runtime-error-store.mjs +595 -0
- package/src/runtime-error-store.test.mjs +307 -0
- package/src/session-start.mjs +2 -0
- package/src/test-env.mjs +59 -2
- package/src/turn-backfill.test.mjs +2 -2
- package/src/turn-processor.mjs +2 -0
- package/src/windows-acl-test-helper.mjs +29 -0
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { existsSync, realpathSync } from 'node:fs';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { isAbsolute, join, resolve, sep } from 'node:path';
|
|
5
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
6
|
+
import { buildBodyRowsFromActiveTurns } from './codex-capture.mjs';
|
|
7
|
+
import { parseCodexRolloutFile } from './codex-rollout-memory.mjs';
|
|
8
|
+
import { getLogicalTurnGroups } from './transcript-reader.mjs';
|
|
9
|
+
|
|
10
|
+
export const AUDITOR_CONTEXT_SCHEMA = 'throughline.auditor_context.v1';
|
|
11
|
+
export const AUDITOR_CONTEXT_DB_SCHEMA_VERSION = 8;
|
|
12
|
+
export const DEFAULT_AUDITOR_RECENT_TURNS = 2;
|
|
13
|
+
export const DEFAULT_AUDITOR_MAX_BODY_CHARS = 1200;
|
|
14
|
+
export const DEFAULT_AUDITOR_MAX_TOTAL_CHARS = 4000;
|
|
15
|
+
|
|
16
|
+
export function defaultAuditorContextDbPath() {
|
|
17
|
+
return join(homedir(), '.throughline', 'throughline.db');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function normalizeAuditorBody(value) {
|
|
21
|
+
return String(value ?? '').normalize('NFC').replace(/\r\n?/g, '\n').trim();
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function hashAuditorBody(value) {
|
|
25
|
+
return createHash('sha256').update(normalizeAuditorBody(value), 'utf8').digest('hex');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function deriveAuditorFreshnessExpectation({ host, transcriptPath, sessionId } = {}) {
|
|
29
|
+
assertNonEmptyString(transcriptPath, 'transcriptPath');
|
|
30
|
+
assertNonEmptyString(sessionId, 'sessionId');
|
|
31
|
+
if (host === 'claude') {
|
|
32
|
+
const latest = getLogicalTurnGroups(transcriptPath).at(-1);
|
|
33
|
+
if (!latest) return null;
|
|
34
|
+
return {
|
|
35
|
+
expectedOriginSessionId: sessionId,
|
|
36
|
+
expectedTurnNumber: latest.representative.index,
|
|
37
|
+
expectedUserSha256: hashAuditorBody(latest.user.content),
|
|
38
|
+
expectedAssistantSha256: hashAuditorBody(latest.representative.content),
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
if (host === 'codex') {
|
|
42
|
+
const parsed = parseCodexRolloutFile(transcriptPath, { includeInFlightTurn: false });
|
|
43
|
+
const rows = buildBodyRowsFromActiveTurns(parsed.activeTurns, { sessionId, now: 0 });
|
|
44
|
+
const latestTurnNumber = rows.reduce((max, row) => Math.max(max, row.turnNumber), 0);
|
|
45
|
+
if (latestTurnNumber < 1) return null;
|
|
46
|
+
const user = rows.find((row) => row.turnNumber === latestTurnNumber && row.role === 'user');
|
|
47
|
+
const assistant = rows.find((row) => row.turnNumber === latestTurnNumber && row.role === 'assistant');
|
|
48
|
+
if (!user || !assistant) return null;
|
|
49
|
+
return {
|
|
50
|
+
expectedOriginSessionId: sessionId,
|
|
51
|
+
// Codex active turns are rebuilt after rollback/in-flight filtering. Their ordinal can
|
|
52
|
+
// shift between Stop capture and the next UserPromptSubmit, so it is not a stable identity.
|
|
53
|
+
// Exact session/origin plus both normalized pair hashes remain mandatory below.
|
|
54
|
+
expectedTurnNumber: null,
|
|
55
|
+
expectedUserSha256: hashAuditorBody(user.text),
|
|
56
|
+
expectedAssistantSha256: hashAuditorBody(assistant.text),
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
throw new TypeError('host must be claude or codex');
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function readAuditorContext({
|
|
63
|
+
dbPath = defaultAuditorContextDbPath(),
|
|
64
|
+
sessionId,
|
|
65
|
+
projectRoot,
|
|
66
|
+
expectedOriginSessionId,
|
|
67
|
+
expectedTurnNumber,
|
|
68
|
+
expectedUserSha256,
|
|
69
|
+
expectedAssistantSha256,
|
|
70
|
+
recentTurns = DEFAULT_AUDITOR_RECENT_TURNS,
|
|
71
|
+
maxBodyChars = DEFAULT_AUDITOR_MAX_BODY_CHARS,
|
|
72
|
+
maxTotalChars = DEFAULT_AUDITOR_MAX_TOTAL_CHARS,
|
|
73
|
+
} = {}) {
|
|
74
|
+
assertNonEmptyString(sessionId, 'sessionId');
|
|
75
|
+
assertNonEmptyString(projectRoot, 'projectRoot');
|
|
76
|
+
assertPositiveInteger(recentTurns, 'recentTurns');
|
|
77
|
+
assertPositiveInteger(maxBodyChars, 'maxBodyChars');
|
|
78
|
+
assertPositiveInteger(maxTotalChars, 'maxTotalChars');
|
|
79
|
+
|
|
80
|
+
if (!existsSync(dbPath)) {
|
|
81
|
+
return emptyResult('unavailable', 'db_not_found', { sessionId, projectRoot, recentTurns });
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
let db;
|
|
85
|
+
try {
|
|
86
|
+
db = new DatabaseSync(dbPath, { readOnly: true });
|
|
87
|
+
} catch (cause) {
|
|
88
|
+
throw new AuditorContextError('E_AUDITOR_CONTEXT_DB_OPEN', 'auditor context DB could not be opened', { cause });
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
try {
|
|
92
|
+
const version = Number(db.prepare('PRAGMA user_version').get()?.user_version ?? 0);
|
|
93
|
+
if (version !== AUDITOR_CONTEXT_DB_SCHEMA_VERSION) {
|
|
94
|
+
return emptyResult('schema_mismatch', 'unsupported_db_schema', {
|
|
95
|
+
sessionId,
|
|
96
|
+
projectRoot,
|
|
97
|
+
recentTurns,
|
|
98
|
+
dbSchemaVersion: version,
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const session = db.prepare(
|
|
103
|
+
`SELECT session_id, project_path
|
|
104
|
+
FROM sessions
|
|
105
|
+
WHERE session_id = ?`,
|
|
106
|
+
).get(sessionId);
|
|
107
|
+
if (!session) {
|
|
108
|
+
return emptyResult('empty', 'session_not_found', { sessionId, projectRoot, recentTurns });
|
|
109
|
+
}
|
|
110
|
+
if (!isSameProjectOrDescendant(session.project_path, projectRoot)) {
|
|
111
|
+
return emptyResult('session_mismatch', 'project_mismatch', { sessionId, projectRoot, recentTurns });
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const rows = db.prepare(
|
|
115
|
+
`SELECT id, origin_session_id, turn_number, role, text, created_at
|
|
116
|
+
FROM bodies
|
|
117
|
+
WHERE session_id = ? AND role IN ('user', 'assistant')
|
|
118
|
+
ORDER BY created_at ASC, id ASC`,
|
|
119
|
+
).all(sessionId);
|
|
120
|
+
const completed = buildCompletedPairs(rows);
|
|
121
|
+
if (completed.length === 0) {
|
|
122
|
+
return emptyResult('empty', 'completed_pair_not_found', { sessionId, projectRoot, recentTurns });
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const latest = completed.at(-1);
|
|
126
|
+
const stableTurnIdentityAvailable = Number.isInteger(expectedTurnNumber) && expectedTurnNumber >= 0;
|
|
127
|
+
const codexPairIdentity = sessionId.startsWith('codex:') && expectedOriginSessionId === sessionId;
|
|
128
|
+
const expectationComplete =
|
|
129
|
+
typeof expectedOriginSessionId === 'string' && expectedOriginSessionId.length > 0 &&
|
|
130
|
+
(stableTurnIdentityAvailable || codexPairIdentity) &&
|
|
131
|
+
isSha256(expectedUserSha256) && isSha256(expectedAssistantSha256);
|
|
132
|
+
if (!expectationComplete) {
|
|
133
|
+
return emptyResult('stale', 'freshness_expectation_incomplete', { sessionId, projectRoot, recentTurns });
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const expectedUserHash = expectedUserSha256.toLowerCase();
|
|
137
|
+
const expectedAssistantHash = expectedAssistantSha256.toLowerCase();
|
|
138
|
+
const matchedIndex = stableTurnIdentityAvailable
|
|
139
|
+
? completed.length - 1
|
|
140
|
+
: completed.findLastIndex((pair) =>
|
|
141
|
+
pair.originSessionId === expectedOriginSessionId &&
|
|
142
|
+
hashAuditorBody(pair.user) === expectedUserHash &&
|
|
143
|
+
hashAuditorBody(pair.assistant) === expectedAssistantHash);
|
|
144
|
+
const matched = matchedIndex >= 0 ? completed[matchedIndex] : latest;
|
|
145
|
+
const identityMatched = matchedIndex >= 0 && matched.originSessionId === expectedOriginSessionId &&
|
|
146
|
+
(!stableTurnIdentityAvailable || matched.turnNumber === expectedTurnNumber);
|
|
147
|
+
const userMatched = matchedIndex >= 0 && hashAuditorBody(matched.user) === expectedUserHash;
|
|
148
|
+
const assistantMatched = matchedIndex >= 0 && hashAuditorBody(matched.assistant) === expectedAssistantHash;
|
|
149
|
+
if (!identityMatched || !userMatched || !assistantMatched) {
|
|
150
|
+
return emptyResult('stale', 'latest_pair_mismatch', { sessionId, projectRoot, recentTurns });
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const stableCompleted = completed.slice(0, matchedIndex + 1);
|
|
154
|
+
const bounded = boundCompletedPairs(stableCompleted.slice(-recentTurns), { maxBodyChars, maxTotalChars });
|
|
155
|
+
return {
|
|
156
|
+
schema: AUDITOR_CONTEXT_SCHEMA,
|
|
157
|
+
status: 'fresh',
|
|
158
|
+
reason: 'latest_pair_matched',
|
|
159
|
+
sessionId,
|
|
160
|
+
projectPath: canonicalProjectPath(projectRoot),
|
|
161
|
+
source: 'throughline-db-l2',
|
|
162
|
+
freshness: {
|
|
163
|
+
originSessionId: matched.originSessionId,
|
|
164
|
+
turnNumber: matched.turnNumber,
|
|
165
|
+
identityMatched: true,
|
|
166
|
+
userMatched: true,
|
|
167
|
+
assistantMatched: true,
|
|
168
|
+
},
|
|
169
|
+
turns: bounded.turns,
|
|
170
|
+
stats: {
|
|
171
|
+
requestedTurns: recentTurns,
|
|
172
|
+
returnedTurns: bounded.turns.length,
|
|
173
|
+
chars: bounded.chars,
|
|
174
|
+
truncated: bounded.truncated,
|
|
175
|
+
},
|
|
176
|
+
};
|
|
177
|
+
} catch (cause) {
|
|
178
|
+
if (cause instanceof AuditorContextError) throw cause;
|
|
179
|
+
throw new AuditorContextError('E_AUDITOR_CONTEXT_QUERY', 'auditor context query failed', { cause });
|
|
180
|
+
} finally {
|
|
181
|
+
db.close();
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export class AuditorContextError extends Error {
|
|
186
|
+
constructor(code, message, { cause } = {}) {
|
|
187
|
+
super(message, { cause });
|
|
188
|
+
this.name = 'AuditorContextError';
|
|
189
|
+
this.code = code;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function buildCompletedPairs(rows) {
|
|
194
|
+
const grouped = new Map();
|
|
195
|
+
for (const row of rows) {
|
|
196
|
+
if (!row?.origin_session_id || !Number.isInteger(row.turn_number)) continue;
|
|
197
|
+
const key = `${row.origin_session_id}\u0000${row.turn_number}`;
|
|
198
|
+
const pair = grouped.get(key) ?? {
|
|
199
|
+
originSessionId: row.origin_session_id,
|
|
200
|
+
turnNumber: row.turn_number,
|
|
201
|
+
user: null,
|
|
202
|
+
assistant: null,
|
|
203
|
+
createdAt: Number(row.created_at) || 0,
|
|
204
|
+
lastId: Number(row.id) || 0,
|
|
205
|
+
};
|
|
206
|
+
if (row.role === 'user' && pair.user === null) pair.user = normalizeAuditorBody(row.text);
|
|
207
|
+
if (row.role === 'assistant' && pair.assistant === null) pair.assistant = normalizeAuditorBody(row.text);
|
|
208
|
+
pair.createdAt = Math.max(pair.createdAt, Number(row.created_at) || 0);
|
|
209
|
+
pair.lastId = Math.max(pair.lastId, Number(row.id) || 0);
|
|
210
|
+
grouped.set(key, pair);
|
|
211
|
+
}
|
|
212
|
+
return [...grouped.values()]
|
|
213
|
+
.filter((pair) => pair.user !== null && pair.assistant !== null)
|
|
214
|
+
.sort((a, b) => a.createdAt - b.createdAt || a.lastId - b.lastId)
|
|
215
|
+
.map(({ lastId: _lastId, ...pair }) => pair);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function boundCompletedPairs(pairs, { maxBodyChars, maxTotalChars }) {
|
|
219
|
+
const prepared = pairs.map((pair) => {
|
|
220
|
+
const user = tail(pair.user, maxBodyChars);
|
|
221
|
+
const assistant = tail(pair.assistant, maxBodyChars);
|
|
222
|
+
return {
|
|
223
|
+
...pair,
|
|
224
|
+
user,
|
|
225
|
+
assistant,
|
|
226
|
+
truncated: user.length < pair.user.length || assistant.length < pair.assistant.length,
|
|
227
|
+
};
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
const selected = [];
|
|
231
|
+
let chars = 0;
|
|
232
|
+
let truncated = prepared.some((pair) => pair.truncated);
|
|
233
|
+
for (let i = prepared.length - 1; i >= 0; i--) {
|
|
234
|
+
const pair = prepared[i];
|
|
235
|
+
const remaining = maxTotalChars - chars;
|
|
236
|
+
if (remaining <= 0) {
|
|
237
|
+
truncated = true;
|
|
238
|
+
continue;
|
|
239
|
+
}
|
|
240
|
+
let user = pair.user;
|
|
241
|
+
let assistant = pair.assistant;
|
|
242
|
+
if (user.length + assistant.length > remaining) {
|
|
243
|
+
const assistantBudget = Math.min(assistant.length, Math.ceil(remaining / 2));
|
|
244
|
+
const userBudget = Math.max(0, remaining - assistantBudget);
|
|
245
|
+
user = tail(user, userBudget);
|
|
246
|
+
assistant = tail(assistant, assistantBudget);
|
|
247
|
+
truncated = true;
|
|
248
|
+
}
|
|
249
|
+
if (user.length === 0 || assistant.length === 0) {
|
|
250
|
+
truncated = true;
|
|
251
|
+
continue;
|
|
252
|
+
}
|
|
253
|
+
chars += user.length + assistant.length;
|
|
254
|
+
selected.unshift({
|
|
255
|
+
originSessionId: pair.originSessionId,
|
|
256
|
+
turnNumber: pair.turnNumber,
|
|
257
|
+
user,
|
|
258
|
+
assistant,
|
|
259
|
+
createdAt: pair.createdAt,
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
return { turns: selected, chars, truncated };
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function emptyResult(status, reason, { sessionId, projectRoot, recentTurns, dbSchemaVersion } = {}) {
|
|
266
|
+
return {
|
|
267
|
+
schema: AUDITOR_CONTEXT_SCHEMA,
|
|
268
|
+
status,
|
|
269
|
+
reason,
|
|
270
|
+
sessionId,
|
|
271
|
+
projectPath: canonicalProjectPath(projectRoot),
|
|
272
|
+
source: 'throughline-db-l2',
|
|
273
|
+
freshness: {
|
|
274
|
+
identityMatched: false,
|
|
275
|
+
userMatched: false,
|
|
276
|
+
assistantMatched: false,
|
|
277
|
+
},
|
|
278
|
+
turns: [],
|
|
279
|
+
stats: {
|
|
280
|
+
requestedTurns: recentTurns,
|
|
281
|
+
returnedTurns: 0,
|
|
282
|
+
chars: 0,
|
|
283
|
+
truncated: false,
|
|
284
|
+
...(Number.isInteger(dbSchemaVersion) ? { dbSchemaVersion } : {}),
|
|
285
|
+
},
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function canonicalProjectPath(value) {
|
|
290
|
+
const raw = String(value ?? '');
|
|
291
|
+
if (/^[A-Za-z]:[\\/]/.test(raw)) {
|
|
292
|
+
return raw.replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase();
|
|
293
|
+
}
|
|
294
|
+
let normalized = isAbsolute(raw) ? raw : resolve(raw);
|
|
295
|
+
try {
|
|
296
|
+
if (existsSync(normalized)) normalized = realpathSync.native(normalized);
|
|
297
|
+
} catch {
|
|
298
|
+
// Keep the lexical path when the filesystem cannot resolve it.
|
|
299
|
+
}
|
|
300
|
+
return normalized.split(sep).join('/').replace(/\/+$/, '');
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function isSameProjectOrDescendant(candidate, root) {
|
|
304
|
+
const normalizedCandidate = canonicalProjectPath(candidate);
|
|
305
|
+
const normalizedRoot = canonicalProjectPath(root);
|
|
306
|
+
const left = /^[A-Za-z]:\//.test(normalizedCandidate) ? normalizedCandidate.toLowerCase() : normalizedCandidate;
|
|
307
|
+
const right = /^[A-Za-z]:\//.test(normalizedRoot) ? normalizedRoot.toLowerCase() : normalizedRoot;
|
|
308
|
+
return left === right || left.startsWith(`${right}/`);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function tail(value, maxChars) {
|
|
312
|
+
if (maxChars <= 0) return '';
|
|
313
|
+
return value.length <= maxChars ? value : value.slice(value.length - maxChars);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function isSha256(value) {
|
|
317
|
+
return typeof value === 'string' && /^[a-fA-F0-9]{64}$/.test(value);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function assertNonEmptyString(value, name) {
|
|
321
|
+
if (typeof value !== 'string' || value.length === 0) {
|
|
322
|
+
throw new TypeError(`${name} must be a non-empty string`);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function assertPositiveInteger(value, name) {
|
|
327
|
+
if (!Number.isInteger(value) || value < 1) {
|
|
328
|
+
throw new TypeError(`${name} must be an integer >= 1`);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { existsSync, lstatSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import test from 'node:test';
|
|
6
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
AUDITOR_CONTEXT_SCHEMA,
|
|
10
|
+
deriveAuditorFreshnessExpectation,
|
|
11
|
+
hashAuditorBody,
|
|
12
|
+
readAuditorContext,
|
|
13
|
+
} from './auditor-context.mjs';
|
|
14
|
+
|
|
15
|
+
function withDb(fn, { version = 8 } = {}) {
|
|
16
|
+
const dir = mkdtempSync(join(tmpdir(), 'tl-auditor-context-'));
|
|
17
|
+
const path = join(dir, 'throughline.db');
|
|
18
|
+
const db = new DatabaseSync(path);
|
|
19
|
+
db.exec(`
|
|
20
|
+
PRAGMA journal_mode = WAL;
|
|
21
|
+
PRAGMA user_version = ${version};
|
|
22
|
+
CREATE TABLE sessions (session_id TEXT PRIMARY KEY, project_path TEXT NOT NULL);
|
|
23
|
+
CREATE TABLE bodies (
|
|
24
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
25
|
+
session_id TEXT NOT NULL,
|
|
26
|
+
origin_session_id TEXT NOT NULL,
|
|
27
|
+
turn_number INTEGER NOT NULL,
|
|
28
|
+
role TEXT NOT NULL,
|
|
29
|
+
text TEXT NOT NULL,
|
|
30
|
+
created_at INTEGER NOT NULL
|
|
31
|
+
);
|
|
32
|
+
`);
|
|
33
|
+
try {
|
|
34
|
+
return fn({ db, path, dir });
|
|
35
|
+
} finally {
|
|
36
|
+
db.close();
|
|
37
|
+
rmSync(dir, { recursive: true, force: true });
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function seedSession(db, { sessionId = 'session-1', projectPath = '/repo', pairs = [] } = {}) {
|
|
42
|
+
db.prepare('INSERT INTO sessions (session_id, project_path) VALUES (?, ?)').run(sessionId, projectPath);
|
|
43
|
+
let createdAt = 1;
|
|
44
|
+
for (const pair of pairs) {
|
|
45
|
+
for (const [role, text] of [['user', pair.user], ['assistant', pair.assistant]]) {
|
|
46
|
+
db.prepare(
|
|
47
|
+
'INSERT INTO bodies (session_id, origin_session_id, turn_number, role, text, created_at) VALUES (?, ?, ?, ?, ?, ?)',
|
|
48
|
+
).run(sessionId, pair.originSessionId, pair.turnNumber, role, text, createdAt++);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function expected(pair) {
|
|
54
|
+
return {
|
|
55
|
+
expectedOriginSessionId: pair.originSessionId,
|
|
56
|
+
expectedTurnNumber: pair.turnNumber,
|
|
57
|
+
expectedUserSha256: hashAuditorBody(pair.user),
|
|
58
|
+
expectedAssistantSha256: hashAuditorBody(pair.assistant),
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function read(path, pair, extra = {}) {
|
|
63
|
+
return readAuditorContext({
|
|
64
|
+
dbPath: path,
|
|
65
|
+
sessionId: 'session-1',
|
|
66
|
+
projectRoot: '/repo',
|
|
67
|
+
...expected(pair),
|
|
68
|
+
...extra,
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
test('readAuditorContext: returns a fresh completed pair with canonical origin identity', () => {
|
|
73
|
+
withDb(({ db, path }) => {
|
|
74
|
+
const pair = { originSessionId: 'origin-a', turnNumber: 4, user: ' ask\r\n', assistant: ' answer\r\n' };
|
|
75
|
+
seedSession(db, { pairs: [pair] });
|
|
76
|
+
|
|
77
|
+
const result = read(path, pair);
|
|
78
|
+
assert.equal(result.schema, AUDITOR_CONTEXT_SCHEMA);
|
|
79
|
+
assert.equal(result.status, 'fresh');
|
|
80
|
+
assert.equal(result.reason, 'latest_pair_matched');
|
|
81
|
+
assert.deepEqual(result.turns, [
|
|
82
|
+
{ originSessionId: 'origin-a', turnNumber: 4, user: 'ask', assistant: 'answer', createdAt: 2 },
|
|
83
|
+
]);
|
|
84
|
+
assert.deepEqual(result.freshness, {
|
|
85
|
+
originSessionId: 'origin-a', turnNumber: 4, identityMatched: true, userMatched: true, assistantMatched: true,
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test('readAuditorContext: pair identity or hash mismatch is stale', () => {
|
|
91
|
+
withDb(({ db, path }) => {
|
|
92
|
+
const pair = { originSessionId: 'origin-a', turnNumber: 4, user: 'ask', assistant: 'answer' };
|
|
93
|
+
seedSession(db, { pairs: [pair] });
|
|
94
|
+
|
|
95
|
+
for (const extra of [
|
|
96
|
+
{ expectedOriginSessionId: 'origin-other' },
|
|
97
|
+
{ expectedTurnNumber: 5 },
|
|
98
|
+
{ expectedUserSha256: hashAuditorBody('other') },
|
|
99
|
+
{ expectedAssistantSha256: hashAuditorBody('other') },
|
|
100
|
+
]) {
|
|
101
|
+
const result = read(path, pair, extra);
|
|
102
|
+
assert.equal(result.status, 'stale');
|
|
103
|
+
assert.equal(result.reason, 'latest_pair_mismatch');
|
|
104
|
+
assert.deepEqual(result.turns, []);
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test('readAuditorContext: returns empty for no complete pair and excludes developer/L3 roles', () => {
|
|
110
|
+
withDb(({ db, path }) => {
|
|
111
|
+
seedSession(db);
|
|
112
|
+
db.prepare('INSERT INTO bodies (session_id, origin_session_id, turn_number, role, text, created_at) VALUES (?, ?, ?, ?, ?, ?)')
|
|
113
|
+
.run('session-1', 'origin-a', 1, 'developer', 'secret developer context', 1);
|
|
114
|
+
db.prepare('INSERT INTO bodies (session_id, origin_session_id, turn_number, role, text, created_at) VALUES (?, ?, ?, ?, ?, ?)')
|
|
115
|
+
.run('session-1', 'origin-a', 1, 'user', 'unpaired request', 2);
|
|
116
|
+
const pair = { originSessionId: 'origin-a', turnNumber: 1, user: 'unpaired request', assistant: 'missing' };
|
|
117
|
+
|
|
118
|
+
const result = read(path, pair);
|
|
119
|
+
assert.equal(result.status, 'empty');
|
|
120
|
+
assert.equal(result.reason, 'completed_pair_not_found');
|
|
121
|
+
assert.deepEqual(result.turns, []);
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test('readAuditorContext: reports project mismatch without exposing rows', () => {
|
|
126
|
+
withDb(({ db, path }) => {
|
|
127
|
+
const pair = { originSessionId: 'origin-a', turnNumber: 1, user: 'private', assistant: 'private reply' };
|
|
128
|
+
seedSession(db, { projectPath: '/other-project', pairs: [pair] });
|
|
129
|
+
const result = read(path, pair);
|
|
130
|
+
assert.equal(result.status, 'session_mismatch');
|
|
131
|
+
assert.equal(result.reason, 'project_mismatch');
|
|
132
|
+
assert.deepEqual(result.turns, []);
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test('readAuditorContext: applies recent-turn, body, and total bounds from the newest pairs', () => {
|
|
137
|
+
withDb(({ db, path }) => {
|
|
138
|
+
const old = { originSessionId: 'origin-a', turnNumber: 1, user: 'old user', assistant: 'old assistant' };
|
|
139
|
+
const latest = { originSessionId: 'origin-a', turnNumber: 2, user: 'abcdef', assistant: 'uvwxyz' };
|
|
140
|
+
seedSession(db, { pairs: [old, latest] });
|
|
141
|
+
const result = read(path, latest, { recentTurns: 1, maxBodyChars: 4, maxTotalChars: 5 });
|
|
142
|
+
assert.equal(result.status, 'fresh');
|
|
143
|
+
assert.deepEqual(result.turns, [
|
|
144
|
+
{ originSessionId: 'origin-a', turnNumber: 2, user: 'ef', assistant: 'xyz', createdAt: 4 },
|
|
145
|
+
]);
|
|
146
|
+
assert.deepEqual(result.stats, { requestedTurns: 1, returnedTurns: 1, chars: 5, truncated: true });
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test('readAuditorContext: reports schema mismatch and missing DB as exit-safe JSON states', () => {
|
|
151
|
+
withDb(({ db, path }) => {
|
|
152
|
+
const pair = { originSessionId: 'origin-a', turnNumber: 1, user: 'u', assistant: 'a' };
|
|
153
|
+
seedSession(db, { pairs: [pair] });
|
|
154
|
+
const mismatched = read(path, pair);
|
|
155
|
+
assert.equal(mismatched.status, 'schema_mismatch');
|
|
156
|
+
assert.equal(mismatched.reason, 'unsupported_db_schema');
|
|
157
|
+
}, { version: 7 });
|
|
158
|
+
|
|
159
|
+
const pair = { originSessionId: 'origin-a', turnNumber: 1, user: 'u', assistant: 'a' };
|
|
160
|
+
const missing = read('/definitely-missing-throughline-auditor-context.db', pair);
|
|
161
|
+
assert.equal(missing.status, 'unavailable');
|
|
162
|
+
assert.equal(missing.reason, 'db_not_found');
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test('readAuditorContext: opens a live WAL database read-only without changing DB, -wal, or -shm', () => {
|
|
166
|
+
withDb(({ db, path }) => {
|
|
167
|
+
const pair = { originSessionId: 'origin-a', turnNumber: 1, user: 'u', assistant: 'a' };
|
|
168
|
+
seedSession(db, { pairs: [pair] });
|
|
169
|
+
db.exec('BEGIN IMMEDIATE');
|
|
170
|
+
const before = snapshotSqliteFiles(path);
|
|
171
|
+
const result = read(path, pair);
|
|
172
|
+
assert.equal(result.status, 'fresh');
|
|
173
|
+
assert.deepEqual(snapshotSqliteFiles(path), before);
|
|
174
|
+
db.exec('ROLLBACK');
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test('readAuditorContext: classifies an exclusive database lock without exposing SQLite diagnostics', () => {
|
|
179
|
+
withDb(({ db, path }) => {
|
|
180
|
+
const pair = { originSessionId: 'origin-a', turnNumber: 1, user: 'u', assistant: 'a' };
|
|
181
|
+
seedSession(db, { pairs: [pair] });
|
|
182
|
+
db.exec('PRAGMA journal_mode = DELETE; BEGIN EXCLUSIVE');
|
|
183
|
+
try {
|
|
184
|
+
assert.throws(
|
|
185
|
+
() => read(path, pair),
|
|
186
|
+
(error) => {
|
|
187
|
+
assert.equal(error.code, 'E_AUDITOR_CONTEXT_QUERY');
|
|
188
|
+
assert.equal(error.message, 'auditor context query failed');
|
|
189
|
+
assert.equal(error.message.includes('locked'), false);
|
|
190
|
+
return true;
|
|
191
|
+
},
|
|
192
|
+
);
|
|
193
|
+
} finally {
|
|
194
|
+
db.exec('ROLLBACK');
|
|
195
|
+
}
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
test('deriveAuditorFreshnessExpectation: Claude logical groups use the latest representative fragment and session origin', () => {
|
|
200
|
+
const dir = mkdtempSync(join(tmpdir(), 'tl-auditor-claude-transcript-'));
|
|
201
|
+
const transcript = join(dir, 'session.jsonl');
|
|
202
|
+
try {
|
|
203
|
+
writeFileSync(transcript, [
|
|
204
|
+
claudeRow('user', 'first request'),
|
|
205
|
+
claudeRow('assistant', 'first answer'),
|
|
206
|
+
claudeRow('user', 'latest request'),
|
|
207
|
+
claudeRow('assistant', 'partial answer'),
|
|
208
|
+
claudeRow('assistant', 'latest representative answer'),
|
|
209
|
+
].map(JSON.stringify).join('\n'));
|
|
210
|
+
|
|
211
|
+
assert.deepEqual(
|
|
212
|
+
deriveAuditorFreshnessExpectation({ host: 'claude', transcriptPath: transcript, sessionId: 'claude-session' }),
|
|
213
|
+
{
|
|
214
|
+
expectedOriginSessionId: 'claude-session',
|
|
215
|
+
expectedTurnNumber: 4,
|
|
216
|
+
expectedUserSha256: hashAuditorBody('latest request'),
|
|
217
|
+
expectedAssistantSha256: hashAuditorBody('latest representative answer'),
|
|
218
|
+
},
|
|
219
|
+
);
|
|
220
|
+
} finally {
|
|
221
|
+
rmSync(dir, { recursive: true, force: true });
|
|
222
|
+
}
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
test('deriveAuditorFreshnessExpectation: Codex excludes current in-flight turn before deriving latest completed identity', () => {
|
|
226
|
+
const dir = mkdtempSync(join(tmpdir(), 'tl-auditor-codex-rollout-'));
|
|
227
|
+
const rollout = join(dir, 'rollout.jsonl');
|
|
228
|
+
try {
|
|
229
|
+
writeFileSync(rollout, [
|
|
230
|
+
codexEvent('user_message', { message: 'completed request' }),
|
|
231
|
+
codexEvent('task_started'),
|
|
232
|
+
codexEvent('agent_message', { message: 'completed answer' }),
|
|
233
|
+
codexEvent('task_complete'),
|
|
234
|
+
codexEvent('user_message', { message: 'in-flight request' }),
|
|
235
|
+
codexEvent('task_started'),
|
|
236
|
+
codexEvent('agent_message', { message: 'in-flight answer' }),
|
|
237
|
+
].map(JSON.stringify).join('\n'));
|
|
238
|
+
|
|
239
|
+
assert.deepEqual(
|
|
240
|
+
deriveAuditorFreshnessExpectation({ host: 'codex', transcriptPath: rollout, sessionId: 'codex:thread-1' }),
|
|
241
|
+
{
|
|
242
|
+
expectedOriginSessionId: 'codex:thread-1',
|
|
243
|
+
expectedTurnNumber: null,
|
|
244
|
+
expectedUserSha256: hashAuditorBody('completed request'),
|
|
245
|
+
expectedAssistantSha256: hashAuditorBody('completed answer'),
|
|
246
|
+
},
|
|
247
|
+
);
|
|
248
|
+
} finally {
|
|
249
|
+
rmSync(dir, { recursive: true, force: true });
|
|
250
|
+
}
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
test('readAuditorContext: Codex freshness uses exact origin and both pair hashes when turn ordinals are unstable', () => {
|
|
254
|
+
withDb(({ db, path }) => {
|
|
255
|
+
seedSession(db, {
|
|
256
|
+
sessionId: 'codex:thread-1',
|
|
257
|
+
projectPath: '/repo',
|
|
258
|
+
pairs: [{
|
|
259
|
+
originSessionId: 'codex:thread-1', turnNumber: 32,
|
|
260
|
+
user: 'completed request', assistant: 'completed answer',
|
|
261
|
+
}, {
|
|
262
|
+
originSessionId: 'codex:thread-1', turnNumber: 33,
|
|
263
|
+
user: 'transient request', assistant: 'transient answer',
|
|
264
|
+
}],
|
|
265
|
+
});
|
|
266
|
+
const result = readAuditorContext({
|
|
267
|
+
dbPath: path,
|
|
268
|
+
sessionId: 'codex:thread-1',
|
|
269
|
+
projectRoot: '/repo',
|
|
270
|
+
expectedOriginSessionId: 'codex:thread-1',
|
|
271
|
+
expectedTurnNumber: null,
|
|
272
|
+
expectedUserSha256: hashAuditorBody('completed request'),
|
|
273
|
+
expectedAssistantSha256: hashAuditorBody('completed answer'),
|
|
274
|
+
});
|
|
275
|
+
assert.equal(result.status, 'fresh');
|
|
276
|
+
assert.equal(result.freshness.turnNumber, 32);
|
|
277
|
+
assert.equal(result.turns.at(-1).turnNumber, 32);
|
|
278
|
+
assert.equal(result.turns.some((turn) => turn.turnNumber === 33), false);
|
|
279
|
+
});
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
function snapshotSqliteFiles(path) {
|
|
283
|
+
return [path, `${path}-wal`, `${path}-shm`].map((file) => {
|
|
284
|
+
if (!existsSync(file)) return { file, exists: false };
|
|
285
|
+
const stat = lstatSync(file);
|
|
286
|
+
try {
|
|
287
|
+
return { file, exists: true, size: stat.size, mtimeMs: stat.mtimeMs, bytes: readFileSync(file).toString('hex') };
|
|
288
|
+
} catch (error) {
|
|
289
|
+
// Windows denies byte reads for a live WAL handle. Metadata still
|
|
290
|
+
// proves the read-only auditor did not create or mutate a sidecar.
|
|
291
|
+
if (error?.code === 'EBUSY') return { file, exists: true, size: stat.size, mtimeMs: stat.mtimeMs, readError: 'EBUSY' };
|
|
292
|
+
throw error;
|
|
293
|
+
}
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function claudeRow(role, text) {
|
|
298
|
+
return { type: role, message: { role, content: [{ type: 'text', text }] } };
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function codexEvent(type, payload = {}) {
|
|
302
|
+
return { timestamp: '2026-07-13T00:00:00.000Z', type: 'event_msg', payload: { type, ...payload } };
|
|
303
|
+
}
|