feat(providers): migrate codex indexing to adapter + persist, remove legacy indexers (Phase 5c)
Complete the skill-side provider migration: codex now goes through a pure adapter
and the shared persist layer, and the two original monolithic indexers are gone.
New:
- scripts/providers/codex.ts — pure codex adapter. Full-reparse (buffers the whole
file) because the event_msg↔response_item dedup needs whole-file, bidirectional
knowledge; emits SessionRecord with countMode 'total'. Handles guardian threads
(→ delete-session), agent spawns/tool calls (→ tool_call/subagent), token_count
(patched onto the message record) and task_complete (→ message-turn-duration).
Contract:
- SessionRecord.countMode ('total' | 'delta') tells persist whether to replace or
accumulate message_count — claude is line-incremental (delta), codex full-reparse
(total). SubagentRecord non-key fields are optional; persist merges them
column-wise with COALESCE. MessageTurnDurationRecord.turn_duration_ms is nullable.
Orchestration:
- buildIndex's codex branch parses via the adapter and writes via persist. An
unchanged file is skipped but still swept for stale guardian rows (routed through
persist as a delete-session), preserving prior behavior.
Cleanup:
- Remove the now-unused indexJsonl, indexCodexJsonl, deleteCodexThreadRows and
upsertCodexSubagent — their semantics now live in the adapters + persist.
indexer.mjs drops from ~840 to 428 lines. Codex pure helpers stay exported for
codex.ts and the guardian sweep (physical move deferred to the app-side reorg).
- Migrate the upsert drift test off indexJsonl to the claude.parse + persist path,
keeping the rowid-stability and count-replace regression guards.
Tests: tests/codex-parse.test.mjs (record-stream golden: dedup, tools, token patch,
turn-duration, guardian→delete) and tests/codex-index.test.mjs (full buildIndex
path: fresh build + incremental full-reparse, total-count replace, no duplicates).
Verified equivalent on the real ~/.obelisk index: codex messages 82476 and
subagents 522 identical before/after, zero guardian leakage; real incremental
confirmed (touch a codex file → reparsed idempotently, unchanged files skipped).
lint + typecheck clean, 119/119.
This commit is contained in:
@@ -0,0 +1,78 @@
|
||||
// Phase 5c: exercises the full codex buildIndex path (discover → codex.parse →
|
||||
// persist) for both a fresh full build and an incremental rebuild after append.
|
||||
// Codex is full-reparse with countMode 'total', so growth must REPLACE the count
|
||||
// (not accumulate) and upsert messages (no duplicates).
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createRequire } from 'node:module';
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, appendFileSync, utimesSync, statSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
|
||||
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const require = createRequire(import.meta.url);
|
||||
const { DatabaseSync } = require('node:sqlite');
|
||||
|
||||
function runRuntime(args, home) {
|
||||
return spawnSync(process.execPath, ['scripts/runtime.mjs', ...args], {
|
||||
cwd: repoRoot, env: { ...process.env, HOME: home }, encoding: 'utf8',
|
||||
});
|
||||
}
|
||||
|
||||
const ID = '019ed000-0000-7000-8000-000000000001';
|
||||
|
||||
function metaLine() {
|
||||
return JSON.stringify({ type: 'session_meta', timestamp: '2026-06-15T10:00:00Z', payload: { id: ID, timestamp: '2026-06-15T10:00:00Z', cwd: '/tmp/cdx', cli_version: '1.0' } });
|
||||
}
|
||||
function evt(type, message, ts) {
|
||||
return JSON.stringify({ type: 'event_msg', timestamp: ts, payload: { type, message } });
|
||||
}
|
||||
|
||||
function clearDebounce(home) {
|
||||
const db = new DatabaseSync(join(home, '.obelisk', 'obelisk.sqlite'));
|
||||
db.prepare("DELETE FROM index_state WHERE jsonl_path='__last_build__'").run();
|
||||
db.close();
|
||||
}
|
||||
|
||||
function codexCounts(home) {
|
||||
writeFileSync(join(home, 'q.mjs'), `return {
|
||||
sessions: sql("SELECT COUNT(*) c FROM sessions WHERE source='codex'")[0].c,
|
||||
mc: sql("SELECT message_count FROM sessions WHERE source='codex'")[0]?.message_count ?? null,
|
||||
msgs: sql("SELECT COUNT(*) c FROM messages WHERE source='codex'")[0].c,
|
||||
hits: search('followup', { source: 'codex', limit: 5 }).length,
|
||||
};`);
|
||||
const r = runRuntime(['--query', join(home, 'q.mjs')], home);
|
||||
assert.equal(r.status, 0, r.stderr || r.stdout);
|
||||
return JSON.parse(r.stdout);
|
||||
}
|
||||
|
||||
test('codex full build then incremental rebuild replaces the total count without duplicates', () => {
|
||||
const home = mkdtempSync(join(tmpdir(), 'obelisk-codex-idx-'));
|
||||
const dir = join(home, '.codex', 'sessions', '2026', '06', '15');
|
||||
mkdirSync(dir, { recursive: true });
|
||||
const jsonl = join(dir, `rollout-2026-06-15T10-00-00-${ID}.jsonl`);
|
||||
|
||||
// Full build: one user + one agent message.
|
||||
writeFileSync(jsonl, [metaLine(), evt('user_message', 'codex hello', '2026-06-15T10:00:01Z'), evt('agent_message', 'codex reply', '2026-06-15T10:00:02Z')].join('\n') + '\n');
|
||||
assert.equal(runRuntime(['--build'], home).status, 0);
|
||||
|
||||
let c = codexCounts(home);
|
||||
assert.equal(c.sessions, 1, 'one codex session indexed');
|
||||
assert.equal(c.mc, 2, 'two messages counted');
|
||||
assert.equal(c.msgs, 2);
|
||||
|
||||
// Append a third message; bump mtime; incremental rebuild (full-reparse).
|
||||
appendFileSync(jsonl, evt('user_message', 'codex followup', '2026-06-15T10:01:00Z') + '\n');
|
||||
const t = statSync(jsonl).mtimeMs / 1000 + 10;
|
||||
utimesSync(jsonl, t, t);
|
||||
clearDebounce(home);
|
||||
|
||||
c = codexCounts(home);
|
||||
// 'total' replace: 3, not 5 (2+3) and not a stale 2.
|
||||
assert.equal(c.mc, 3, 'message_count replaced with the new total');
|
||||
assert.equal(c.msgs, 3, 'exactly three messages, upserted (no duplicates)');
|
||||
assert.equal(c.hits, 1, 'the appended message is searchable');
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
// Phase 5c-2 golden test: pins the codex adapter's parse() record stream.
|
||||
// Binding-independent (no database). Covers the event_msg↔response_item dedup,
|
||||
// tool call/result, token patching, turn-duration, the 'total' session count,
|
||||
// and guardian-thread → delete-session.
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import { parse } from '../scripts/providers/codex.ts';
|
||||
|
||||
function writeFixture(lines) {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'obelisk-codex-parse-'));
|
||||
const path = join(dir, 'rollout.jsonl');
|
||||
writeFileSync(path, lines.map(l => JSON.stringify(l)).join('\n') + '\n');
|
||||
return path;
|
||||
}
|
||||
|
||||
function drain(gen) {
|
||||
const values = [];
|
||||
let step = gen.next();
|
||||
while (!step.done) { values.push(step.value); step = gen.next(); }
|
||||
return { values, ret: step.value };
|
||||
}
|
||||
|
||||
const META = { id: '019e8951-3e7d-7343-a3e3-05bff48a317d', cwd: '/proj', git: { branch: 'main' }, cli_version: '1.2', timestamp: '2026-06-10T10:00:00Z' };
|
||||
|
||||
test('codex parse() yields a deduped, tool-aware record stream with a total session', () => {
|
||||
const path = writeFixture([
|
||||
{ type: 'session_meta', timestamp: '2026-06-10T10:00:00Z', payload: META },
|
||||
{ type: 'event_msg', timestamp: '2026-06-10T10:00:01Z', payload: { type: 'user_message', message: 'hello codex' } },
|
||||
{ type: 'event_msg', timestamp: '2026-06-10T10:00:02Z', payload: { type: 'agent_message', message: 'hi there' } },
|
||||
// Duplicate of the agent_message above — must be deduped (dropped).
|
||||
{ type: 'response_item', timestamp: '2026-06-10T10:00:02Z', payload: { type: 'message', role: 'assistant', content: [{ type: 'output_text', text: 'hi there' }] } },
|
||||
{ type: 'response_item', timestamp: '2026-06-10T10:00:03Z', payload: { type: 'function_call', call_id: 'call_1', name: 'shell', arguments: '{"cmd":"ls"}' } },
|
||||
{ type: 'response_item', timestamp: '2026-06-10T10:00:04Z', payload: { type: 'function_call_output', call_id: 'call_1', output: 'file listing' } },
|
||||
{ type: 'event_msg', payload: { type: 'token_count', info: { last_token_usage: { input_tokens: 100, output_tokens: 50 } } } },
|
||||
{ type: 'event_msg', timestamp: '2026-06-10T10:00:05Z', payload: { type: 'task_complete', duration_ms: 1500 } },
|
||||
]);
|
||||
|
||||
const { values } = drain(parse({ key: path, sessionId: '' }, null));
|
||||
const byKind = k => values.filter(r => r.kind === k);
|
||||
|
||||
// Three messages: user, assistant text, assistant tool_use. The duplicate
|
||||
// response_item 'hi there' was deduped.
|
||||
const msgs = byKind('message');
|
||||
assert.equal(msgs.length, 3);
|
||||
assert.equal(msgs.filter(m => m.text === 'hi there').length, 1, 'agent_message deduped against response_item');
|
||||
assert.equal(msgs.every(m => m.source === 'codex'), true);
|
||||
|
||||
// token_count patched the last text-assistant message's tokens.
|
||||
const textAssistant = msgs.find(m => m.role === 'assistant' && m.content_type === 'text');
|
||||
assert.equal(textAssistant.input_tokens, 100);
|
||||
assert.equal(textAssistant.output_tokens, 50);
|
||||
|
||||
// Tool call + result.
|
||||
assert.deepEqual(byKind('tool_call').map(t => ({ id: t.id, name: t.name })), [{ id: 'codex:call_1', name: 'shell' }]);
|
||||
assert.equal(byKind('tool_result').length, 1);
|
||||
assert.equal(byKind('tool_result')[0].tool_use_id, 'codex:call_1');
|
||||
|
||||
// task_complete → turn duration on the text-assistant message.
|
||||
assert.deepEqual(byKind('message-turn-duration').map(d => d.turn_duration_ms), [1500]);
|
||||
|
||||
// One session record, full-reparse semantics.
|
||||
const sessions = byKind('session');
|
||||
assert.equal(sessions.length, 1);
|
||||
assert.equal(sessions[0].source, 'codex');
|
||||
assert.equal(sessions[0].countMode, 'total');
|
||||
assert.equal(sessions[0].message_count, 3);
|
||||
assert.equal(sessions[0].git_branch, 'main');
|
||||
});
|
||||
|
||||
test('codex parse() retracts a guardian thread via delete-session and emits nothing else', () => {
|
||||
const path = writeFixture([
|
||||
{ type: 'session_meta', timestamp: '2026-06-10T10:00:00Z', payload: { ...META, source: { subagent: { other: 'guardian' } } } },
|
||||
{ type: 'event_msg', timestamp: '2026-06-10T10:00:01Z', payload: { type: 'user_message', message: 'ignored' } },
|
||||
]);
|
||||
|
||||
const { values } = drain(parse({ key: path, sessionId: '' }, null));
|
||||
|
||||
assert.equal(values.length, 1);
|
||||
assert.equal(values[0].kind, 'delete-session');
|
||||
assert.match(values[0].sessionId, /^codex:/);
|
||||
});
|
||||
@@ -1,64 +1,54 @@
|
||||
// Regression test for the indexer silent-drift fix.
|
||||
//
|
||||
// scripts/indexer.mjs and app/indexer.js had diverged in indexJsonl's message
|
||||
// write: scripts used INSERT OR REPLACE (churns rowid → FTS churn) and always
|
||||
// carried the previous message_count forward (inflating it on a full re-scan),
|
||||
// while app used ON CONFLICT DO UPDATE and reset the count when skip===0. app's
|
||||
// semantics are canonical; this pins them so the two cannot drift again and so
|
||||
// the Phase 5 provider-adapter merge inherits one known-correct behavior.
|
||||
// Regression test for the message write semantics (formerly the indexJsonl
|
||||
// INSERT-OR-REPLACE vs ON-CONFLICT drift; now enforced through the shared
|
||||
// persist layer). Re-indexing a claude session must upsert messages (stable
|
||||
// rowid, no FTS churn) and, because claude parses fresh from an empty cursor
|
||||
// (countMode 'total'), must replace message_count rather than accumulate.
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createRequire } from 'node:module';
|
||||
import { mkdtempSync, writeFileSync } from 'node:fs';
|
||||
import { mkdtempSync, writeFileSync, readFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import { indexJsonl } from '../scripts/indexer.mjs';
|
||||
import { parse } from '../scripts/providers/claude.ts';
|
||||
import { persist } from '../scripts/persist.ts';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const { DatabaseSync } = require('node:sqlite');
|
||||
const SCHEMA = require('node:fs').readFileSync(new URL('../scripts/schema.sql', import.meta.url), 'utf8');
|
||||
const SCHEMA = readFileSync(new URL('../scripts/schema.sql', import.meta.url), 'utf8');
|
||||
|
||||
function writeSessionJsonl() {
|
||||
function fixtureUnit() {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'obelisk-drift-'));
|
||||
const jsonlPath = join(dir, 'sid-drift.jsonl');
|
||||
const jsonlPath = join(dir, 'sess.jsonl');
|
||||
const lines = [
|
||||
{ uuid: 'u-1', type: 'user', timestamp: '2026-06-10T10:00:00Z', cwd: '/tmp/proj', message: { role: 'user', content: 'first question' } },
|
||||
{ uuid: 'a-1', type: 'assistant', timestamp: '2026-06-10T10:00:05Z', message: { role: 'assistant', model: 'claude-opus', content: 'first answer' } },
|
||||
{ uuid: 'u-2', type: 'user', timestamp: '2026-06-10T10:00:10Z', cwd: '/tmp/proj', message: { role: 'user', content: 'second question' } },
|
||||
];
|
||||
writeFileSync(jsonlPath, lines.map(l => JSON.stringify(l)).join('\n') + '\n');
|
||||
return jsonlPath;
|
||||
return { key: jsonlPath, sessionId: 'sid-drift', project: 'quiet-zero' };
|
||||
}
|
||||
|
||||
test('re-indexing a session upserts messages (stable rowid) and does not inflate message_count', () => {
|
||||
test('re-indexing upserts messages (stable rowid) and replaces message_count', () => {
|
||||
const db = new DatabaseSync(':memory:');
|
||||
db.exec(SCHEMA);
|
||||
const fi = { path: writeSessionJsonl(), sessionId: 'sid-drift', project: 'quiet-zero' };
|
||||
|
||||
indexJsonl(db, fi);
|
||||
const unit = fixtureUnit();
|
||||
|
||||
persist(db, unit, parse(unit, null));
|
||||
const countAfterFirst = db.prepare('SELECT message_count FROM sessions WHERE id=?').get('sid-drift').message_count;
|
||||
const rowidAfterFirst = db.prepare('SELECT rowid FROM messages WHERE uuid=?').get('u-1').rowid;
|
||||
const totalMessages = db.prepare('SELECT COUNT(*) AS c FROM messages').get().c;
|
||||
assert.equal(countAfterFirst, 3, 'three user/assistant messages counted');
|
||||
assert.equal(totalMessages, 3);
|
||||
assert.equal(countAfterFirst, 3);
|
||||
assert.equal(db.prepare('SELECT COUNT(*) AS c FROM messages').get().c, 3);
|
||||
|
||||
// Simulate a fresh full re-scan (force / lost index_state): skip resets to 0.
|
||||
db.prepare('DELETE FROM index_state').run();
|
||||
indexJsonl(db, fi);
|
||||
// Re-index the same session from scratch (fresh parse → countMode 'total').
|
||||
persist(db, unit, parse(unit, null));
|
||||
|
||||
const countAfterSecond = db.prepare('SELECT message_count FROM sessions WHERE id=?').get('sid-drift').message_count;
|
||||
const rowidAfterSecond = db.prepare('SELECT rowid FROM messages WHERE uuid=?').get('u-1').rowid;
|
||||
const totalAfterSecond = db.prepare('SELECT COUNT(*) AS c FROM messages').get().c;
|
||||
|
||||
// message_count is reset+recounted, not accumulated (would be 6 under the old bug).
|
||||
assert.equal(countAfterSecond, 3, 'message_count must not inflate on re-scan');
|
||||
// No duplicate rows.
|
||||
assert.equal(totalAfterSecond, 3);
|
||||
// Upsert preserves rowid; INSERT OR REPLACE would have churned it.
|
||||
assert.equal(rowidAfterSecond, rowidAfterFirst, 'upsert must preserve message rowid (no REPLACE churn)');
|
||||
assert.equal(countAfterSecond, 3, 'message_count is replaced, not accumulated (would be 6 under the old bug)');
|
||||
assert.equal(db.prepare('SELECT COUNT(*) AS c FROM messages').get().c, 3, 'no duplicate rows');
|
||||
assert.equal(rowidAfterSecond, rowidAfterFirst, 'upsert preserves rowid (INSERT OR REPLACE would churn it)');
|
||||
|
||||
db.close();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user