throughline 0.6.0 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,280 @@
1
+ import assert from 'node:assert/strict';
2
+ import {
3
+ existsSync,
4
+ lstatSync,
5
+ mkdirSync,
6
+ mkdtempSync,
7
+ readFileSync,
8
+ rmSync,
9
+ symlinkSync,
10
+ writeFileSync,
11
+ } from 'node:fs';
12
+ import { tmpdir } from 'node:os';
13
+ import { join } from 'node:path';
14
+ import test from 'node:test';
15
+ import { DatabaseSync } from 'node:sqlite';
16
+
17
+ import { captureCodexRolloutToDb } from './codex-capture.mjs';
18
+ import { runCodexUserPromptSubmitHook } from './cli/codex-hook.mjs';
19
+ import { findCodexThreadCandidate } from './codex-thread-index.mjs';
20
+
21
+ const THREAD_ID = '019dfaba-f87e-7f41-a144-d5ca7c6dd7f9';
22
+
23
+ function makeCaptureDb() {
24
+ const db = new DatabaseSync(':memory:');
25
+ db.exec(`
26
+ CREATE TABLE sessions (
27
+ session_id TEXT PRIMARY KEY,
28
+ project_path TEXT NOT NULL,
29
+ status TEXT NOT NULL DEFAULT 'active',
30
+ created_at INTEGER NOT NULL,
31
+ updated_at INTEGER NOT NULL,
32
+ merged_into TEXT
33
+ );
34
+ CREATE TABLE skeletons (
35
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
36
+ session_id TEXT NOT NULL,
37
+ origin_session_id TEXT,
38
+ turn_number INTEGER NOT NULL,
39
+ role TEXT NOT NULL,
40
+ summary TEXT NOT NULL,
41
+ created_at INTEGER NOT NULL
42
+ );
43
+ CREATE TABLE bodies (
44
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
45
+ session_id TEXT NOT NULL,
46
+ origin_session_id TEXT NOT NULL,
47
+ turn_number INTEGER NOT NULL,
48
+ role TEXT NOT NULL,
49
+ text TEXT NOT NULL,
50
+ token_count INTEGER,
51
+ created_at INTEGER NOT NULL
52
+ );
53
+ CREATE TABLE details (
54
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
55
+ session_id TEXT NOT NULL,
56
+ origin_session_id TEXT,
57
+ turn_number INTEGER,
58
+ tool_name TEXT NOT NULL,
59
+ input_text TEXT,
60
+ output_text TEXT,
61
+ token_count INTEGER NOT NULL DEFAULT 0,
62
+ created_at INTEGER NOT NULL,
63
+ kind TEXT,
64
+ source_id TEXT
65
+ );
66
+ `);
67
+ return db;
68
+ }
69
+
70
+ function event(type, payload = {}) {
71
+ return {
72
+ timestamp: '2026-07-13T00:00:00.000Z',
73
+ type: 'event_msg',
74
+ payload: { type, ...payload },
75
+ };
76
+ }
77
+
78
+ function developerMemory(text = '## Throughline: Active Work Context\ninternal memory') {
79
+ return {
80
+ timestamp: '2026-07-13T00:00:01.000Z',
81
+ type: 'response_item',
82
+ payload: {
83
+ type: 'message',
84
+ role: 'developer',
85
+ content: [{ type: 'input_text', text }],
86
+ },
87
+ };
88
+ }
89
+
90
+ function toolInput() {
91
+ return {
92
+ timestamp: '2026-07-13T00:00:01.000Z',
93
+ type: 'response_item',
94
+ payload: {
95
+ type: 'function_call',
96
+ name: 'exec_command',
97
+ arguments: '{"cmd":"pwd"}',
98
+ call_id: 'call_inflight',
99
+ },
100
+ };
101
+ }
102
+
103
+ function writeRollout(home, { cwd, id = THREAD_ID, events = [] }) {
104
+ const dir = join(home, 'sessions', '2026', '07', '13');
105
+ mkdirSync(dir, { recursive: true });
106
+ const path = join(dir, `rollout-2026-07-13T00-00-00-${id}.jsonl`);
107
+ const rows = [
108
+ {
109
+ timestamp: '2026-07-13T00:00:00.000Z',
110
+ type: 'session_meta',
111
+ payload: { id, cwd, source: 'vscode', cli_version: '0.128.0-alpha.1' },
112
+ },
113
+ ...events,
114
+ ];
115
+ writeFileSync(path, `${rows.map((row) => JSON.stringify(row)).join('\n')}\n`);
116
+ return path;
117
+ }
118
+
119
+ test('Phase 0: capture projection preserves one completed user/assistant pair and its Codex origin identity', () => {
120
+ const home = mkdtempSync(join(tmpdir(), 'tl-phase0-capture-home-'));
121
+ const project = mkdtempSync(join(tmpdir(), 'tl-phase0-capture-project-'));
122
+ const db = makeCaptureDb();
123
+ try {
124
+ writeRollout(home, {
125
+ cwd: project,
126
+ events: [
127
+ event('user_message', { message: 'completed user request' }),
128
+ event('task_started'),
129
+ event('agent_message', { message: 'completed assistant response' }),
130
+ event('task_complete'),
131
+ developerMemory(),
132
+ ],
133
+ });
134
+
135
+ const result = captureCodexRolloutToDb(db, { threadId: THREAD_ID, codexHome: home, projectPath: project });
136
+ assert.equal(result.status, 'captured');
137
+ assert.deepEqual(
138
+ db
139
+ .prepare('SELECT origin_session_id, turn_number, role, text FROM bodies ORDER BY id')
140
+ .all()
141
+ .map((row) => ({ ...row })),
142
+ [
143
+ { origin_session_id: `codex:${THREAD_ID}`, turn_number: 1, role: 'user', text: 'completed user request' },
144
+ {
145
+ origin_session_id: `codex:${THREAD_ID}`,
146
+ turn_number: 1,
147
+ role: 'assistant',
148
+ text: 'completed assistant response',
149
+ },
150
+ ],
151
+ 'audit projection candidates are completed conversation pairs only',
152
+ );
153
+ } finally {
154
+ db.close();
155
+ rmSync(home, { recursive: true, force: true });
156
+ rmSync(project, { recursive: true, force: true });
157
+ }
158
+ });
159
+
160
+ test('Phase 0: read-only WAL audit harness sees committed data during a writer transaction without writing DB sidecars', () => {
161
+ const dir = mkdtempSync(join(tmpdir(), 'tl-phase0-wal-'));
162
+ const path = join(dir, 'throughline.db');
163
+ const writer = new DatabaseSync(path);
164
+ let reader;
165
+ try {
166
+ writer.exec('PRAGMA journal_mode = WAL; CREATE TABLE audit_probe (value TEXT); INSERT INTO audit_probe VALUES (\'committed\');');
167
+ writer.exec("BEGIN IMMEDIATE; UPDATE audit_probe SET value = 'uncommitted';");
168
+
169
+ const before = snapshotSqliteFiles(path);
170
+ reader = new DatabaseSync(path, { readOnly: true });
171
+ assert.equal(reader.prepare('SELECT value FROM audit_probe').get().value, 'committed');
172
+ assert.throws(() => reader.exec("INSERT INTO audit_probe VALUES ('forbidden')"));
173
+ assert.deepEqual(snapshotSqliteFiles(path), before, 'audit reader must not modify DB, -wal, or -shm');
174
+ } finally {
175
+ reader?.close();
176
+ writer.exec('ROLLBACK');
177
+ writer.close();
178
+ rmSync(dir, { recursive: true, force: true });
179
+ }
180
+ });
181
+
182
+ test('Phase 0: Spotter child environment prevents Throughline Codex hook re-entry before any capture side effect', async () => {
183
+ const home = mkdtempSync(join(tmpdir(), 'tl-phase0-spotter-home-'));
184
+ const project = mkdtempSync(join(tmpdir(), 'tl-phase0-spotter-project-'));
185
+ try {
186
+ writeRollout(home, {
187
+ cwd: project,
188
+ events: [
189
+ event('user_message', { message: 'must not be captured from Spotter child' }),
190
+ event('task_started'),
191
+ event('agent_message', { message: 'must not be captured from Spotter child' }),
192
+ event('task_complete'),
193
+ ],
194
+ });
195
+
196
+ for (const childEnv of ['SPOTTER_PARENT_PID', 'SPOTTER_BACKEND', 'SPOTTER_CHILD_BACKEND']) {
197
+ const db = makeCaptureDb();
198
+ let monitorWrites = 0;
199
+ let taskEnsures = 0;
200
+ try {
201
+ const result = await runCodexUserPromptSubmitHook({
202
+ args: { codexThreadId: THREAD_ID, codexHome: home, projectPath: project },
203
+ env: { [childEnv]: '1' },
204
+ db,
205
+ ensureMonitorTask: () => {
206
+ taskEnsures++;
207
+ },
208
+ writeMonitorState: () => {
209
+ monitorWrites++;
210
+ },
211
+ buildMonitorUsage: () => null,
212
+ });
213
+
214
+ assert.equal(result.status, 'skipped', childEnv);
215
+ assert.equal(result.reason, 'spotter_child_backend', childEnv);
216
+ assert.equal(taskEnsures, 0, childEnv);
217
+ assert.equal(monitorWrites, 0, childEnv);
218
+ assert.equal(db.prepare('SELECT COUNT(*) AS count FROM bodies').get().count, 0, childEnv);
219
+ } finally {
220
+ db.close();
221
+ }
222
+ }
223
+ } finally {
224
+ rmSync(home, { recursive: true, force: true });
225
+ rmSync(project, { recursive: true, force: true });
226
+ }
227
+ });
228
+
229
+ test('Phase 0: project identity accepts a rollout under a marker root subdirectory through symlink and Windows-style case', () => {
230
+ const home = mkdtempSync(join(tmpdir(), 'tl-phase0-identity-home-'));
231
+ const markerRoot = mkdtempSync(join(tmpdir(), 'tl-phase0-marker-root-'));
232
+ const aliasParent = mkdtempSync(join(tmpdir(), 'tl-phase0-marker-alias-'));
233
+ const child = join(markerRoot, 'packages', 'adapter');
234
+ const alias = join(aliasParent, 'spotter-link');
235
+ try {
236
+ mkdirSync(child, { recursive: true });
237
+ symlinkSync(markerRoot, alias);
238
+ assert.ok(lstatSync(alias).isSymbolicLink());
239
+
240
+ writeRollout(home, { cwd: child });
241
+ assert.equal(
242
+ findCodexThreadCandidate({ threadId: THREAD_ID, codexHome: home, projectPath: alias })?.id,
243
+ THREAD_ID,
244
+ 'marker root must include rollout cwd descendants after symlink resolution',
245
+ );
246
+
247
+ const windowsThreadId = '019dfabb-1111-7111-8111-111111111111';
248
+ writeRollout(home, {
249
+ id: windowsThreadId,
250
+ cwd: 'C:\\Users\\Kite\\Developer\\Spotter\\packages\\adapter',
251
+ });
252
+ assert.equal(
253
+ findCodexThreadCandidate({
254
+ threadId: windowsThreadId,
255
+ codexHome: home,
256
+ projectPath: 'c:/users/kite/developer/spotter',
257
+ })?.id,
258
+ windowsThreadId,
259
+ 'Windows-style path case must retain marker-root descendant identity',
260
+ );
261
+ } finally {
262
+ rmSync(home, { recursive: true, force: true });
263
+ rmSync(markerRoot, { recursive: true, force: true });
264
+ rmSync(aliasParent, { recursive: true, force: true });
265
+ }
266
+ });
267
+
268
+ function snapshotSqliteFiles(path) {
269
+ return [path, `${path}-wal`, `${path}-shm`].map((file) => {
270
+ if (!existsSync(file)) return { file, exists: false };
271
+ const stat = lstatSync(file);
272
+ return {
273
+ file,
274
+ exists: true,
275
+ size: stat.size,
276
+ mtimeMs: stat.mtimeMs,
277
+ bytes: readFileSync(file).toString('hex'),
278
+ };
279
+ });
280
+ }
@@ -207,7 +207,7 @@ test('backfillBodies: sidechain entries and missing or empty paths produce no gr
207
207
 
208
208
  test('deriveTranscriptPath munges slash and dot characters with one leading dash', () => {
209
209
  assert.equal(
210
- deriveTranscriptPath('/Users/kite/Developer/Through.line', 'session-id'),
211
- join(homedir(), '.claude', 'projects', '-Users-kite-Developer-Through-line', 'session-id.jsonl'),
210
+ deriveTranscriptPath('/Users/example/Developer/Through.line', 'session-id'),
211
+ join(homedir(), '.claude', 'projects', '-Users-example-Developer-Through-line', 'session-id.jsonl'),
212
212
  );
213
213
  });