throughline 0.6.3 → 0.8.0
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 +101 -1
- package/README.md +86 -28
- package/bin/throughline.mjs +20 -0
- package/docs/00_overview.md +12 -0
- package/docs/02_clear_auto_handoff_plan.md +47 -13
- package/docs/04_public_release_plan.md +1 -0
- package/docs/14_observer_completed_turn_feed_plan.md +290 -0
- package/docs/BUGHUB_RUNTIME_ERROR_STORE_PLAN.md +9 -3
- package/docs/adr/0002-observer-claude-completion-receipt.md +42 -0
- package/docs/adr/0003-observer-completed-chain-cursor.md +34 -0
- package/docs/adr/0004-observer-db-pair-projection.md +71 -0
- package/docs/adr/0005-observer-read-pagination.md +51 -0
- package/docs/adr/0006-observer-page-offset-proof.md +33 -0
- package/docs/adr/0007-observer-read-cli-contract.md +61 -0
- package/docs/adr/0008-observer-wait-deadline-cancel.md +81 -0
- package/docs/adr/0009-observer-integration-regression-and-docs.md +37 -0
- package/docs/adr/0010-observer-o1-phase-acceptance.md +49 -0
- package/docs/adr/0011-observer-o1-control-lane-reconciliation.md +34 -0
- package/docs/adr/0012-claude-stop-transcript-flush-barrier.md +32 -0
- package/docs/adr/0013-observer-read-busy-writer-gate.md +46 -0
- package/docs/adr/0014-two-phase-handoff-ghost-baton.md +112 -0
- package/docs/adr/0015-l1-summarizer-model-effort-ratio.md +81 -0
- package/docs/adr/0016-push-pull-recall-injection.md +93 -0
- package/package.json +1 -1
- package/rag/01-hooks/hook-stdout-10k-persisted-output.md +65 -0
- package/rag/INDEX.md +4 -0
- package/src/auditor-context.mjs +92 -11
- package/src/auditor-context.test.mjs +116 -1
- package/src/baton.mjs +27 -7
- package/src/baton.test.mjs +44 -0
- package/src/body-digest.mjs +9 -0
- package/src/cli/auditor-context.test.mjs +1 -1
- package/src/cli/factory-diagnostics.mjs +1 -0
- package/src/cli/factory-diagnostics.test.mjs +6 -2
- package/src/cli/observer-read.mjs +73 -0
- package/src/cli/observer-read.test.mjs +93 -0
- package/src/cli/observer-wait.mjs +123 -0
- package/src/cli/observer-wait.test.mjs +167 -0
- package/src/cli/recall.mjs +279 -0
- package/src/cli/recall.test.mjs +269 -0
- package/src/codex-rollout-memory.mjs +13 -0
- package/src/codex-rollout-memory.test.mjs +27 -0
- package/src/codex-thread-index.mjs +1 -1
- package/src/codex-thread-index.test.mjs +18 -0
- package/src/completed-turn-receipts.mjs +374 -0
- package/src/completed-turn-receipts.test.mjs +186 -0
- package/src/db-schema.test.mjs +9 -2
- package/src/db.mjs +20 -1
- package/src/decision-log.mjs +24 -0
- package/src/haiku-summarizer.mjs +93 -16
- package/src/haiku-summarizer.test.mjs +118 -9
- package/src/handoff-executor.mjs +161 -0
- package/src/hook-entrypoints.test.mjs +192 -12
- package/src/observer-codex-projection.test.mjs +49 -0
- package/src/observer-turn-feed.mjs +392 -0
- package/src/observer-turn-feed.test.mjs +339 -0
- package/src/observer-turn-wait.mjs +102 -0
- package/src/observer-turn-wait.test.mjs +122 -0
- package/src/pending-handoff.mjs +96 -0
- package/src/pending-handoff.test.mjs +107 -0
- package/src/prompt-submit.mjs +46 -1
- package/src/resume-context.mjs +337 -60
- package/src/resume-context.test.mjs +228 -1
- package/src/runtime-error-store.mjs +2 -1
- package/src/runtime-error-store.test.mjs +1 -1
- package/src/session-start.mjs +70 -233
- package/src/transcript-reader.mjs +32 -0
- package/src/turn-backfill.mjs +3 -2
- package/src/turn-backfill.test.mjs +10 -4
- package/src/turn-processor.mjs +90 -1
- package/src/turn-processor.test.mjs +142 -0
- package/src/windows-acl-test-helper.mjs +2 -2
|
@@ -13,6 +13,7 @@ import { tmpdir } from 'node:os';
|
|
|
13
13
|
import { dirname, join } from 'node:path';
|
|
14
14
|
import { DatabaseSync } from 'node:sqlite';
|
|
15
15
|
import { fileURLToPath } from 'node:url';
|
|
16
|
+
import { readCompletedTurnReceiptSnapshot } from './completed-turn-receipts.mjs';
|
|
16
17
|
|
|
17
18
|
const REPO_ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
18
19
|
|
|
@@ -183,7 +184,7 @@ test('prompt-submit: non-baton prompt does not write any baton', () => {
|
|
|
183
184
|
}
|
|
184
185
|
});
|
|
185
186
|
|
|
186
|
-
test('session-start
|
|
187
|
+
test('two-phase: session-start registers intent only; first prompt consumes baton and injects', () => {
|
|
187
188
|
const home = makeTempHome();
|
|
188
189
|
const project = makeTempProject();
|
|
189
190
|
try {
|
|
@@ -210,6 +211,7 @@ test('session-start subprocess consumes baton and injects inherited resume conte
|
|
|
210
211
|
).run();
|
|
211
212
|
db.close();
|
|
212
213
|
|
|
214
|
+
// 第一相: SessionStart は intent 登録のみ。merge も注入もせず、baton も残る。
|
|
213
215
|
const started = runNode([join(REPO_ROOT, 'src/session-start.mjs')], {
|
|
214
216
|
home,
|
|
215
217
|
cwd: project,
|
|
@@ -219,17 +221,127 @@ test('session-start subprocess consumes baton and injects inherited resume conte
|
|
|
219
221
|
source: 'startup',
|
|
220
222
|
}),
|
|
221
223
|
});
|
|
222
|
-
|
|
223
224
|
assert.equal(started.status, 0, started.stderr);
|
|
224
|
-
assert.
|
|
225
|
+
assert.equal(started.stdout, '', 'SessionStart must not inject anything');
|
|
226
|
+
|
|
227
|
+
const mid = openDb(home);
|
|
228
|
+
assert.equal(
|
|
229
|
+
mid.prepare("SELECT merged_into FROM sessions WHERE session_id = 'old-session'").get()
|
|
230
|
+
.merged_into,
|
|
231
|
+
null,
|
|
232
|
+
'no merge at SessionStart',
|
|
233
|
+
);
|
|
234
|
+
assert.equal(mid.prepare('SELECT COUNT(*) AS c FROM handoff_batons').get().c, 1, 'baton kept');
|
|
235
|
+
assert.equal(
|
|
236
|
+
mid.prepare("SELECT COUNT(*) AS c FROM pending_handoffs WHERE session_id = 'new-session'").get().c,
|
|
237
|
+
1,
|
|
238
|
+
'pending intent registered',
|
|
239
|
+
);
|
|
240
|
+
mid.close();
|
|
241
|
+
|
|
242
|
+
// 第二相: 初回プロンプト (= 実体の証明) で consume + merge + 注入。
|
|
243
|
+
const firstPrompt = runNode([join(REPO_ROOT, 'src/prompt-submit.mjs')], {
|
|
244
|
+
home,
|
|
245
|
+
cwd: project,
|
|
246
|
+
input: JSON.stringify({
|
|
247
|
+
session_id: 'new-session',
|
|
248
|
+
cwd: project,
|
|
249
|
+
prompt: 'continue the work',
|
|
250
|
+
}),
|
|
251
|
+
});
|
|
252
|
+
assert.equal(firstPrompt.status, 0, firstPrompt.stderr);
|
|
253
|
+
assert.match(firstPrompt.stdout, /old assistant body/);
|
|
254
|
+
|
|
255
|
+
const after = openDb(home);
|
|
256
|
+
assert.equal(
|
|
257
|
+
after.prepare("SELECT merged_into FROM sessions WHERE session_id = 'old-session'").get()
|
|
258
|
+
.merged_into,
|
|
259
|
+
'new-session',
|
|
260
|
+
);
|
|
261
|
+
assert.equal(after.prepare('SELECT COUNT(*) AS c FROM handoff_batons').get().c, 0);
|
|
262
|
+
assert.equal(after.prepare('SELECT COUNT(*) AS c FROM pending_handoffs').get().c, 0);
|
|
263
|
+
after.close();
|
|
264
|
+
|
|
265
|
+
// 2 回目のプロンプトでは再注入しない (pending 消費済み)
|
|
266
|
+
const secondPrompt = runNode([join(REPO_ROOT, 'src/prompt-submit.mjs')], {
|
|
267
|
+
home,
|
|
268
|
+
cwd: project,
|
|
269
|
+
input: JSON.stringify({
|
|
270
|
+
session_id: 'new-session',
|
|
271
|
+
cwd: project,
|
|
272
|
+
prompt: 'and then?',
|
|
273
|
+
}),
|
|
274
|
+
});
|
|
275
|
+
assert.equal(secondPrompt.status, 0, secondPrompt.stderr);
|
|
276
|
+
assert.equal(secondPrompt.stdout, '', 'no duplicate injection on later prompts');
|
|
277
|
+
} finally {
|
|
278
|
+
rmSync(project, { recursive: true, force: true });
|
|
279
|
+
rmSync(home, { recursive: true, force: true });
|
|
280
|
+
}
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
test('two-phase GHOST regression: first-arriving promptless SessionStart cannot steal the baton', () => {
|
|
284
|
+
// 2026-07-17 incident (ADR 0014) の再現形:
|
|
285
|
+
// 同一 project で幽霊 SessionStart が実体より数百 ms 先に発火しても、
|
|
286
|
+
// プロンプトを発火しない限りバトンも記憶も奪えない。
|
|
287
|
+
const home = makeTempHome();
|
|
288
|
+
const project = makeTempProject();
|
|
289
|
+
try {
|
|
290
|
+
const baton = runNode([join(REPO_ROOT, 'src/prompt-submit.mjs')], {
|
|
291
|
+
home,
|
|
292
|
+
cwd: project,
|
|
293
|
+
input: JSON.stringify({ session_id: 'predecessor', cwd: project, prompt: '/tl' }),
|
|
294
|
+
});
|
|
295
|
+
assert.equal(baton.status, 0, baton.stderr);
|
|
296
|
+
|
|
297
|
+
const db = openDb(home);
|
|
298
|
+
db.prepare(
|
|
299
|
+
`INSERT INTO sessions (session_id, project_path, status, created_at, updated_at)
|
|
300
|
+
VALUES ('predecessor', ?, 'active', 1, 1)`,
|
|
301
|
+
).run(project);
|
|
302
|
+
db.prepare(
|
|
303
|
+
`INSERT INTO bodies
|
|
304
|
+
(session_id, origin_session_id, turn_number, role, text, token_count, created_at)
|
|
305
|
+
VALUES ('predecessor', 'predecessor', 1, 'assistant', 'precious memory', 4, 2)`,
|
|
306
|
+
).run();
|
|
307
|
+
db.close();
|
|
308
|
+
|
|
309
|
+
// 幽霊が先着 (488ms 差の再現 — 順序だけが本質)
|
|
310
|
+
const ghost = runNode([join(REPO_ROOT, 'src/session-start.mjs')], {
|
|
311
|
+
home,
|
|
312
|
+
cwd: project,
|
|
313
|
+
input: JSON.stringify({ session_id: 'ghost-session', cwd: project, source: 'startup' }),
|
|
314
|
+
});
|
|
315
|
+
assert.equal(ghost.status, 0, ghost.stderr);
|
|
316
|
+
|
|
317
|
+
// 実体が後着
|
|
318
|
+
const real = runNode([join(REPO_ROOT, 'src/session-start.mjs')], {
|
|
319
|
+
home,
|
|
320
|
+
cwd: project,
|
|
321
|
+
input: JSON.stringify({ session_id: 'real-session', cwd: project, source: 'startup' }),
|
|
322
|
+
});
|
|
323
|
+
assert.equal(real.status, 0, real.stderr);
|
|
324
|
+
|
|
325
|
+
// 幽霊はプロンプトを発火しない。実体の初回プロンプトが記憶を受け取る。
|
|
326
|
+
const firstPrompt = runNode([join(REPO_ROOT, 'src/prompt-submit.mjs')], {
|
|
327
|
+
home,
|
|
328
|
+
cwd: project,
|
|
329
|
+
input: JSON.stringify({ session_id: 'real-session', cwd: project, prompt: '続きをやろう' }),
|
|
330
|
+
});
|
|
331
|
+
assert.equal(firstPrompt.status, 0, firstPrompt.stderr);
|
|
332
|
+
assert.match(firstPrompt.stdout, /precious memory/, 'real session receives the memory');
|
|
225
333
|
|
|
226
334
|
const after = openDb(home);
|
|
227
|
-
|
|
228
|
-
.prepare("SELECT merged_into FROM sessions WHERE session_id = '
|
|
335
|
+
assert.equal(
|
|
336
|
+
after.prepare("SELECT merged_into FROM sessions WHERE session_id = 'predecessor'").get()
|
|
337
|
+
.merged_into,
|
|
338
|
+
'real-session',
|
|
339
|
+
'memory merged into the REAL session, not the ghost',
|
|
340
|
+
);
|
|
341
|
+
const ghostBodies = after
|
|
342
|
+
.prepare("SELECT COUNT(*) AS c FROM bodies WHERE session_id = 'ghost-session'")
|
|
229
343
|
.get();
|
|
230
|
-
|
|
231
|
-
assert.equal(old.merged_into, 'new-session');
|
|
232
|
-
assert.equal(batons.c, 0);
|
|
344
|
+
assert.equal(ghostBodies.c, 0, 'ghost holds nothing');
|
|
233
345
|
after.close();
|
|
234
346
|
} finally {
|
|
235
347
|
rmSync(project, { recursive: true, force: true });
|
|
@@ -237,7 +349,53 @@ test('session-start subprocess consumes baton and injects inherited resume conte
|
|
|
237
349
|
}
|
|
238
350
|
});
|
|
239
351
|
|
|
240
|
-
test('
|
|
352
|
+
test('two-phase: a running session does not steal a baton written after its birth', () => {
|
|
353
|
+
const home = makeTempHome();
|
|
354
|
+
const project = makeTempProject();
|
|
355
|
+
try {
|
|
356
|
+
// セッション B が誕生して初回プロンプトも済んでいる (pending 消費済み)
|
|
357
|
+
const startB = runNode([join(REPO_ROOT, 'src/session-start.mjs')], {
|
|
358
|
+
home,
|
|
359
|
+
cwd: project,
|
|
360
|
+
input: JSON.stringify({ session_id: 'running-B', cwd: project, source: 'startup' }),
|
|
361
|
+
});
|
|
362
|
+
assert.equal(startB.status, 0, startB.stderr);
|
|
363
|
+
const promptB1 = runNode([join(REPO_ROOT, 'src/prompt-submit.mjs')], {
|
|
364
|
+
home,
|
|
365
|
+
cwd: project,
|
|
366
|
+
input: JSON.stringify({ session_id: 'running-B', cwd: project, prompt: 'work work' }),
|
|
367
|
+
});
|
|
368
|
+
assert.equal(promptB1.status, 0, promptB1.stderr);
|
|
369
|
+
|
|
370
|
+
// その後、別セッション A が /tl → 次の新規セッションへのバトンを書く
|
|
371
|
+
const tl = runNode([join(REPO_ROOT, 'src/prompt-submit.mjs')], {
|
|
372
|
+
home,
|
|
373
|
+
cwd: project,
|
|
374
|
+
input: JSON.stringify({ session_id: 'session-A', cwd: project, prompt: '/tl' }),
|
|
375
|
+
});
|
|
376
|
+
assert.equal(tl.status, 0, tl.stderr);
|
|
377
|
+
|
|
378
|
+
// B の 2 発目のプロンプトはバトンを奪えない (pending 無し = ハンドオフ判定なし)
|
|
379
|
+
const promptB2 = runNode([join(REPO_ROOT, 'src/prompt-submit.mjs')], {
|
|
380
|
+
home,
|
|
381
|
+
cwd: project,
|
|
382
|
+
input: JSON.stringify({ session_id: 'running-B', cwd: project, prompt: 'more work' }),
|
|
383
|
+
});
|
|
384
|
+
assert.equal(promptB2.status, 0, promptB2.stderr);
|
|
385
|
+
assert.equal(promptB2.stdout, '');
|
|
386
|
+
|
|
387
|
+
const db = openDb(home);
|
|
388
|
+
const rows = db.prepare('SELECT session_id FROM handoff_batons').all();
|
|
389
|
+
assert.equal(rows.length, 1, 'baton must remain for the true successor');
|
|
390
|
+
assert.equal(rows[0].session_id, 'session-A');
|
|
391
|
+
db.close();
|
|
392
|
+
} finally {
|
|
393
|
+
rmSync(project, { recursive: true, force: true });
|
|
394
|
+
rmSync(home, { recursive: true, force: true });
|
|
395
|
+
}
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
test('session-start subprocess registers session row and stays silent without a baton', () => {
|
|
241
399
|
const home = makeTempHome();
|
|
242
400
|
const project = makeTempProject();
|
|
243
401
|
try {
|
|
@@ -260,6 +418,10 @@ test('session-start subprocess does not inject context when no baton exists', ()
|
|
|
260
418
|
.get();
|
|
261
419
|
assert.equal(row.session_id, 'new-session');
|
|
262
420
|
assert.equal(row.project_path, project);
|
|
421
|
+
const pending = db
|
|
422
|
+
.prepare("SELECT COUNT(*) AS c FROM pending_handoffs WHERE session_id = 'new-session'")
|
|
423
|
+
.get();
|
|
424
|
+
assert.equal(pending.c, 1, 'pending intent registered even without baton');
|
|
263
425
|
db.close();
|
|
264
426
|
} finally {
|
|
265
427
|
rmSync(project, { recursive: true, force: true });
|
|
@@ -333,6 +495,7 @@ test('process-turn subprocess stores L2 bodies and L3 details in an isolated DB'
|
|
|
333
495
|
session_id: 'turn-session',
|
|
334
496
|
cwd: project,
|
|
335
497
|
transcript_path: transcriptPath,
|
|
498
|
+
last_assistant_message: 'check passed',
|
|
336
499
|
}),
|
|
337
500
|
});
|
|
338
501
|
|
|
@@ -388,7 +551,12 @@ test('process-turn subprocess backfills all completed logical turns from a multi
|
|
|
388
551
|
const result = runNode([join(REPO_ROOT, 'src/turn-processor.mjs')], {
|
|
389
552
|
home,
|
|
390
553
|
cwd: project,
|
|
391
|
-
input: JSON.stringify({
|
|
554
|
+
input: JSON.stringify({
|
|
555
|
+
session_id: 'multi-turn-session',
|
|
556
|
+
cwd: project,
|
|
557
|
+
transcript_path: transcriptPath,
|
|
558
|
+
last_assistant_message: 'second answer',
|
|
559
|
+
}),
|
|
392
560
|
});
|
|
393
561
|
assert.equal(result.status, 0, result.stderr);
|
|
394
562
|
const db = openDb(home);
|
|
@@ -401,6 +569,9 @@ test('process-turn subprocess backfills all completed logical turns from a multi
|
|
|
401
569
|
{ role: 'user', text: 'second request' },
|
|
402
570
|
],
|
|
403
571
|
);
|
|
572
|
+
const receipts = readCompletedTurnReceiptSnapshot({ projectPath: project, env: childEnv(home) });
|
|
573
|
+
assert.equal(receipts.receipts.length, 2);
|
|
574
|
+
assert.deepEqual(receipts.receipts.map((entry) => entry.sequence), [1, 2]);
|
|
404
575
|
db.close();
|
|
405
576
|
} finally {
|
|
406
577
|
rmSync(project, { recursive: true, force: true });
|
|
@@ -408,7 +579,7 @@ test('process-turn subprocess backfills all completed logical turns from a multi
|
|
|
408
579
|
}
|
|
409
580
|
});
|
|
410
581
|
|
|
411
|
-
test('
|
|
582
|
+
test('first prompt backfills a derived predecessor transcript without a state file', {
|
|
412
583
|
skip: process.platform === 'win32' ? 'Windowsはstate file transcriptPath fallback契約' : undefined,
|
|
413
584
|
}, () => {
|
|
414
585
|
const home = makeTempHome();
|
|
@@ -450,6 +621,15 @@ test('session-start backfills a derived predecessor transcript without a state f
|
|
|
450
621
|
input: JSON.stringify({ session_id: 'new-session', cwd: project, source: 'startup' }),
|
|
451
622
|
});
|
|
452
623
|
assert.equal(started.status, 0, started.stderr);
|
|
624
|
+
|
|
625
|
+
// 二相ハンドオフ: backfill + merge は初回プロンプトで行われる
|
|
626
|
+
const firstPrompt = runNode([join(REPO_ROOT, 'src/prompt-submit.mjs')], {
|
|
627
|
+
home,
|
|
628
|
+
cwd: project,
|
|
629
|
+
input: JSON.stringify({ session_id: 'new-session', cwd: project, prompt: 'continue' }),
|
|
630
|
+
});
|
|
631
|
+
assert.equal(firstPrompt.status, 0, firstPrompt.stderr);
|
|
632
|
+
|
|
453
633
|
const after = openDb(home);
|
|
454
634
|
assert.deepEqual(
|
|
455
635
|
after
|
|
@@ -466,7 +646,7 @@ test('session-start backfills a derived predecessor transcript without a state f
|
|
|
466
646
|
.split('\n')
|
|
467
647
|
.filter((line) => line)
|
|
468
648
|
.map((line) => JSON.parse(line));
|
|
469
|
-
assert.ok(backfillLog.some((entry) => entry.hook === '
|
|
649
|
+
assert.ok(backfillLog.some((entry) => entry.hook === 'prompt-submit'));
|
|
470
650
|
assert.equal(existsSync(join(home, '.throughline', 'state', `${predecessorId}.json`)), false);
|
|
471
651
|
} finally {
|
|
472
652
|
rmSync(project, { recursive: true, force: true });
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { mkdtempSync, mkdirSync, 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
|
+
import { resolveObserverTurnFeed } from './observer-turn-feed.mjs';
|
|
8
|
+
|
|
9
|
+
test('observer Codex projection: feed origin hash matches codex:<thread_id> DB identity', () => {
|
|
10
|
+
const root = mkdtempSync(join(tmpdir(), 'tl-observer-codex-projection-'));
|
|
11
|
+
const project = join(root, 'project');
|
|
12
|
+
const codexHome = join(root, 'codex');
|
|
13
|
+
const rolloutDir = join(codexHome, 'sessions', '2026', '07', '15');
|
|
14
|
+
const threadId = '019dfaba-f87e-7f41-a144-d5ca7c6dd7f9';
|
|
15
|
+
const sessionId = `codex:${threadId}`;
|
|
16
|
+
const dbPath = join(root, 'throughline.db');
|
|
17
|
+
let db;
|
|
18
|
+
try {
|
|
19
|
+
mkdirSync(project);
|
|
20
|
+
mkdirSync(rolloutDir, { recursive: true });
|
|
21
|
+
const events = [
|
|
22
|
+
{ type: 'session_meta', timestamp: '2026-07-15T00:00:00.000Z', payload: { id: threadId, cwd: project } },
|
|
23
|
+
{ type: 'event_msg', timestamp: '2026-07-15T00:00:01.000Z', payload: { type: 'user_message', message: 'codex user' } },
|
|
24
|
+
{ type: 'event_msg', timestamp: '2026-07-15T00:00:02.000Z', payload: { type: 'task_started' } },
|
|
25
|
+
{ type: 'event_msg', timestamp: '2026-07-15T00:00:03.000Z', payload: { type: 'agent_message', message: 'codex answer' } },
|
|
26
|
+
{ type: 'event_msg', timestamp: '2026-07-15T00:00:04.000Z', payload: { type: 'task_complete' } },
|
|
27
|
+
];
|
|
28
|
+
writeFileSync(join(rolloutDir, `rollout-2026-07-15T00-00-00-${threadId}.jsonl`), events.map(JSON.stringify).join('\n'));
|
|
29
|
+
|
|
30
|
+
db = new DatabaseSync(dbPath);
|
|
31
|
+
db.exec(`PRAGMA user_version = 9;
|
|
32
|
+
CREATE TABLE sessions (session_id TEXT PRIMARY KEY, project_path TEXT NOT NULL);
|
|
33
|
+
CREATE TABLE bodies (id INTEGER PRIMARY KEY AUTOINCREMENT, session_id TEXT NOT NULL, origin_session_id TEXT NOT NULL, turn_number INTEGER NOT NULL, role TEXT NOT NULL, text TEXT NOT NULL, created_at INTEGER NOT NULL);`);
|
|
34
|
+
db.prepare('INSERT INTO sessions (session_id, project_path) VALUES (?, ?)').run(sessionId, project);
|
|
35
|
+
const insert = db.prepare('INSERT INTO bodies (session_id, origin_session_id, turn_number, role, text, created_at) VALUES (?, ?, 1, ?, ?, ?)');
|
|
36
|
+
insert.run(sessionId, sessionId, 'user', 'codex user', 1);
|
|
37
|
+
insert.run(sessionId, sessionId, 'assistant', 'codex answer', 2);
|
|
38
|
+
|
|
39
|
+
const result = resolveObserverTurnFeed({ projectPath: project, codexHome, dbPath });
|
|
40
|
+
assert.equal(result.status, 'snapshot');
|
|
41
|
+
assert.deepEqual(result.turns.map(({ user, assistant }) => ({ user, assistant })), [
|
|
42
|
+
{ user: 'codex user', assistant: 'codex answer' },
|
|
43
|
+
]);
|
|
44
|
+
assert.doesNotMatch(JSON.stringify(result), new RegExp(`${threadId}|codex:`));
|
|
45
|
+
} finally {
|
|
46
|
+
db?.close();
|
|
47
|
+
rmSync(root, { recursive: true, force: true });
|
|
48
|
+
}
|
|
49
|
+
});
|