feat(core): add first-class Pi session indexing (#23)
Pi cannot be read as another linear JSONL stream. Its history is a tree with a durable leaf, orphan roots, branch summaries, and two compaction forms, so the active context is something the format states rather than something line order implies. The adapter keeps those semantics inside itself and projects the result into the existing canonical tables. Sessions are keyed by (normalized header cwd, header id) rather than by path, because Pi's --session-id lookup is project-local: two projects may reuse an id, while a move or an identical copy is still one session. Discovery covers both layouts Pi writes and fingerprints each file by mtime, ctime, size and inode, so a rewrite that preserves mtime is not read as unchanged. Abandoned branches are preserved rather than dropped. Visibility becomes three-state -- visible, inactive, hidden -- and helpers return only visible rows until includeInactive asks for the superseded path, labeling every row so a caller knows which it holds. Usage counts all three, because an abandoned call still spent tokens; message_count reports only the visible transcript. A committed MIT-licensed oracle transcribed from Pi 0.83.0 pins the context algorithms, and a fixed-seed differential runs 512 generated sessions against it on every test run. Schema changes are additive.
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createRequire } from 'node:module';
|
||||
import { mkdtempSync } from 'node:fs';
|
||||
import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
@@ -101,6 +101,33 @@ test('indexer service reschedules a writer-lease deferral without publishing a h
|
||||
assert.equal(heartbeats, 1);
|
||||
});
|
||||
|
||||
test('a deferred full-inventory build stays full when retried', async () => {
|
||||
const timers = manualTimers();
|
||||
const calls = [];
|
||||
const service = createIndexerService({
|
||||
buildIndex: async (args) => {
|
||||
calls.push(args);
|
||||
return calls.length === 1 ? { deferred: true } : { deferred: false };
|
||||
},
|
||||
watchProjects: () => null,
|
||||
writeHeartbeat: () => {},
|
||||
timers,
|
||||
stabilityMs: 0,
|
||||
});
|
||||
|
||||
service.scheduleBuild('root-appeared');
|
||||
timers.flush();
|
||||
await service.idle();
|
||||
service.scheduleBuild('ordinary-change', '/tmp/later.jsonl');
|
||||
timers.flush();
|
||||
await service.idle();
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
{ reason: 'root-appeared', changedPaths: undefined },
|
||||
{ reason: 'ordinary-change', changedPaths: undefined },
|
||||
]);
|
||||
});
|
||||
|
||||
test('indexer service does not log a build cancelled by a service stop', async () => {
|
||||
const timers = manualTimers();
|
||||
const warnings = [];
|
||||
@@ -143,6 +170,59 @@ test('indexer service logs a build that fails while running', async () => {
|
||||
assert.match(warnings[0], /Obelisk index build failed: disk on fire/);
|
||||
});
|
||||
|
||||
test('indexer service reports partial inventory paths on ordinary builds', async () => {
|
||||
const warnings = [];
|
||||
const service = createIndexerService({
|
||||
buildIndex: async () => ({
|
||||
deferred: false,
|
||||
complete: false,
|
||||
inventoryIssues: [{
|
||||
provider: 'pi',
|
||||
path: '/tmp/pi/locked',
|
||||
error: 'EACCES: permission denied',
|
||||
}],
|
||||
}),
|
||||
watchProjects: () => null,
|
||||
writeHeartbeat: () => {},
|
||||
logger: { warn: (msg) => warnings.push(msg) },
|
||||
stabilityMs: 0,
|
||||
});
|
||||
|
||||
await service.runBuildNow('startup');
|
||||
|
||||
assert.deepEqual(warnings, [
|
||||
'Obelisk indexed a partial pi inventory at /tmp/pi/locked: EACCES: permission denied',
|
||||
]);
|
||||
});
|
||||
|
||||
test('indexer service reports partial inventory paths before a deferred retry', async () => {
|
||||
const timers = manualTimers();
|
||||
const warnings = [];
|
||||
const service = createIndexerService({
|
||||
buildIndex: async () => ({
|
||||
deferred: true,
|
||||
complete: false,
|
||||
inventoryIssues: [{
|
||||
provider: 'pi',
|
||||
path: '/tmp/pi/locked',
|
||||
error: 'EACCES: permission denied',
|
||||
}],
|
||||
}),
|
||||
watchProjects: () => null,
|
||||
writeHeartbeat: () => {},
|
||||
logger: { warn: (msg) => warnings.push(msg) },
|
||||
timers,
|
||||
stabilityMs: 0,
|
||||
});
|
||||
|
||||
await service.runBuildNow('startup');
|
||||
service.stop();
|
||||
|
||||
assert.deepEqual(warnings, [
|
||||
'Obelisk indexed a partial pi inventory at /tmp/pi/locked: EACCES: permission denied',
|
||||
]);
|
||||
});
|
||||
|
||||
test('indexer service waits for a stability window before building', async () => {
|
||||
const timers = manualTimers();
|
||||
const calls = [];
|
||||
@@ -345,3 +425,53 @@ test('indexer service watches Claude projects and Codex sessions for app-side in
|
||||
join(codexSessionsDir, '2026/06/15/rollout-2026-06-15T00-00-00-codex.jsonl'),
|
||||
]);
|
||||
});
|
||||
|
||||
test('indexer service starts watching a configured root that appears after startup', async () => {
|
||||
const existingRoot = mkdtempSync(join(tmpdir(), 'obelisk-watch-existing-'));
|
||||
const parent = mkdtempSync(join(tmpdir(), 'obelisk-watch-late-parent-'));
|
||||
const lateRoot = join(parent, 'nested', 'sessions');
|
||||
const timers = manualTimers();
|
||||
const calls = [];
|
||||
const watchArgs = [];
|
||||
const chokidar = {
|
||||
watch(root) {
|
||||
watchArgs.push(root);
|
||||
const watcher = {
|
||||
on() {
|
||||
return watcher;
|
||||
},
|
||||
close() {},
|
||||
};
|
||||
return watcher;
|
||||
},
|
||||
};
|
||||
const service = createIndexerService({
|
||||
watchDirs: [existingRoot, lateRoot],
|
||||
buildIndex: async (args) => calls.push(args),
|
||||
chokidar,
|
||||
writeHeartbeat: () => {},
|
||||
timers,
|
||||
stabilityMs: 0,
|
||||
debounceMs: 0,
|
||||
watchRetryMs: 0,
|
||||
});
|
||||
|
||||
try {
|
||||
service.start({ buildOnStart: false });
|
||||
assert.deepEqual(watchArgs, [existingRoot]);
|
||||
|
||||
mkdirSync(lateRoot, { recursive: true });
|
||||
writeFileSync(join(lateRoot, 'pre-existing.jsonl'), '{}\n');
|
||||
timers.flush();
|
||||
assert.deepEqual(watchArgs, [existingRoot, lateRoot]);
|
||||
|
||||
timers.flush();
|
||||
await service.idle();
|
||||
assert.deepEqual(calls, [{
|
||||
reason: 'watch',
|
||||
changedPaths: undefined,
|
||||
}]);
|
||||
} finally {
|
||||
service.stop();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -117,12 +117,15 @@ function defaultIndexerWorkerClient() {
|
||||
};
|
||||
}
|
||||
|
||||
async function loadMainForWindowFlags(flags) {
|
||||
async function loadMainForWindowFlags(flags, { settingsText } = {}) {
|
||||
const originalArgv = process.argv;
|
||||
const originalHome = process.env.HOME;
|
||||
const home = join(tmpdir(), `obelisk-window-flags-${Date.now()}-${Math.random()}`);
|
||||
mkdirSync(join(home, '.obelisk'), { recursive: true });
|
||||
writeFileSync(join(home, '.obelisk', 'obelisk.sqlite'), '');
|
||||
if (settingsText !== undefined) {
|
||||
writeFileSync(join(home, '.obelisk', 'settings.json'), settingsText);
|
||||
}
|
||||
process.env.HOME = home;
|
||||
process.argv = [originalArgv[0] || 'node', originalArgv[1] || 'electron', ...flags];
|
||||
|
||||
@@ -199,6 +202,11 @@ test('dev mode does not open DevTools unless explicitly requested', async () =>
|
||||
assert.equal(devtoolsWindows[0].devToolsOpened, true);
|
||||
});
|
||||
|
||||
test('malformed settings keep the desktop recovery window available', async () => {
|
||||
const windows = await loadMainForWindowFlags([], { settingsText: '{broken' });
|
||||
assert.equal(windows.length, 1);
|
||||
});
|
||||
|
||||
test('main process watches every root declared by the built-in provider registry', async () => {
|
||||
const originalHome = process.env.HOME;
|
||||
const home = join(tmpdir(), `obelisk-main-watch-dirs-${Date.now()}`);
|
||||
@@ -212,6 +220,7 @@ test('main process watches every root declared by the built-in provider registry
|
||||
process.env.HOME = home;
|
||||
|
||||
const serviceOptions = [];
|
||||
const workerCalls = [];
|
||||
|
||||
class FakeDatabase {
|
||||
pragma() {}
|
||||
@@ -251,7 +260,17 @@ test('main process watches every root declared by the built-in provider registry
|
||||
},
|
||||
},
|
||||
}],
|
||||
[INDEXER_WORKER_URL, { namedExports: defaultIndexerWorkerClient() }],
|
||||
[INDEXER_WORKER_URL, {
|
||||
namedExports: {
|
||||
createWorkerBuildIndex: () => ({
|
||||
buildIndex: async (args) => {
|
||||
workerCalls.push(args);
|
||||
return { files: 0, affectedSessionIds: [], complete: true };
|
||||
},
|
||||
stop() {},
|
||||
}),
|
||||
},
|
||||
}],
|
||||
]);
|
||||
|
||||
try {
|
||||
@@ -265,8 +284,11 @@ test('main process watches every root declared by the built-in provider registry
|
||||
join(codexDir, 'session_index.jsonl'),
|
||||
join(home, '.kimi-code', 'sessions'),
|
||||
join(home, '.kimi-code', 'session_index.jsonl'),
|
||||
join(home, '.pi', 'agent', 'sessions'),
|
||||
]);
|
||||
assert.equal(serviceOptions[0].watchDirs.includes(codexDir), false);
|
||||
await serviceOptions[0].buildIndex({ reason: 'settings-transfer' });
|
||||
assert.deepEqual(workerCalls[0].providerSettings, {});
|
||||
} finally {
|
||||
restore();
|
||||
process.env.HOME = originalHome;
|
||||
@@ -286,6 +308,7 @@ test('main process forwards committed IDs without reopening after a deferred bui
|
||||
let databaseOpens = 0;
|
||||
let serviceOptions;
|
||||
let notifications = 0;
|
||||
const sent = [];
|
||||
|
||||
class FakeDatabase {
|
||||
constructor() { databaseOpens += 1; }
|
||||
@@ -302,7 +325,16 @@ test('main process forwards committed IDs without reopening after a deferred bui
|
||||
loadFile() {}
|
||||
loadURL() {}
|
||||
close() {}
|
||||
static getAllWindows() { return [{ webContents: { send() { notifications += 1; } } }]; }
|
||||
static getAllWindows() {
|
||||
return [{
|
||||
webContents: {
|
||||
send(channel, payload) {
|
||||
notifications += 1;
|
||||
sent.push({ channel, payload });
|
||||
},
|
||||
},
|
||||
}];
|
||||
}
|
||||
static fromWebContents() { return null; }
|
||||
}
|
||||
|
||||
@@ -322,7 +354,19 @@ test('main process forwards committed IDs without reopening after a deferred bui
|
||||
[INDEXER_WORKER_URL, {
|
||||
namedExports: {
|
||||
createWorkerBuildIndex: () => ({
|
||||
buildIndex: async () => ({ deferred: true, reason: 'database_busy', affectedSessionIds: ['session-1'] }),
|
||||
buildIndex: async ({ reason }) => reason === 'inventory'
|
||||
? {
|
||||
deferred: true,
|
||||
complete: false,
|
||||
reason: 'database_busy',
|
||||
affectedSessionIds: [],
|
||||
inventoryIssues: [{
|
||||
provider: 'pi',
|
||||
path: '/tmp/pi/locked',
|
||||
error: 'EACCES: permission denied',
|
||||
}],
|
||||
}
|
||||
: { deferred: true, reason: 'database_busy', affectedSessionIds: ['session-1'] },
|
||||
stop() {},
|
||||
}),
|
||||
},
|
||||
@@ -338,6 +382,22 @@ test('main process forwards committed IDs without reopening after a deferred bui
|
||||
assert.equal(result.deferred, true);
|
||||
assert.equal(databaseOpens, opensBeforeBuild);
|
||||
assert.equal(notifications, notificationsBeforeBuild + 2);
|
||||
|
||||
const beforeInventoryNotification = notifications;
|
||||
await serviceOptions.buildIndex({ reason: 'inventory' });
|
||||
assert.equal(databaseOpens, opensBeforeBuild);
|
||||
assert.equal(notifications, beforeInventoryNotification + 1);
|
||||
assert.deepEqual(sent.at(-1), {
|
||||
channel: 'obelisk:index-updated',
|
||||
payload: {
|
||||
affectedSessionIds: [],
|
||||
sourceIssues: [{
|
||||
provider: 'pi',
|
||||
path: '/tmp/pi/locked',
|
||||
error: 'EACCES: permission denied',
|
||||
}],
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
restore();
|
||||
process.env.HOME = originalHome;
|
||||
@@ -459,6 +519,39 @@ test('usage IPC aggregates normalized tokens across all indexed providers', asyn
|
||||
input_tokens, output_tokens, source
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run('codex-message', 'codex:session', 'assistant', '2026-07-10T11:00:00Z', 'assistant', 'ok', 100, 10, 'codex');
|
||||
setup.prepare('INSERT INTO sessions (id,source) VALUES (?,?)')
|
||||
.run('pi:session', 'pi');
|
||||
setup.prepare(`
|
||||
INSERT INTO summaries (
|
||||
id, session_id, timestamp, source, content, visibility, input_tokens, output_tokens
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run('pi-summary', 'pi:session', '2026-07-10T12:00:00Z', 'pi:compaction', 'summary', 'inactive', 30, 5);
|
||||
setup.prepare(`
|
||||
INSERT INTO messages (
|
||||
uuid, session_id, type, role, text, timestamp, visibility, source
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run('pi-hidden-main', 'pi:session', 'assistant', 'assistant', 'inactive main', '2026-07-10T12:01:00Z', 'inactive', 'pi');
|
||||
setup.prepare(`
|
||||
INSERT INTO messages (
|
||||
uuid, session_id, type, role, text, timestamp, visibility, source, agent_id
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run('pi-hidden-agent', 'pi:session', 'assistant', 'assistant', 'inactive agent', '2026-07-10T12:02:00Z', 'inactive', 'pi', 'pi:hidden-agent');
|
||||
setup.prepare(`
|
||||
INSERT INTO tool_calls (id, message_uuid, session_id, name, input_json)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`).run('pi-hidden-main-call', 'pi-hidden-main', 'pi:session', 'read', '{"path":"secret"}');
|
||||
setup.prepare(`
|
||||
INSERT INTO tool_calls (id, message_uuid, session_id, name, input_json)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`).run('pi-hidden-agent-call', 'pi-hidden-agent', 'pi:session', 'read', '{"path":"secret"}');
|
||||
setup.prepare(`
|
||||
INSERT INTO tool_results (tool_use_id, message_uuid, session_id, content)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`).run('pi-hidden-main-call', 'pi-hidden-main', 'pi:session', 'hidden result');
|
||||
setup.prepare(`
|
||||
INSERT INTO tool_results (tool_use_id, message_uuid, session_id, content)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`).run('pi-hidden-agent-call', 'pi-hidden-agent', 'pi:session', 'hidden result');
|
||||
setup.close();
|
||||
|
||||
const ipcHandlers = new Map();
|
||||
@@ -495,14 +588,26 @@ test('usage IPC aggregates normalized tokens across all indexed providers', asyn
|
||||
try {
|
||||
await importMain();
|
||||
|
||||
assert.deepEqual(ipcHandlers.get('db:getSessionSummaries')(null, 'pi:session'), []);
|
||||
assert.deepEqual(ipcHandlers.get('db:getSessionMessages')(null, 'pi:session'), []);
|
||||
assert.deepEqual(ipcHandlers.get('db:getSessionToolCalls')(null, 'pi:session'), []);
|
||||
assert.deepEqual(ipcHandlers.get('db:getSessionToolResults')(null, 'pi:session'), []);
|
||||
assert.deepEqual(ipcHandlers.get('db:getSubagentMessages')(null, 'pi:hidden-agent'), []);
|
||||
assert.deepEqual(ipcHandlers.get('db:getSubagentToolCalls')(null, 'pi:hidden-agent'), []);
|
||||
assert.deepEqual(ipcHandlers.get('db:getSubagentToolResults')(null, 'pi:hidden-agent'), []);
|
||||
assert.equal(ipcHandlers.get('db:getMessageFullText')(null, 'pi-hidden-main'), null);
|
||||
|
||||
const claudeOnly = ipcHandlers.get('db:getUsageStats')(null, {});
|
||||
assert.equal(claudeOnly.totalTokens, 65);
|
||||
assert.equal(claudeOnly.daily[0].tokens, 65);
|
||||
|
||||
const allSources = ipcHandlers.get('db:getUsageStats')(null, { source: 'all' });
|
||||
assert.equal(allSources.totalTokens, 175);
|
||||
assert.equal(allSources.daily[0].tokens, 175);
|
||||
assert.equal(allSources.peakDay.tokens, 175);
|
||||
assert.equal(allSources.totalTokens, 210);
|
||||
assert.equal(allSources.daily[0].tokens, 210);
|
||||
assert.equal(allSources.peakDay.tokens, 210);
|
||||
|
||||
const piOnly = ipcHandlers.get('db:getUsageStats')(null, { source: 'pi' });
|
||||
assert.equal(piOnly.totalTokens, 35);
|
||||
} finally {
|
||||
restore();
|
||||
process.env.HOME = originalHome;
|
||||
@@ -793,7 +898,9 @@ test('settings rebuild reopens the database from the configured Claude path', as
|
||||
const openedDbPaths = [];
|
||||
const buildCalls = [];
|
||||
const serviceEvents = [];
|
||||
const sent = [];
|
||||
let competingLeaseDuringBuild;
|
||||
let publishRebuild = false;
|
||||
|
||||
class FakeDatabase {
|
||||
constructor(dbPath) {
|
||||
@@ -810,12 +917,19 @@ test('settings rebuild reopens the database from the configured Claude path', as
|
||||
|
||||
class FakeBrowserWindow {
|
||||
constructor() {
|
||||
this.webContents = { on() {}, setWindowOpenHandler() {}, getURL() { return ''; }, setZoomLevel() {}, openDevTools() {}, send() {} };
|
||||
this.webContents = {
|
||||
on() {},
|
||||
setWindowOpenHandler() {},
|
||||
getURL() { return ''; },
|
||||
setZoomLevel() {},
|
||||
openDevTools() {},
|
||||
send(channel, payload) { sent.push({ channel, payload }); },
|
||||
};
|
||||
}
|
||||
loadFile() {}
|
||||
loadURL() {}
|
||||
close() {}
|
||||
static getAllWindows() { return []; }
|
||||
static getAllWindows() { return [new FakeBrowserWindow()]; }
|
||||
static fromWebContents() { return null; }
|
||||
}
|
||||
|
||||
@@ -856,7 +970,20 @@ test('settings rebuild reopens the database from the configured Claude path', as
|
||||
competingLeaseDuringBuild = Boolean(competingLease);
|
||||
competingLease?.release();
|
||||
writeFileSync(args.dbPath, 'rebuilt temp db');
|
||||
return { files: 2, affectedSessionIds: ['session-1', 'session-2'] };
|
||||
return {
|
||||
files: 2,
|
||||
affectedSessionIds: ['session-1', 'session-2'],
|
||||
complete: publishRebuild,
|
||||
reason: publishRebuild ? undefined : 'incomplete_snapshot',
|
||||
inventoryIssues: [],
|
||||
skippedFiles: publishRebuild
|
||||
? []
|
||||
: [{
|
||||
provider: 'pi',
|
||||
path: '/tmp/pi/structurally-invalid.jsonl',
|
||||
error: 'Malformed Pi message at line 2',
|
||||
}],
|
||||
};
|
||||
},
|
||||
stop() { return Promise.resolve(); },
|
||||
}),
|
||||
@@ -869,19 +996,45 @@ test('settings rebuild reopens the database from the configured Claude path', as
|
||||
|
||||
const rebuild = ipcHandlers.get('settings:rebuildIndex');
|
||||
assert.equal(typeof rebuild, 'function');
|
||||
const liveDbPath = join(home, '.obelisk', 'obelisk.sqlite');
|
||||
const beforeIncomplete = require('node:fs').readFileSync(liveDbPath, 'utf8');
|
||||
const incomplete = await rebuild();
|
||||
assert.equal(incomplete.complete, false);
|
||||
assert.deepEqual(sent.findLast(message => message.channel === 'obelisk:index-updated'), {
|
||||
channel: 'obelisk:index-updated',
|
||||
payload: {
|
||||
affectedSessionIds: ['session-1', 'session-2'],
|
||||
sourceIssues: [{
|
||||
provider: 'pi',
|
||||
path: '/tmp/pi/structurally-invalid.jsonl',
|
||||
error: 'Malformed Pi message at line 2',
|
||||
}],
|
||||
},
|
||||
});
|
||||
assert.equal(
|
||||
require('node:fs').readFileSync(liveDbPath, 'utf8'),
|
||||
beforeIncomplete,
|
||||
'an incomplete temp database must not replace the live database',
|
||||
);
|
||||
|
||||
publishRebuild = true;
|
||||
await rebuild();
|
||||
assert.deepEqual(
|
||||
sent.findLast(message => message.channel === 'obelisk:index-updated').payload.sourceIssues,
|
||||
[],
|
||||
);
|
||||
|
||||
assert.equal(buildCalls.at(-1).claudeDir, customClaudeDir);
|
||||
assert.equal(buildCalls.at(-1).projectsDir, join(customClaudeDir, 'projects'));
|
||||
assert.equal(buildCalls.at(-1).codexDir, customCodexDir);
|
||||
assert.notEqual(buildCalls.at(-1).dbPath, join(home, '.obelisk', 'obelisk.sqlite'));
|
||||
assert.equal(buildCalls.at(-1).preserveDbPath, join(home, '.obelisk', 'obelisk.sqlite'));
|
||||
assert.notEqual(buildCalls.at(-1).dbPath, liveDbPath);
|
||||
assert.equal(buildCalls.at(-1).preserveDbPath, liveDbPath);
|
||||
assert.equal(buildCalls.at(-1).writerLeasePath, join(home, '.obelisk', 'writer.lock.sqlite'));
|
||||
assert.equal(buildCalls.at(-1).writerLeaseMode, 'caller-held');
|
||||
assert.equal(competingLeaseDuringBuild, false);
|
||||
assert.equal(openedDbPaths.at(-1), join(home, '.obelisk', 'obelisk.sqlite'));
|
||||
assert.equal(openedDbPaths.at(-1), liveDbPath);
|
||||
assert.equal(
|
||||
require('node:fs').readFileSync(join(home, '.obelisk', 'obelisk.sqlite'), 'utf8'),
|
||||
require('node:fs').readFileSync(liveDbPath, 'utf8'),
|
||||
'rebuilt temp db',
|
||||
);
|
||||
assert.ok(serviceEvents.indexOf('build') > serviceEvents.indexOf('stop'));
|
||||
@@ -1081,7 +1234,7 @@ test('settings rebuild cancels an in-flight background build instead of waiting
|
||||
buildIndex: async (args) => {
|
||||
serviceEvents.push(`build-${++buildIndexCalls}`);
|
||||
writeFileSync(args.dbPath, 'rebuilt temp db');
|
||||
return { files: 2, affectedSessionIds: [] };
|
||||
return { files: 2, affectedSessionIds: [], complete: true };
|
||||
},
|
||||
stop() {
|
||||
serviceEvents.push('worker-stop');
|
||||
|
||||
@@ -0,0 +1,650 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createRequire } from 'node:module';
|
||||
import {
|
||||
chmodSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
readdirSync,
|
||||
renameSync,
|
||||
unlinkSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { dirname, join } from 'node:path';
|
||||
|
||||
import { buildIndex } from '../app/src/main/indexer.ts';
|
||||
import {
|
||||
createPiProvider,
|
||||
piSessionId,
|
||||
PI_CANONICAL_TRANSCRIPT_MARKER,
|
||||
} from '../packages/core/src/providers/pi.ts';
|
||||
import { createProviderRegistry } from '../packages/core/src/providers/registry.ts';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const { DatabaseSync } = require('node:sqlite');
|
||||
const TOOL_FIXTURE = new URL('./fixtures/pi/tool-session.jsonl', import.meta.url);
|
||||
|
||||
class TestDatabase {
|
||||
constructor(dbPath) {
|
||||
this.db = new DatabaseSync(dbPath);
|
||||
}
|
||||
|
||||
pragma(statement) { this.db.exec(`PRAGMA ${statement}`); }
|
||||
exec(sql) { return this.db.exec(sql); }
|
||||
prepare(sql) { return this.db.prepare(sql); }
|
||||
close() { return this.db.close(); }
|
||||
}
|
||||
|
||||
class TransactionAwareTestDatabase extends TestDatabase {
|
||||
get inTransaction() { return this.db.isTransaction; }
|
||||
}
|
||||
|
||||
function writeFixture(piDir) {
|
||||
const sessionPath = join(piDir, '--tmp-pi-real-tool--', 'session.jsonl');
|
||||
mkdirSync(dirname(sessionPath), { recursive: true });
|
||||
writeFileSync(sessionPath, readFileSync(TOOL_FIXTURE));
|
||||
return sessionPath;
|
||||
}
|
||||
|
||||
function invalidMessageLine(id = 'invalid-message') {
|
||||
return JSON.stringify({
|
||||
type: 'message',
|
||||
id,
|
||||
parentId: null,
|
||||
timestamp: '2026-08-02T10:00:01.000Z',
|
||||
message: null,
|
||||
});
|
||||
}
|
||||
|
||||
function indexOptions(home, piDir) {
|
||||
return {
|
||||
claudeDir: join(home, '.claude'),
|
||||
codexDir: join(home, '.codex'),
|
||||
providerRoots: { pi: piDir },
|
||||
dbPath: join(home, '.obelisk', 'obelisk.sqlite'),
|
||||
DatabaseImpl: TestDatabase,
|
||||
};
|
||||
}
|
||||
|
||||
test('app build indexes Pi through the registry and replays complete session snapshots', () => {
|
||||
const home = mkdtempSync(join(tmpdir(), 'obelisk-pi-index-'));
|
||||
const piDir = join(home, 'pi-sessions');
|
||||
const sessionPath = writeFixture(piDir);
|
||||
const options = indexOptions(home, piDir);
|
||||
const provider = createPiProvider({ rootDir: piDir });
|
||||
const sessionId = provider.discover({ lastCursor: () => null })[0].sessionId;
|
||||
|
||||
const first = buildIndex(options);
|
||||
assert.deepEqual(first.affectedSessionIds, [sessionId]);
|
||||
|
||||
let db = new TestDatabase(options.dbPath);
|
||||
assert.deepEqual(
|
||||
db.prepare('SELECT id,title,source,message_count FROM sessions').all().map(row => ({ ...row })),
|
||||
[{ id: sessionId, title: 'Tool probe', source: 'pi', message_count: 4 }],
|
||||
);
|
||||
assert.equal(
|
||||
db.prepare("SELECT text FROM messages WHERE source='pi' AND role='assistant' AND content_type='text'").get().text,
|
||||
'The read tool returned real-pi-tool-result.',
|
||||
);
|
||||
assert.deepEqual(
|
||||
{ ...db.prepare("SELECT name,file_path FROM tool_calls WHERE session_id=?").get(sessionId) },
|
||||
{ name: 'read', file_path: 'probe.txt' },
|
||||
);
|
||||
const schema = db.prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='messages'").get().sql;
|
||||
assert.doesNotMatch(schema, /\bpi\b/i);
|
||||
db.close();
|
||||
|
||||
const header = readFileSync(TOOL_FIXTURE, 'utf8').split('\n')[0];
|
||||
writeFileSync(sessionPath, `${header}\n`);
|
||||
const replay = buildIndex({ ...options, changedPaths: [sessionPath] });
|
||||
assert.deepEqual(replay.affectedSessionIds, [sessionId]);
|
||||
|
||||
db = new TestDatabase(options.dbPath);
|
||||
assert.equal(db.prepare("SELECT COUNT(*) AS c FROM messages WHERE source='pi'").get().c, 0);
|
||||
assert.equal(db.prepare('SELECT message_count FROM sessions WHERE id=?').get(sessionId).message_count, 0);
|
||||
db.close();
|
||||
});
|
||||
|
||||
test('an unreadable Pi directory does not block readable sessions on a fresh index', {
|
||||
skip: process.platform === 'win32',
|
||||
}, (t) => {
|
||||
const home = mkdtempSync(join(tmpdir(), 'obelisk-pi-partial-inventory-'));
|
||||
const piDir = join(home, 'pi-sessions');
|
||||
writeFixture(piDir);
|
||||
const lockedDir = join(piDir, 'locked');
|
||||
mkdirSync(lockedDir, { recursive: true });
|
||||
chmodSync(lockedDir, 0o000);
|
||||
|
||||
try {
|
||||
try {
|
||||
readdirSync(lockedDir);
|
||||
t.skip('current user can read mode-000 directories');
|
||||
return;
|
||||
} catch {}
|
||||
|
||||
const options = indexOptions(home, piDir);
|
||||
const first = buildIndex(options);
|
||||
assert.equal(first.complete, false);
|
||||
assert.equal(first.files, 1);
|
||||
assert.deepEqual(first.incompleteProviders, ['pi']);
|
||||
assert.ok(first.inventoryIssues.some((issue) => (
|
||||
issue.provider === 'pi' && issue.path === lockedDir
|
||||
)));
|
||||
|
||||
const db = new TestDatabase(options.dbPath);
|
||||
assert.equal(db.prepare("SELECT COUNT(*) AS c FROM sessions WHERE source='pi'").get().c, 1);
|
||||
assert.equal(
|
||||
db.prepare('SELECT COUNT(*) AS c FROM index_state WHERE jsonl_path=?')
|
||||
.get(PI_CANONICAL_TRANSCRIPT_MARKER).c,
|
||||
0,
|
||||
);
|
||||
db.close();
|
||||
} finally {
|
||||
chmodSync(lockedDir, 0o700);
|
||||
}
|
||||
|
||||
const recovered = buildIndex(indexOptions(home, piDir));
|
||||
assert.equal(recovered.complete, true);
|
||||
const db = new TestDatabase(join(home, '.obelisk', 'obelisk.sqlite'));
|
||||
assert.equal(
|
||||
db.prepare('SELECT COUNT(*) AS c FROM index_state WHERE jsonl_path=?')
|
||||
.get(PI_CANONICAL_TRANSCRIPT_MARKER).c,
|
||||
1,
|
||||
);
|
||||
db.close();
|
||||
});
|
||||
|
||||
test('an incomplete Pi identity census preserves committed provenance over a readable copy', {
|
||||
skip: process.platform === 'win32',
|
||||
}, (t) => {
|
||||
const home = mkdtempSync(join(tmpdir(), 'obelisk-pi-partial-copy-'));
|
||||
const piDir = join(home, 'pi-sessions');
|
||||
const lockedDir = join(piDir, 'locked');
|
||||
const committedPath = join(lockedDir, 'session.jsonl');
|
||||
mkdirSync(lockedDir, { recursive: true });
|
||||
writeFileSync(committedPath, readFileSync(TOOL_FIXTURE));
|
||||
const options = indexOptions(home, piDir);
|
||||
assert.equal(buildIndex(options).complete, true);
|
||||
|
||||
let db = new TestDatabase(options.dbPath);
|
||||
const before = {
|
||||
session: {
|
||||
...db.prepare("SELECT id,title,message_count,jsonl_path FROM sessions WHERE source='pi'").get(),
|
||||
},
|
||||
messages: db.prepare(
|
||||
"SELECT uuid,text,visibility FROM messages WHERE source='pi' ORDER BY uuid",
|
||||
).all().map(row => ({ ...row })),
|
||||
};
|
||||
db.close();
|
||||
|
||||
const readablePath = join(piDir, 'readable', 'session.jsonl');
|
||||
mkdirSync(dirname(readablePath), { recursive: true });
|
||||
writeFileSync(
|
||||
readablePath,
|
||||
readFileSync(TOOL_FIXTURE, 'utf8').replaceAll(
|
||||
'real-pi-tool-result',
|
||||
'UNVERIFIED NEW',
|
||||
),
|
||||
);
|
||||
chmodSync(lockedDir, 0o000);
|
||||
|
||||
try {
|
||||
try {
|
||||
readdirSync(lockedDir);
|
||||
t.skip('current user can read mode-000 directories');
|
||||
return;
|
||||
} catch {}
|
||||
|
||||
const partial = buildIndex(options);
|
||||
assert.equal(partial.complete, false);
|
||||
assert.equal(partial.files, 0);
|
||||
assert.deepEqual(partial.affectedSessionIds, []);
|
||||
assert.ok(partial.inventoryIssues.some((issue) => (
|
||||
issue.provider === 'pi' && issue.path === lockedDir
|
||||
)));
|
||||
|
||||
db = new TestDatabase(options.dbPath);
|
||||
const after = {
|
||||
session: {
|
||||
...db.prepare("SELECT id,title,message_count,jsonl_path FROM sessions WHERE source='pi'").get(),
|
||||
},
|
||||
messages: db.prepare(
|
||||
"SELECT uuid,text,visibility FROM messages WHERE source='pi' ORDER BY uuid",
|
||||
).all().map(row => ({ ...row })),
|
||||
};
|
||||
assert.deepEqual(after, before);
|
||||
assert.equal(
|
||||
db.prepare('SELECT COUNT(*) AS c FROM index_state WHERE jsonl_path=?')
|
||||
.get(readablePath).c,
|
||||
0,
|
||||
);
|
||||
db.close();
|
||||
} finally {
|
||||
chmodSync(lockedDir, 0o700);
|
||||
}
|
||||
});
|
||||
|
||||
test('Pi canonical marker forces one provider-owned replay after projection changes', () => {
|
||||
const home = mkdtempSync(join(tmpdir(), 'obelisk-pi-marker-'));
|
||||
const piDir = join(home, 'pi-sessions');
|
||||
writeFixture(piDir);
|
||||
const options = indexOptions(home, piDir);
|
||||
const sessionId = createPiProvider({ rootDir: piDir })
|
||||
.discover({ lastCursor: () => null })[0].sessionId;
|
||||
|
||||
buildIndex(options);
|
||||
let db = new TestDatabase(options.dbPath);
|
||||
db.prepare("UPDATE messages SET text='stale Pi projection' WHERE source='pi'").run();
|
||||
db.prepare('DELETE FROM index_state WHERE jsonl_path=?').run(PI_CANONICAL_TRANSCRIPT_MARKER);
|
||||
db.close();
|
||||
|
||||
const replay = buildIndex(options);
|
||||
assert.deepEqual(replay.affectedSessionIds, [sessionId]);
|
||||
db = new TestDatabase(options.dbPath);
|
||||
assert.equal(
|
||||
db.prepare("SELECT COUNT(*) AS c FROM messages WHERE source='pi' AND text='stale Pi projection'").get().c,
|
||||
0,
|
||||
);
|
||||
assert.equal(
|
||||
db.prepare('SELECT COUNT(*) AS c FROM index_state WHERE jsonl_path=?')
|
||||
.get(PI_CANONICAL_TRANSCRIPT_MARKER).c,
|
||||
1,
|
||||
);
|
||||
db.close();
|
||||
});
|
||||
|
||||
test('a structurally invalid Pi file retries alone after a canonical replay', () => {
|
||||
const home = mkdtempSync(join(tmpdir(), 'obelisk-pi-marker-retry-'));
|
||||
const piDir = join(home, 'pi-sessions');
|
||||
const validPath = writeFixture(piDir);
|
||||
const options = {
|
||||
...indexOptions(home, piDir),
|
||||
DatabaseImpl: TransactionAwareTestDatabase,
|
||||
};
|
||||
const provider = createPiProvider({ rootDir: piDir });
|
||||
const parseCalls = new Map();
|
||||
const countedProvider = {
|
||||
...provider,
|
||||
parse(unit, cursor) {
|
||||
parseCalls.set(unit.key, (parseCalls.get(unit.key) ?? 0) + 1);
|
||||
return provider.parse(unit, cursor);
|
||||
},
|
||||
};
|
||||
const providerRegistry = createProviderRegistry([countedProvider]);
|
||||
|
||||
buildIndex({ ...options, providerRegistry });
|
||||
const badPath = join(piDir, '--tmp-pi-bad--', 'session.jsonl');
|
||||
mkdirSync(dirname(badPath), { recursive: true });
|
||||
writeFileSync(badPath, [
|
||||
JSON.stringify({
|
||||
type: 'session',
|
||||
version: 3,
|
||||
id: 'permanently-bad',
|
||||
timestamp: '2026-08-02T10:00:00.000Z',
|
||||
cwd: '/tmp/pi-bad',
|
||||
}),
|
||||
invalidMessageLine('permanently-bad-message'),
|
||||
'',
|
||||
].join('\n'));
|
||||
|
||||
let db = new TransactionAwareTestDatabase(options.dbPath);
|
||||
db.prepare('DELETE FROM index_state WHERE jsonl_path=?').run(PI_CANONICAL_TRANSCRIPT_MARKER);
|
||||
db.close();
|
||||
parseCalls.clear();
|
||||
|
||||
const replay = buildIndex({ ...options, providerRegistry });
|
||||
assert.equal(replay.files, 2);
|
||||
assert.equal(replay.skipped, 1);
|
||||
assert.equal(parseCalls.get(validPath), 1);
|
||||
assert.equal(parseCalls.get(badPath), 1);
|
||||
|
||||
db = new TransactionAwareTestDatabase(options.dbPath);
|
||||
assert.equal(
|
||||
db.prepare('SELECT COUNT(*) AS c FROM index_state WHERE jsonl_path=?')
|
||||
.get(PI_CANONICAL_TRANSCRIPT_MARKER).c,
|
||||
1,
|
||||
);
|
||||
assert.equal(
|
||||
db.prepare('SELECT COUNT(*) AS c FROM index_state WHERE jsonl_path=?').get(badPath).c,
|
||||
0,
|
||||
);
|
||||
db.close();
|
||||
|
||||
const retry = buildIndex({ ...options, providerRegistry });
|
||||
assert.equal(retry.files, 1);
|
||||
assert.equal(retry.skipped, 1);
|
||||
assert.equal(parseCalls.get(validPath), 1, 'the successful session must not replay again');
|
||||
assert.equal(parseCalls.get(badPath), 2, 'only the failed session remains retryable');
|
||||
});
|
||||
|
||||
test('a failed Pi unit rolls back a force rebuild to the last complete snapshot', () => {
|
||||
const home = mkdtempSync(join(tmpdir(), 'obelisk-pi-force-rollback-'));
|
||||
const piDir = join(home, 'pi-sessions');
|
||||
const sessionPath = writeFixture(piDir);
|
||||
const options = {
|
||||
...indexOptions(home, piDir),
|
||||
DatabaseImpl: TransactionAwareTestDatabase,
|
||||
};
|
||||
assert.equal(buildIndex(options).complete, true);
|
||||
|
||||
let db = new TransactionAwareTestDatabase(options.dbPath);
|
||||
const before = {
|
||||
session: { ...db.prepare("SELECT id,title,message_count,jsonl_path FROM sessions WHERE source='pi'").get() },
|
||||
messages: db.prepare("SELECT uuid,text,content_type FROM messages WHERE source='pi' ORDER BY uuid").all()
|
||||
.map(row => ({ ...row })),
|
||||
cursor: { ...db.prepare('SELECT mtime,lines_processed,cursor FROM index_state WHERE jsonl_path=?').get(sessionPath) },
|
||||
lastBuild: { ...db.prepare("SELECT mtime,lines_processed FROM index_state WHERE jsonl_path='__last_build__'").get() },
|
||||
};
|
||||
db.close();
|
||||
|
||||
const fixtureHeader = readFileSync(TOOL_FIXTURE, 'utf8').split('\n')[0];
|
||||
writeFileSync(sessionPath, `${fixtureHeader}\n${invalidMessageLine()}\n`);
|
||||
const failed = buildIndex({ ...options, force: true });
|
||||
assert.equal(failed.complete, false);
|
||||
assert.equal(failed.reason, 'provider_failure');
|
||||
assert.deepEqual(failed.affectedSessionIds, []);
|
||||
assert.equal(failed.skippedFiles[0].provider, 'pi');
|
||||
assert.equal(failed.skippedFiles[0].path, sessionPath);
|
||||
assert.match(failed.skippedFiles[0].error, /Malformed Pi message at line 2/);
|
||||
|
||||
db = new TransactionAwareTestDatabase(options.dbPath);
|
||||
const after = {
|
||||
session: { ...db.prepare("SELECT id,title,message_count,jsonl_path FROM sessions WHERE source='pi'").get() },
|
||||
messages: db.prepare("SELECT uuid,text,content_type FROM messages WHERE source='pi' ORDER BY uuid").all()
|
||||
.map(row => ({ ...row })),
|
||||
cursor: { ...db.prepare('SELECT mtime,lines_processed,cursor FROM index_state WHERE jsonl_path=?').get(sessionPath) },
|
||||
lastBuild: { ...db.prepare("SELECT mtime,lines_processed FROM index_state WHERE jsonl_path='__last_build__'").get() },
|
||||
};
|
||||
db.close();
|
||||
assert.deepEqual(after, before);
|
||||
});
|
||||
|
||||
test('a temp force rebuild uses live Pi provenance before publishing an empty snapshot', () => {
|
||||
const home = mkdtempSync(join(tmpdir(), 'obelisk-pi-temp-provenance-'));
|
||||
const piDir = join(home, 'pi-sessions');
|
||||
writeFixture(piDir);
|
||||
const options = indexOptions(home, piDir);
|
||||
assert.equal(buildIndex(options).complete, true);
|
||||
|
||||
const unavailableDir = `${piDir}.unavailable`;
|
||||
renameSync(piDir, unavailableDir);
|
||||
const tempDbPath = join(home, '.obelisk', 'obelisk.rebuild.tmp');
|
||||
const rebuilt = buildIndex({
|
||||
...options,
|
||||
dbPath: tempDbPath,
|
||||
preserveDbPath: options.dbPath,
|
||||
force: true,
|
||||
});
|
||||
assert.equal(rebuilt.complete, false);
|
||||
assert.equal(rebuilt.reason, 'incomplete_snapshot');
|
||||
assert.deepEqual(rebuilt.incompleteProviders, ['pi']);
|
||||
|
||||
const live = new TestDatabase(options.dbPath);
|
||||
assert.equal(live.prepare("SELECT COUNT(*) AS c FROM sessions WHERE source='pi'").get().c, 1);
|
||||
live.close();
|
||||
renameSync(unavailableDir, piDir);
|
||||
});
|
||||
|
||||
test('an unavailable Pi root keeps replay pending on the missing session cursor', () => {
|
||||
const home = mkdtempSync(join(tmpdir(), 'obelisk-pi-incomplete-marker-'));
|
||||
const piDir = join(home, 'pi-sessions');
|
||||
const sessionPath = writeFixture(piDir);
|
||||
const options = indexOptions(home, piDir);
|
||||
|
||||
buildIndex(options);
|
||||
let db = new TestDatabase(options.dbPath);
|
||||
db.prepare(`
|
||||
UPDATE messages
|
||||
SET text='stale Pi projection'
|
||||
WHERE uuid = (
|
||||
SELECT uuid FROM messages WHERE source='pi' ORDER BY uuid LIMIT 1
|
||||
)
|
||||
`).run();
|
||||
db.prepare('DELETE FROM index_state WHERE jsonl_path=?').run(PI_CANONICAL_TRANSCRIPT_MARKER);
|
||||
db.close();
|
||||
|
||||
const unavailableDir = `${piDir}.unavailable`;
|
||||
renameSync(piDir, unavailableDir);
|
||||
const forced = buildIndex({ ...options, force: true });
|
||||
assert.equal(forced.complete, false);
|
||||
assert.equal(forced.reason, 'incomplete_snapshot');
|
||||
assert.deepEqual(forced.incompleteProviders, ['pi']);
|
||||
const unavailable = buildIndex(options);
|
||||
assert.equal(unavailable.files, 0);
|
||||
|
||||
db = new TestDatabase(options.dbPath);
|
||||
assert.equal(
|
||||
db.prepare('SELECT COUNT(*) AS c FROM index_state WHERE jsonl_path=?')
|
||||
.get(PI_CANONICAL_TRANSCRIPT_MARKER).c,
|
||||
1,
|
||||
);
|
||||
assert.equal(
|
||||
db.prepare('SELECT COUNT(*) AS c FROM index_state WHERE jsonl_path=?')
|
||||
.get(sessionPath).c,
|
||||
0,
|
||||
);
|
||||
assert.equal(
|
||||
db.prepare("SELECT COUNT(*) AS c FROM messages WHERE text='stale Pi projection'").get().c,
|
||||
1,
|
||||
);
|
||||
db.close();
|
||||
|
||||
renameSync(unavailableDir, piDir);
|
||||
const replay = buildIndex(options);
|
||||
assert.equal(replay.files, 1);
|
||||
db = new TestDatabase(options.dbPath);
|
||||
assert.equal(
|
||||
db.prepare('SELECT COUNT(*) AS c FROM index_state WHERE jsonl_path=?')
|
||||
.get(PI_CANONICAL_TRANSCRIPT_MARKER).c,
|
||||
1,
|
||||
);
|
||||
assert.equal(
|
||||
db.prepare("SELECT COUNT(*) AS c FROM messages WHERE text='stale Pi projection'").get().c,
|
||||
0,
|
||||
);
|
||||
db.close();
|
||||
});
|
||||
|
||||
test('an unresolved Pi root keeps replay pending on the unresolved session cursor', () => {
|
||||
const home = mkdtempSync(join(tmpdir(), 'obelisk-pi-unresolved-marker-'));
|
||||
const piDir = join(home, 'pi-sessions');
|
||||
const sessionPath = writeFixture(piDir);
|
||||
const options = indexOptions(home, piDir);
|
||||
|
||||
buildIndex(options);
|
||||
let db = new TestDatabase(options.dbPath);
|
||||
db.prepare("UPDATE messages SET text='stale unresolved projection' WHERE source='pi'").run();
|
||||
db.prepare('DELETE FROM index_state WHERE jsonl_path=?').run(PI_CANONICAL_TRANSCRIPT_MARKER);
|
||||
db.close();
|
||||
|
||||
const unresolvedProvider = createPiProvider({ rootDir: 'relative-session-root' });
|
||||
assert.equal(unresolvedProvider.rootResolution.requiresExplicitRoot, true);
|
||||
const unresolved = buildIndex({
|
||||
...options,
|
||||
providerRegistry: createProviderRegistry([unresolvedProvider]),
|
||||
});
|
||||
assert.equal(unresolved.files, 0);
|
||||
|
||||
db = new TestDatabase(options.dbPath);
|
||||
assert.equal(
|
||||
db.prepare('SELECT COUNT(*) AS c FROM index_state WHERE jsonl_path=?')
|
||||
.get(PI_CANONICAL_TRANSCRIPT_MARKER).c,
|
||||
1,
|
||||
);
|
||||
assert.equal(
|
||||
db.prepare('SELECT COUNT(*) AS c FROM index_state WHERE jsonl_path=?')
|
||||
.get(sessionPath).c,
|
||||
0,
|
||||
);
|
||||
assert.ok(
|
||||
db.prepare("SELECT COUNT(*) AS c FROM messages WHERE text='stale unresolved projection'").get().c > 0,
|
||||
);
|
||||
db.close();
|
||||
|
||||
const replay = buildIndex(options);
|
||||
assert.equal(replay.files, 1);
|
||||
db = new TestDatabase(options.dbPath);
|
||||
assert.equal(
|
||||
db.prepare('SELECT COUNT(*) AS c FROM index_state WHERE jsonl_path=?')
|
||||
.get(PI_CANONICAL_TRANSCRIPT_MARKER).c,
|
||||
1,
|
||||
);
|
||||
assert.equal(
|
||||
db.prepare("SELECT COUNT(*) AS c FROM messages WHERE text='stale unresolved projection'").get().c,
|
||||
0,
|
||||
);
|
||||
db.close();
|
||||
});
|
||||
|
||||
test('Pi identity marker retracts a legacy id through a non-selected identical copy', () => {
|
||||
const home = mkdtempSync(join(tmpdir(), 'obelisk-pi-identity-marker-'));
|
||||
const piDir = join(home, 'pi-sessions');
|
||||
const selectedPath = writeFixture(piDir);
|
||||
const copiedPath = join(piDir, 'z-copy', 'session.jsonl');
|
||||
mkdirSync(dirname(copiedPath), { recursive: true });
|
||||
writeFileSync(copiedPath, readFileSync(TOOL_FIXTURE));
|
||||
const options = indexOptions(home, piDir);
|
||||
const sourceHeader = JSON.parse(readFileSync(TOOL_FIXTURE, 'utf8').split('\n')[0]);
|
||||
const legacyId = `pi:${sourceHeader.id}`;
|
||||
const currentId = piSessionId(sourceHeader);
|
||||
|
||||
buildIndex(options);
|
||||
let db = new TestDatabase(options.dbPath);
|
||||
db.prepare("UPDATE sessions SET id=?, jsonl_path=? WHERE source='pi'").run(legacyId, copiedPath);
|
||||
for (const table of ['messages', 'tool_calls', 'tool_results', 'summaries']) {
|
||||
db.prepare(`UPDATE ${table} SET session_id=? WHERE session_id=?`).run(legacyId, currentId);
|
||||
}
|
||||
db.prepare('UPDATE index_state SET jsonl_path=? WHERE jsonl_path=?').run(copiedPath, selectedPath);
|
||||
db.prepare('DELETE FROM index_state WHERE jsonl_path=?').run(PI_CANONICAL_TRANSCRIPT_MARKER);
|
||||
db.close();
|
||||
|
||||
const replay = buildIndex(options);
|
||||
assert.deepEqual(new Set(replay.affectedSessionIds), new Set([legacyId, currentId]));
|
||||
db = new TestDatabase(options.dbPath);
|
||||
assert.deepEqual(
|
||||
db.prepare("SELECT id,jsonl_path FROM sessions WHERE source='pi'").all().map(row => ({ ...row })),
|
||||
[{ id: currentId, jsonl_path: selectedPath }],
|
||||
);
|
||||
db.close();
|
||||
});
|
||||
|
||||
test('app replay keeps Pi identity stable across migration and retracts replacement and unlink', () => {
|
||||
const home = mkdtempSync(join(tmpdir(), 'obelisk-pi-provenance-'));
|
||||
const piDir = join(home, 'pi-sessions');
|
||||
const sessionPath = writeFixture(piDir);
|
||||
const options = indexOptions(home, piDir);
|
||||
const original = readFileSync(sessionPath, 'utf8');
|
||||
const records = original.trimEnd().split('\n').map(line => JSON.parse(line));
|
||||
|
||||
buildIndex(options);
|
||||
const stableId = piSessionId(records[0]);
|
||||
|
||||
records[0].version = 2;
|
||||
writeFileSync(sessionPath, records.map(record => JSON.stringify(record)).join('\n') + '\n');
|
||||
buildIndex({ ...options, changedPaths: [sessionPath] });
|
||||
let db = new TestDatabase(options.dbPath);
|
||||
assert.deepEqual(
|
||||
db.prepare("SELECT id FROM sessions WHERE source='pi'").all().map(row => row.id),
|
||||
[stableId],
|
||||
);
|
||||
db.close();
|
||||
|
||||
records[0].version = 3;
|
||||
records[0].id = 'replacement-session';
|
||||
const replacementId = piSessionId(records[0]);
|
||||
writeFileSync(sessionPath, records.map(record => JSON.stringify(record)).join('\n') + '\n');
|
||||
const replacement = buildIndex({ ...options, changedPaths: [sessionPath] });
|
||||
assert.deepEqual(
|
||||
new Set(replacement.affectedSessionIds),
|
||||
new Set([stableId, replacementId]),
|
||||
);
|
||||
db = new TestDatabase(options.dbPath);
|
||||
assert.deepEqual(
|
||||
db.prepare("SELECT id FROM sessions WHERE source='pi'").all().map(row => row.id),
|
||||
[replacementId],
|
||||
);
|
||||
db.close();
|
||||
|
||||
unlinkSync(sessionPath);
|
||||
const removed = buildIndex({ ...options, changedPaths: [sessionPath] });
|
||||
assert.deepEqual(removed.affectedSessionIds, [replacementId]);
|
||||
db = new TestDatabase(options.dbPath);
|
||||
assert.equal(db.prepare("SELECT COUNT(*) AS c FROM sessions WHERE source='pi'").get().c, 0);
|
||||
assert.equal(
|
||||
db.prepare('SELECT mtime FROM index_state WHERE jsonl_path=?').get(sessionPath).mtime,
|
||||
0,
|
||||
);
|
||||
db.close();
|
||||
});
|
||||
|
||||
test('passive Pi inventory retracts deleted sessions when its configured root remains readable', () => {
|
||||
const home = mkdtempSync(join(tmpdir(), 'obelisk-pi-passive-delete-'));
|
||||
const piDir = join(home, 'pi-sessions');
|
||||
const sessionPath = writeFixture(piDir);
|
||||
const options = indexOptions(home, piDir);
|
||||
|
||||
buildIndex(options);
|
||||
unlinkSync(sessionPath);
|
||||
const removed = buildIndex(options);
|
||||
|
||||
assert.equal(removed.files, 1);
|
||||
const db = new TestDatabase(options.dbPath);
|
||||
assert.equal(db.prepare("SELECT COUNT(*) AS c FROM sessions WHERE source='pi'").get().c, 0);
|
||||
db.close();
|
||||
});
|
||||
|
||||
test('a terminal malformed line follows Pi by publishing the valid replacement prefix', () => {
|
||||
const home = mkdtempSync(join(tmpdir(), 'obelisk-pi-torn-replacement-'));
|
||||
const piDir = join(home, 'pi-sessions');
|
||||
const sessionPath = writeFixture(piDir);
|
||||
const options = indexOptions(home, piDir);
|
||||
const records = readFileSync(TOOL_FIXTURE, 'utf8')
|
||||
.trimEnd()
|
||||
.split('\n')
|
||||
.map(line => JSON.parse(line));
|
||||
const oldId = piSessionId(records[0]);
|
||||
|
||||
buildIndex(options);
|
||||
let db = new TestDatabase(options.dbPath);
|
||||
assert.equal(
|
||||
db.prepare("SELECT id FROM sessions WHERE source='pi'").get().id,
|
||||
oldId,
|
||||
);
|
||||
const committedCursor = db.prepare(
|
||||
'SELECT cursor FROM index_state WHERE jsonl_path=?',
|
||||
).get(sessionPath).cursor;
|
||||
db.close();
|
||||
|
||||
const replacementHeader = { ...records[0], id: 'torn-replacement' };
|
||||
const replacementId = piSessionId(replacementHeader);
|
||||
writeFileSync(sessionPath, `${JSON.stringify(replacementHeader)}\n{"type":"message"`);
|
||||
const prefix = buildIndex({ ...options, changedPaths: [sessionPath] });
|
||||
assert.deepEqual(
|
||||
new Set(prefix.affectedSessionIds),
|
||||
new Set([oldId, replacementId]),
|
||||
);
|
||||
|
||||
db = new TestDatabase(options.dbPath);
|
||||
assert.deepEqual(
|
||||
db.prepare("SELECT id,message_count FROM sessions WHERE source='pi'").all().map(row => ({ ...row })),
|
||||
[{ id: replacementId, message_count: 0 }],
|
||||
);
|
||||
assert.notEqual(
|
||||
db.prepare('SELECT cursor FROM index_state WHERE jsonl_path=?').get(sessionPath).cursor,
|
||||
committedCursor,
|
||||
);
|
||||
db.close();
|
||||
|
||||
records[0] = replacementHeader;
|
||||
writeFileSync(sessionPath, `${records.map(record => JSON.stringify(record)).join('\n')}\n`);
|
||||
const completed = buildIndex({ ...options, changedPaths: [sessionPath] });
|
||||
assert.deepEqual(completed.affectedSessionIds, [replacementId]);
|
||||
db = new TestDatabase(options.dbPath);
|
||||
assert.deepEqual(
|
||||
db.prepare("SELECT id,message_count FROM sessions WHERE source='pi'").all().map(row => ({ ...row })),
|
||||
[{ id: replacementId, message_count: 4 }],
|
||||
);
|
||||
db.close();
|
||||
});
|
||||
@@ -77,3 +77,272 @@ test('app indexer persists every provider through one registry-driven loop', ()
|
||||
assert.deepEqual(second.affectedSessionIds, []);
|
||||
assert.equal(second.files, 0);
|
||||
});
|
||||
|
||||
test('serialized invalid provider settings stay disabled when the worker rebuilds the registry', () => {
|
||||
const home = mkdtempSync(join(tmpdir(), 'obelisk-provider-settings-worker-'));
|
||||
const result = buildIndex({
|
||||
providerSettings: {
|
||||
providerRoots: {
|
||||
claude: './claude',
|
||||
codex: './codex',
|
||||
kimi: './kimi',
|
||||
},
|
||||
},
|
||||
providerRoots: { kimi: join(home, '.kimi-code') },
|
||||
claudeDir: join(home, '.claude'),
|
||||
codexDir: join(home, '.codex'),
|
||||
dbPath: join(home, '.obelisk', 'obelisk.sqlite'),
|
||||
DatabaseImpl: TestDatabase,
|
||||
});
|
||||
|
||||
assert.equal(result.files, 0);
|
||||
assert.equal(result.complete, false);
|
||||
assert.deepEqual(result.incompleteProviders, ['claude', 'codex', 'kimi']);
|
||||
});
|
||||
|
||||
test('an incomplete canonical inventory converges without replaying readable units forever', () => {
|
||||
const home = mkdtempSync(join(tmpdir(), 'obelisk-provider-incomplete-replay-'));
|
||||
const dbPath = join(home, '.obelisk', 'obelisk.sqlite');
|
||||
const marker = '__alpha_canonical_v1__';
|
||||
let incomplete = true;
|
||||
let parseCalls = 0;
|
||||
const provider = {
|
||||
name: 'alpha',
|
||||
descriptor: { id: 'alpha', name: 'Alpha', vendor: 'Test', defaultRoot: '/alpha', color: '#123456' },
|
||||
indexVersionMarker: marker,
|
||||
watchRoots: () => [],
|
||||
discover(ctx) {
|
||||
if (incomplete) {
|
||||
ctx.reportIncompleteInventory({
|
||||
path: '/alpha/locked',
|
||||
error: 'EACCES: permission denied',
|
||||
});
|
||||
}
|
||||
return ctx.lastCursor('alpha:unit') === '10:1'
|
||||
? []
|
||||
: [{
|
||||
key: 'alpha:unit',
|
||||
sessionId: 'alpha:session',
|
||||
}];
|
||||
},
|
||||
*parse(unit) {
|
||||
parseCalls += 1;
|
||||
yield {
|
||||
kind: 'session', id: unit.sessionId, title: 'Alpha', project: null,
|
||||
started_at: null, ended_at: null, git_branch: null, version: null,
|
||||
message_count: 0, countMode: 'total', jsonl_path: unit.key, source: 'alpha',
|
||||
};
|
||||
return '10:1';
|
||||
},
|
||||
raw: () => null,
|
||||
};
|
||||
const options = {
|
||||
providerRegistry: createProviderRegistry([provider]),
|
||||
dbPath,
|
||||
DatabaseImpl: TestDatabase,
|
||||
};
|
||||
|
||||
const first = buildIndex(options);
|
||||
const second = buildIndex(options);
|
||||
assert.equal(first.complete, false);
|
||||
assert.equal(second.complete, false);
|
||||
assert.deepEqual(first.incompleteProviders, ['alpha']);
|
||||
assert.deepEqual(first.inventoryIssues, [{
|
||||
provider: 'alpha',
|
||||
path: '/alpha/locked',
|
||||
error: 'EACCES: permission denied',
|
||||
}]);
|
||||
assert.equal(first.files, 1);
|
||||
assert.equal(second.files, 1);
|
||||
assert.equal(parseCalls, 2, 'readable units remain available while certification retries');
|
||||
|
||||
const forced = buildIndex({ ...options, force: true });
|
||||
assert.equal(forced.complete, false);
|
||||
assert.equal(forced.reason, 'incomplete_snapshot');
|
||||
assert.equal(forced.files, 1);
|
||||
assert.equal(parseCalls, 2, 'force rejects the whole snapshot before parsing even safe units');
|
||||
|
||||
let db = new TestDatabase(dbPath);
|
||||
assert.equal(db.prepare("SELECT COUNT(*) AS c FROM sessions WHERE source='alpha'").get().c, 1);
|
||||
assert.equal(
|
||||
db.prepare('SELECT COUNT(*) AS c FROM index_state WHERE jsonl_path=?').get(marker).c,
|
||||
1,
|
||||
);
|
||||
db.close();
|
||||
|
||||
incomplete = false;
|
||||
assert.equal(buildIndex(options).complete, true);
|
||||
assert.equal(parseCalls, 2, 'completed units keep their cursors after the partial replay');
|
||||
db = new TestDatabase(dbPath);
|
||||
assert.equal(
|
||||
db.prepare('SELECT COUNT(*) AS c FROM index_state WHERE jsonl_path=?').get(marker).c,
|
||||
1,
|
||||
);
|
||||
db.close();
|
||||
assert.equal(buildIndex(options).files, 0);
|
||||
assert.equal(parseCalls, 2);
|
||||
});
|
||||
|
||||
test('a provider can withhold inventory-dependent tombstones from a partial census', () => {
|
||||
const home = mkdtempSync(join(tmpdir(), 'obelisk-provider-incomplete-tombstone-'));
|
||||
const dbPath = join(home, '.obelisk', 'obelisk.sqlite');
|
||||
const marker = '__alpha_canonical_v1__';
|
||||
let removeSession = false;
|
||||
let parseCalls = 0;
|
||||
const provider = {
|
||||
name: 'alpha',
|
||||
descriptor: { id: 'alpha', name: 'Alpha', vendor: 'Test', defaultRoot: '/alpha', color: '#123456' },
|
||||
indexVersionMarker: marker,
|
||||
watchRoots: () => [],
|
||||
discover(ctx) {
|
||||
if (removeSession) {
|
||||
ctx.reportIncompleteInventory({
|
||||
path: '/alpha/locked',
|
||||
error: 'EACCES: permission denied',
|
||||
});
|
||||
return [
|
||||
{
|
||||
key: 'alpha:readable',
|
||||
sessionId: 'alpha:readable-session',
|
||||
},
|
||||
];
|
||||
}
|
||||
return [{
|
||||
key: 'alpha:unit',
|
||||
sessionId: 'alpha:session',
|
||||
}];
|
||||
},
|
||||
*parse(unit) {
|
||||
parseCalls += 1;
|
||||
yield {
|
||||
kind: 'session', id: unit.sessionId, title: 'Alpha', project: null,
|
||||
started_at: null, ended_at: null, git_branch: null, version: null,
|
||||
message_count: 0, countMode: 'total', jsonl_path: unit.key, source: 'alpha',
|
||||
};
|
||||
return '10:1';
|
||||
},
|
||||
raw: () => null,
|
||||
};
|
||||
const options = {
|
||||
providerRegistry: createProviderRegistry([provider]),
|
||||
dbPath,
|
||||
DatabaseImpl: TestDatabase,
|
||||
};
|
||||
|
||||
assert.equal(buildIndex(options).complete, true);
|
||||
assert.equal(parseCalls, 1);
|
||||
|
||||
let db = new TestDatabase(dbPath);
|
||||
db.prepare('DELETE FROM index_state WHERE jsonl_path=?').run(marker);
|
||||
db.close();
|
||||
|
||||
removeSession = true;
|
||||
const incomplete = buildIndex(options);
|
||||
assert.equal(incomplete.complete, false);
|
||||
assert.equal(incomplete.files, 1);
|
||||
assert.equal(parseCalls, 2, 'the readable unit committed and the tombstone was filtered before parse');
|
||||
|
||||
db = new TestDatabase(dbPath);
|
||||
assert.equal(db.prepare("SELECT COUNT(*) AS c FROM sessions WHERE id='alpha:session'").get().c, 1);
|
||||
assert.equal(db.prepare("SELECT COUNT(*) AS c FROM sessions WHERE id='alpha:readable-session'").get().c, 1);
|
||||
assert.equal(
|
||||
db.prepare('SELECT COUNT(*) AS c FROM index_state WHERE jsonl_path=?').get(marker).c,
|
||||
1,
|
||||
);
|
||||
db.close();
|
||||
});
|
||||
|
||||
test('force rebuild resets arbitrary provider keys and rewrites arbitrary provider markers', () => {
|
||||
const home = mkdtempSync(join(tmpdir(), 'obelisk-provider-force-keys-'));
|
||||
const dbPath = join(home, '.obelisk', 'obelisk.sqlite');
|
||||
const unitKey = '__remote-1__';
|
||||
const marker = 'alpha-v1';
|
||||
let present = true;
|
||||
let parseCalls = 0;
|
||||
const provider = {
|
||||
name: 'alpha',
|
||||
descriptor: { id: 'alpha', name: 'Alpha', vendor: 'Test', defaultRoot: '/alpha', color: '#123456' },
|
||||
indexVersionMarker: marker,
|
||||
watchRoots: () => [],
|
||||
discover(ctx) {
|
||||
if (!present || ctx.lastCursor(unitKey) === '10:1') return [];
|
||||
return [{ key: unitKey, sessionId: 'alpha:session' }];
|
||||
},
|
||||
*parse(unit) {
|
||||
parseCalls += 1;
|
||||
yield {
|
||||
kind: 'session', id: unit.sessionId, title: 'Alpha', project: null,
|
||||
started_at: null, ended_at: null, git_branch: null, version: null,
|
||||
message_count: 0, countMode: 'total', jsonl_path: unit.key, source: 'alpha',
|
||||
};
|
||||
return '10:1';
|
||||
},
|
||||
raw: () => null,
|
||||
};
|
||||
const options = {
|
||||
providerRegistry: createProviderRegistry([provider]),
|
||||
dbPath,
|
||||
DatabaseImpl: TestDatabase,
|
||||
};
|
||||
|
||||
assert.equal(buildIndex(options).complete, true);
|
||||
present = false;
|
||||
assert.equal(buildIndex({ ...options, force: true }).complete, true);
|
||||
let db = new TestDatabase(dbPath);
|
||||
assert.equal(db.prepare('SELECT COUNT(*) AS c FROM index_state WHERE jsonl_path=?').get(unitKey).c, 0);
|
||||
assert.equal(db.prepare('SELECT COUNT(*) AS c FROM index_state WHERE jsonl_path=?').get(marker).c, 1);
|
||||
db.close();
|
||||
|
||||
present = true;
|
||||
assert.equal(buildIndex(options).complete, true);
|
||||
db = new TestDatabase(dbPath);
|
||||
assert.equal(db.prepare("SELECT COUNT(*) AS c FROM sessions WHERE source='alpha'").get().c, 1);
|
||||
db.close();
|
||||
assert.equal(parseCalls, 2);
|
||||
});
|
||||
|
||||
test('force rebuild keeps legacy providers without inventory certification compatible', () => {
|
||||
const home = mkdtempSync(join(tmpdir(), 'obelisk-provider-force-certification-'));
|
||||
const dbPath = join(home, '.obelisk', 'obelisk.sqlite');
|
||||
const unitKey = 'alpha:unit';
|
||||
const marker = 'alpha-marker';
|
||||
const provider = {
|
||||
name: 'alpha',
|
||||
descriptor: { id: 'alpha', name: 'Alpha', vendor: 'Test', defaultRoot: '/alpha', color: '#123456' },
|
||||
indexVersionMarker: marker,
|
||||
watchRoots: () => [],
|
||||
discover(ctx) {
|
||||
return ctx.lastCursor(unitKey) === '10:1'
|
||||
? []
|
||||
: [{ key: unitKey, sessionId: 'alpha:session' }];
|
||||
},
|
||||
*parse(unit) {
|
||||
yield {
|
||||
kind: 'session', id: unit.sessionId, title: 'Last good Alpha', project: null,
|
||||
started_at: null, ended_at: null, git_branch: null, version: null,
|
||||
message_count: 0, countMode: 'total', jsonl_path: unit.key, source: 'alpha',
|
||||
};
|
||||
return '10:1';
|
||||
},
|
||||
raw: () => null,
|
||||
};
|
||||
const options = {
|
||||
providerRegistry: createProviderRegistry([provider]),
|
||||
dbPath,
|
||||
DatabaseImpl: TestDatabase,
|
||||
};
|
||||
assert.equal(buildIndex(options).complete, true);
|
||||
|
||||
const forced = buildIndex({ ...options, force: true });
|
||||
assert.equal(forced.complete, true);
|
||||
assert.deepEqual(forced.incompleteProviders, []);
|
||||
|
||||
const afterDb = new TestDatabase(dbPath);
|
||||
assert.deepEqual(
|
||||
{ ...afterDb.prepare("SELECT id,title,source FROM sessions WHERE source='alpha'").get() },
|
||||
{ id: 'alpha:session', title: 'Last good Alpha', source: 'alpha' },
|
||||
);
|
||||
assert.equal(afterDb.prepare('SELECT COUNT(*) AS c FROM index_state WHERE jsonl_path=?').get(unitKey).c, 1);
|
||||
assert.equal(afterDb.prepare('SELECT COUNT(*) AS c FROM index_state WHERE jsonl_path=?').get(marker).c, 1);
|
||||
afterDb.close();
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Regression tests for the write-transaction runner (docs/adr/0006):
|
||||
// - a transient BUSY (auto-rolled-back txn) is retried and recovers;
|
||||
// - a persistent BUSY exhausts retries, and that file is SKIPPED, not fatal;
|
||||
// - a persistent BUSY exhausts retries without publishing a partial force rebuild;
|
||||
// - the guarded rollback never masks the real error ("cannot rollback ...").
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
@@ -180,7 +180,7 @@ test('a transient BUSY during a file transaction is retried and recovers', () =>
|
||||
assert.equal(result.skipped, 0);
|
||||
});
|
||||
|
||||
test('a persistent BUSY exhausts retries and skips just that file, not the build', () => {
|
||||
test('a persistent BUSY rolls back the complete force rebuild', () => {
|
||||
const { home, dbPath, projectsDir } = twoFileHome('POISON alpha', 'hello beta');
|
||||
// Always poison writes that carry alpha's marker text; beta is untouched.
|
||||
const Db = makeDbClass((args) => args.some(a => typeof a === 'string' && a.includes('POISON')));
|
||||
@@ -191,7 +191,8 @@ test('a persistent BUSY exhausts retries and skips just that file, not the build
|
||||
const check = new DatabaseSync(dbPath);
|
||||
const sessions = check.prepare('SELECT id FROM sessions ORDER BY id').all().map(r => r.id);
|
||||
check.close();
|
||||
assert.deepEqual(sessions, ['beta'], 'the persistently-failing file is skipped; the other indexes');
|
||||
assert.deepEqual(sessions, [], 'no partial force snapshot is published');
|
||||
assert.equal(result.complete, false);
|
||||
assert.equal(result.skipped, 1, 'the skipped file is reported in the build result');
|
||||
assert.equal(result.skippedFiles[0].diagnostics?.phase, 'work', 'diagnostics record the failing phase');
|
||||
});
|
||||
|
||||
@@ -99,7 +99,7 @@ test('search() hit shape matches api-reference.md', () => {
|
||||
const hit = createQueryApi(db).search('needle', { limit: 1 })[0];
|
||||
|
||||
exactKeys(hit, ['message', 'session', 'rank', 'context'], 'search() hit');
|
||||
exactKeys(hit.message, ['uuid', 'text', 'content_type', 'is_meta', 'role', 'timestamp', 'model', 'cwd', 'source'], 'search() hit.message');
|
||||
exactKeys(hit.message, ['uuid', 'text', 'content_type', 'is_meta', 'role', 'timestamp', 'model', 'cwd', 'visibility', 'source'], 'search() hit.message');
|
||||
exactKeys(hit.session, ['id', 'title', 'project', 'started_at', 'source'], 'search() hit.session');
|
||||
assert.ok(Array.isArray(hit.context), 'search() hit.context is an array');
|
||||
db.close();
|
||||
@@ -119,7 +119,7 @@ test('fileHistory() row shape matches api-reference.md', () => {
|
||||
const db = fixture();
|
||||
const row = createQueryApi(db).fileHistory('/x/file.ts')[0];
|
||||
|
||||
exactKeys(row, ['toolCall', 'session', 'timestamp'], 'fileHistory() row');
|
||||
exactKeys(row, ['toolCall', 'session', 'timestamp', 'visibility'], 'fileHistory() row');
|
||||
exactKeys(row.toolCall, ['id', 'message_uuid', 'name', 'input_json'], 'fileHistory() row.toolCall');
|
||||
exactKeys(row.session, ['id', 'title', 'project'], 'fileHistory() row.session');
|
||||
db.close();
|
||||
@@ -129,7 +129,7 @@ test('failures() row shape matches api-reference.md', () => {
|
||||
const db = fixture();
|
||||
const row = createQueryApi(db).failures()[0];
|
||||
|
||||
exactKeys(row, ['toolCall', 'result', 'session', 'nextMessages'], 'failures() row');
|
||||
exactKeys(row, ['toolCall', 'result', 'session', 'nextMessages', 'visibility'], 'failures() row');
|
||||
assert.ok(Array.isArray(row.nextMessages), 'failures() row.nextMessages is an array');
|
||||
assert.equal(row.nextMessages[0].uuid, 'm-after');
|
||||
db.close();
|
||||
@@ -196,7 +196,7 @@ test('raw() shape matches api-reference.md', () => {
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`).run('m-raw', 'sid-raw', 'user', 'user', 'raw line body', 'text', 'claude');
|
||||
|
||||
const result = createQueryApi(db).raw('m-raw');
|
||||
exactKeys(result, ['text', 'totalLength', 'offset', 'limit', 'hasMore'], 'raw()');
|
||||
exactKeys(result, ['text', 'totalLength', 'offset', 'limit', 'hasMore', 'visibility'], 'raw()');
|
||||
assert.equal(result.text, line);
|
||||
assert.equal(result.totalLength, line.length);
|
||||
db.close();
|
||||
|
||||
@@ -17,4 +17,8 @@ test('build:core emits an importable package with its schema resource', async ()
|
||||
assert.equal(typeof core.searchText, 'function');
|
||||
assert.equal(typeof core.executeQuery, 'function');
|
||||
assert.equal(typeof core.executeAttune, 'function');
|
||||
|
||||
const pi = await import(`${pathToFileURL(join(coreDist, 'providers', 'pi.js')).href}?test=${Date.now()}`);
|
||||
assert.equal(typeof pi.createPiProvider, 'function');
|
||||
assert.equal(pi.piProvider.descriptor.id, 'pi');
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@ import { readFile } from 'node:fs/promises';
|
||||
import { DatabaseSync } from 'node:sqlite';
|
||||
|
||||
import { extractContentType, extractMessageIsMeta } from '../packages/core/src/db.ts';
|
||||
import { migrateCoreSchemaColumns } from '../packages/core/src/schema-migrations.ts';
|
||||
|
||||
async function readExecutableSchema() {
|
||||
return readFile(new URL('../packages/core/src/schema.sql', import.meta.url), 'utf8');
|
||||
@@ -50,6 +51,42 @@ test('messages schema stores the raw content block type', async () => {
|
||||
assert.match(source, /CREATE TRIGGER IF NOT EXISTS messages_fts_ad AFTER DELETE ON messages/);
|
||||
});
|
||||
|
||||
test('summaries preserve usage from provider-owned summary model calls', async () => {
|
||||
const source = await readExecutableSchema();
|
||||
|
||||
assert.match(source, /summaries \([\s\S]*visibility TEXT DEFAULT 'visible'[\s\S]*input_tokens INTEGER, output_tokens INTEGER/);
|
||||
assert.match(source, /index_state \([\s\S]*cursor TEXT/);
|
||||
});
|
||||
|
||||
test('additive migrations preserve old index state and summary rows while adding canonical fields', () => {
|
||||
const db = new DatabaseSync(':memory:');
|
||||
try {
|
||||
db.exec(`
|
||||
CREATE TABLE index_state (
|
||||
jsonl_path TEXT PRIMARY KEY, mtime REAL, lines_processed INTEGER
|
||||
);
|
||||
CREATE TABLE summaries (
|
||||
id TEXT PRIMARY KEY, session_id TEXT, timestamp TEXT,
|
||||
source TEXT, content TEXT
|
||||
);
|
||||
INSERT INTO index_state VALUES ('unit', 12, 3);
|
||||
INSERT INTO summaries VALUES ('summary', 'session', NULL, 'legacy', 'kept');
|
||||
`);
|
||||
|
||||
migrateCoreSchemaColumns(db);
|
||||
assert.equal(
|
||||
db.prepare("SELECT cursor FROM index_state WHERE jsonl_path='unit'").get().cursor,
|
||||
null,
|
||||
);
|
||||
assert.deepEqual(
|
||||
{ ...db.prepare("SELECT content,visibility FROM summaries WHERE id='summary'").get() },
|
||||
{ content: 'kept', visibility: 'visible' },
|
||||
);
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('tool results schema indexes live session patch lookups', async () => {
|
||||
const db = new DatabaseSync(':memory:');
|
||||
try {
|
||||
@@ -125,10 +162,18 @@ test('schema reference stays focused on raw SQL structure', async () => {
|
||||
|
||||
assert.ok(ref.split('\n').length < 420, 'schema.md should remain a quick SQL reference');
|
||||
assert.match(ref, /Raw SQL Quick Reference/i);
|
||||
assert.match(ref, /Claude Code, Codex, Kimi Code, and Pi/);
|
||||
assert.equal(
|
||||
ref.match(/Provider ID: `claude`, `codex`, `kimi`, or `pi`/g)?.length,
|
||||
2,
|
||||
'session and message source fields should document every provider',
|
||||
);
|
||||
assert.match(ref, /references\/api-reference\.md/);
|
||||
assert.match(ref, /sessions\.id\s+<--\s+messages\.session_id/);
|
||||
assert.match(ref, /tool_calls.*does not have timestamps/i);
|
||||
assert.match(ref, /COALESCE\(m\.is_meta, 0\) = 0/);
|
||||
assert.match(ref, /provider-attested superseded history/);
|
||||
assert.match(ref, /Exact opaque provider cursor/);
|
||||
assert.doesNotMatch(ref, /#### `summaries\(opts\?\)`/);
|
||||
assert.doesNotMatch(ref, /#### `raw\(uuid, opts\?\)`/);
|
||||
});
|
||||
@@ -137,8 +182,11 @@ test('api reference documents query helpers and current return fields', async ()
|
||||
const ref = await readApiReference();
|
||||
|
||||
assert.match(ref, /## Query API Reference/);
|
||||
assert.match(ref, /'claude' \| 'codex' \| 'kimi' \| 'pi'/);
|
||||
assert.doesNotMatch(ref, /"claude", "codex", or omitted/);
|
||||
assert.match(ref, /#### `summaries\(opts\?\)`/);
|
||||
assert.match(ref, /summary rows/i);
|
||||
assert.match(ref, /Inactive summaries describe work that was tried and[\s\S]*then superseded/);
|
||||
assert.match(ref, /session_title/);
|
||||
assert.match(ref, /opts\.branch/);
|
||||
assert.match(ref, /#### `raw\(uuid, opts\?\)`/);
|
||||
@@ -154,6 +202,8 @@ test('api reference documents query helpers and current return fields', async ()
|
||||
test('skill routes agents to the right reference document', async () => {
|
||||
const skill = await readSkill();
|
||||
|
||||
assert.match(skill, /Claude Code, Codex, Kimi Code, and Pi/);
|
||||
assert.match(skill, /'claude'.*'codex'.*'kimi'.*'pi'/s);
|
||||
assert.match(skill, /Reference Map/);
|
||||
assert.match(skill, /references\/schema\.md.*raw SQL/i);
|
||||
assert.match(skill, /references\/api-reference\.md.*helper/i);
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
{"type":"session","version":3,"id":"harness-checkpoint-fork","timestamp":"2026-08-02T10:17:40.694Z","cwd":"/tmp/pi-harness-project","parentSession":"/tmp/source-session.jsonl"}
|
||||
{"type":"compaction","id":"2bd121f3","parentId":"3582fbcc","timestamp":"2026-08-02T10:17:40.693Z","summary":"Harness compaction checkpoint","firstKeptEntryId":"bb37b0d7","tokensBefore":9001,"retainedTail":[{"role":"user","content":"Harness retained user turn","timestamp":1785664802000},{"role":"assistant","content":[{"type":"thinking","thinking":"retained reasoning"},{"type":"text","text":"Harness retained assistant turn"}],"api":"openai-responses","provider":"obelisk-probe","model":"probe-model","usage":{"input":101,"output":23,"cacheRead":17,"cacheWrite":5,"reasoning":7,"totalTokens":146,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"stop","timestamp":1785664803000}]}
|
||||
{"type":"message","id":"f56a248d","parentId":"2bd121f3","timestamp":"2026-08-02T10:17:40.693Z","message":{"role":"user","content":"Harness post-compaction user turn","timestamp":1785664804000}}
|
||||
{"type":"message","id":"5b2c961b","parentId":"f56a248d","timestamp":"2026-08-02T10:17:40.693Z","message":{"role":"assistant","content":[{"type":"text","text":"Harness post-compaction assistant turn"}],"api":"openai-responses","provider":"obelisk-probe","model":"probe-model","usage":{"input":101,"output":23,"cacheRead":17,"cacheWrite":5,"reasoning":7,"totalTokens":146,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"stop","timestamp":1785664805000}}
|
||||
{"type":"session_info","id":"4bb3c8bd","parentId":"5b2c961b","timestamp":"2026-08-02T10:17:40.694Z","name":"Checkpoint fork with retained tail"}
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
{"type":"session","version":3,"id":"harness-null-leaf","timestamp":"2026-08-02T10:17:40.695Z","cwd":"/tmp/pi-harness-project"}
|
||||
{"type":"message","id":"be0488ce","parentId":null,"timestamp":"2026-08-02T10:17:40.695Z","message":{"role":"user","content":"This physical message is no longer active","timestamp":1785664806000}}
|
||||
{"type":"leaf","id":"ff4d594c","parentId":"be0488ce","timestamp":"2026-08-02T10:17:40.695Z","targetId":null}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
{"type":"session","version":3,"id":"harness-probe","timestamp":"2026-08-02T10:17:40.691Z","cwd":"/tmp/pi-harness-project","metadata":{"purpose":"obelisk-pi-adapter-probe"}}
|
||||
{"type":"message","id":"a49e0082","parentId":null,"timestamp":"2026-08-02T10:17:40.693Z","message":{"role":"user","content":"Harness first user turn","timestamp":1785664800000}}
|
||||
{"type":"message","id":"6edde419","parentId":"a49e0082","timestamp":"2026-08-02T10:17:40.693Z","message":{"role":"assistant","content":[{"type":"text","text":"Harness first assistant turn"}],"api":"openai-responses","provider":"obelisk-probe","model":"probe-model","usage":{"input":101,"output":23,"cacheRead":17,"cacheWrite":5,"reasoning":7,"totalTokens":146,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"stop","timestamp":1785664801000}}
|
||||
{"type":"message","id":"bb37b0d7","parentId":"6edde419","timestamp":"2026-08-02T10:17:40.693Z","message":{"role":"user","content":"Harness retained user turn","timestamp":1785664802000}}
|
||||
{"type":"message","id":"79b4a046","parentId":"bb37b0d7","timestamp":"2026-08-02T10:17:40.693Z","message":{"role":"assistant","content":[{"type":"text","text":"Harness retained assistant turn"}],"api":"openai-responses","provider":"obelisk-probe","model":"probe-model","usage":{"input":101,"output":23,"cacheRead":17,"cacheWrite":5,"reasoning":7,"totalTokens":146,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"stop","timestamp":1785664803000}}
|
||||
{"type":"active_tools_change","id":"3582fbcc","parentId":"79b4a046","timestamp":"2026-08-02T10:17:40.693Z","activeToolNames":["read","write"]}
|
||||
{"type":"compaction","id":"2bd121f3","parentId":"3582fbcc","timestamp":"2026-08-02T10:17:40.693Z","summary":"Harness compaction checkpoint","firstKeptEntryId":"bb37b0d7","tokensBefore":9001,"retainedTail":[{"role":"user","content":"Harness retained user turn","timestamp":1785664802000},{"role":"assistant","content":[{"type":"text","text":"Harness retained assistant turn"}],"api":"openai-responses","provider":"obelisk-probe","model":"probe-model","usage":{"input":101,"output":23,"cacheRead":17,"cacheWrite":5,"reasoning":7,"totalTokens":146,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"stop","timestamp":1785664803000}]}
|
||||
{"type":"message","id":"f56a248d","parentId":"2bd121f3","timestamp":"2026-08-02T10:17:40.693Z","message":{"role":"user","content":"Harness post-compaction user turn","timestamp":1785664804000}}
|
||||
{"type":"message","id":"5b2c961b","parentId":"f56a248d","timestamp":"2026-08-02T10:17:40.693Z","message":{"role":"assistant","content":[{"type":"text","text":"Harness post-compaction assistant turn"}],"api":"openai-responses","provider":"obelisk-probe","model":"probe-model","usage":{"input":101,"output":23,"cacheRead":17,"cacheWrite":5,"reasoning":7,"totalTokens":146,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"stop","timestamp":1785664805000}}
|
||||
{"type":"session_info","id":"116253e0","parentId":"5b2c961b","timestamp":"2026-08-02T10:17:40.695Z","name":"Source with durable leaf marker"}
|
||||
{"type":"leaf","id":"771ef412","parentId":"116253e0","timestamp":"2026-08-02T10:17:40.695Z","targetId":"a49e0082"}
|
||||
+169
@@ -0,0 +1,169 @@
|
||||
/*
|
||||
* Test-only transcription of the Pi 0.83.0 context algorithms:
|
||||
* https://github.com/earendil-works/pi/blob/v0.83.0/packages/coding-agent/src/core/session-manager.ts
|
||||
* https://github.com/earendil-works/pi/blob/v0.83.0/packages/agent/src/harness/session/session.ts
|
||||
*
|
||||
* MIT License
|
||||
*
|
||||
* Copyright (c) 2025 Mario Zechner
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*/
|
||||
|
||||
export const PI_CONTEXT_ORACLE_VERSION = '0.83.0';
|
||||
|
||||
function buildEntryIndex(entries, byId) {
|
||||
if (byId) return byId;
|
||||
const index = new Map();
|
||||
for (const entry of entries) index.set(entry.id, entry);
|
||||
return index;
|
||||
}
|
||||
|
||||
export function buildCodingAgentSessionPath(entries, leafId, byId) {
|
||||
const index = buildEntryIndex(entries, byId);
|
||||
let leaf;
|
||||
if (leafId === null) return [];
|
||||
if (leafId) leaf = index.get(leafId);
|
||||
leaf ??= entries[entries.length - 1];
|
||||
if (!leaf) return [];
|
||||
|
||||
const path = [];
|
||||
let current = leaf;
|
||||
while (current) {
|
||||
path.push(current);
|
||||
current = current.parentId ? index.get(current.parentId) : undefined;
|
||||
}
|
||||
path.reverse();
|
||||
return path;
|
||||
}
|
||||
|
||||
// pi-agent-core 0.83.0 JsonlSessionStorage.getPathToRootOrCompaction().
|
||||
// The checked fixtures may contain synthetic orphan edges; those terminate the
|
||||
// path just as the coding-agent oracle above does instead of exercising the
|
||||
// storage API's separate invalid-session error.
|
||||
export function buildAgentCoreSessionPath(entries, leafId, byId) {
|
||||
const index = buildEntryIndex(entries, byId);
|
||||
if (leafId === null) return [];
|
||||
let current = leafId ? index.get(leafId) : entries[entries.length - 1];
|
||||
current ??= entries[entries.length - 1];
|
||||
if (!current) return [];
|
||||
|
||||
const path = [];
|
||||
let stopAtEntryId = null;
|
||||
while (current) {
|
||||
path.unshift(current);
|
||||
if (stopAtEntryId !== null && current.id === stopAtEntryId) break;
|
||||
if (current.type === 'compaction') {
|
||||
if (current.retainedTail) break;
|
||||
stopAtEntryId = current.firstKeptEntryId ?? null;
|
||||
}
|
||||
if (!current.parentId) break;
|
||||
current = index.get(current.parentId);
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
export function buildCodingAgentContextEntries(entries, leafId, byId) {
|
||||
const path = buildCodingAgentSessionPath(entries, leafId, byId);
|
||||
let compaction = null;
|
||||
for (const entry of path) {
|
||||
if (entry.type === 'compaction') compaction = entry;
|
||||
}
|
||||
if (!compaction) return path;
|
||||
|
||||
const compactionIndex = path.findIndex(entry => entry.id === compaction.id);
|
||||
if (compactionIndex < 0) return path;
|
||||
|
||||
const contextEntries = [compaction];
|
||||
let foundFirstKept = false;
|
||||
for (let index = 0; index < compactionIndex; index++) {
|
||||
const entry = path[index];
|
||||
if (entry.id === compaction.firstKeptEntryId) foundFirstKept = true;
|
||||
if (foundFirstKept) contextEntries.push(entry);
|
||||
}
|
||||
contextEntries.push(...path.slice(compactionIndex + 1));
|
||||
return contextEntries;
|
||||
}
|
||||
|
||||
export function defaultAgentCoreContextEntryTransform(pathEntries) {
|
||||
let compaction = null;
|
||||
for (const entry of pathEntries) {
|
||||
if (entry.type === 'compaction') compaction = entry;
|
||||
}
|
||||
if (!compaction) return [...pathEntries];
|
||||
|
||||
const entries = [compaction];
|
||||
const compactionIndex = pathEntries.findIndex(entry => (
|
||||
entry.type === 'compaction' && entry.id === compaction.id
|
||||
));
|
||||
if (compaction.retainedTail) {
|
||||
for (let index = compactionIndex + 1; index < pathEntries.length; index++) {
|
||||
entries.push(pathEntries[index]);
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
if (compaction.firstKeptEntryId) {
|
||||
let foundFirstKept = false;
|
||||
for (let index = 0; index < compactionIndex; index++) {
|
||||
const entry = pathEntries[index];
|
||||
if (entry.id === compaction.firstKeptEntryId) foundFirstKept = true;
|
||||
if (foundFirstKept) entries.push(entry);
|
||||
}
|
||||
}
|
||||
for (let index = compactionIndex + 1; index < pathEntries.length; index++) {
|
||||
entries.push(pathEntries[index]);
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
export function pi083LeafId(entries) {
|
||||
let leafId = null;
|
||||
for (const entry of entries) {
|
||||
leafId = entry.type === 'leaf' ? entry.targetId : entry.id;
|
||||
}
|
||||
return leafId;
|
||||
}
|
||||
|
||||
function evidence(kind, source, role, content) {
|
||||
return JSON.stringify({ kind, source, role, content });
|
||||
}
|
||||
|
||||
// The Pi oracles select the active context entries. Obelisk stores those entries
|
||||
// in durable physical order for its evidence timeline, while retainedTail
|
||||
// messages remain immediately after their owning compaction summary.
|
||||
export function projectCanonicalEvidence(physicalEntries, contextEntries) {
|
||||
const activeIds = new Set(contextEntries.map(entry => entry.id));
|
||||
return physicalEntries.filter(entry => activeIds.has(entry.id)).flatMap((entry) => {
|
||||
if (entry.type === 'message') {
|
||||
return [evidence('message', null, entry.message.role, entry.message.content)];
|
||||
}
|
||||
if (entry.type === 'branch_summary') {
|
||||
return [evidence('summary', 'pi:branch_summary', null, entry.summary)];
|
||||
}
|
||||
if (entry.type === 'compaction') {
|
||||
return [
|
||||
evidence('summary', 'pi:compaction', null, entry.summary),
|
||||
...(entry.retainedTail ?? []).map(message => (
|
||||
evidence('message', null, message.role, message.content)
|
||||
)),
|
||||
];
|
||||
}
|
||||
return [];
|
||||
});
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
{"type":"session","version":3,"id":"real-probe","timestamp":"2026-08-02T09:46:45.516Z","cwd":"/tmp/pi-project"}
|
||||
{"type":"session_info","id":"211e5f6a","parentId":null,"timestamp":"2026-08-02T09:46:45.517Z","name":"Real model probe"}
|
||||
{"type":"model_change","id":"ec3a3e01","parentId":"211e5f6a","timestamp":"2026-08-02T09:46:45.535Z","provider":"custom-openai","modelId":"gpt-probe"}
|
||||
{"type":"message","id":"a7d8022e","parentId":"ec3a3e01","timestamp":"2026-08-02T09:46:45.539Z","message":{"role":"user","content":[{"type":"text","text":"Reply with the probe marker"},{"type":"image","mimeType":"image/png","data":"QUJDRA=="}],"timestamp":1785664005538}}
|
||||
{"type":"message","id":"3fecb3df","parentId":"a7d8022e","timestamp":"2026-08-02T09:46:46.507Z","message":{"role":"assistant","content":[],"api":"openai-completions","provider":"custom-openai","model":"gpt-probe","usage":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"totalTokens":0,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"error","timestamp":1785664005566,"errorMessage":"404 not found"}}
|
||||
{"type":"message","id":"e40917d4","parentId":"3fecb3df","timestamp":"2026-08-02T09:47:57.481Z","message":{"role":"user","content":[{"type":"text","text":"Try the responses protocol"}],"timestamp":1785664077480}}
|
||||
{"type":"message","id":"57e279e0","parentId":"e40917d4","timestamp":"2026-08-02T09:47:59.459Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"","thinkingSignature":"opaque-probe-signature"},{"type":"text","text":"REAL_PI_PROBE_OK"}],"api":"openai-responses","provider":"custom-openai","model":"gpt-probe","usage":{"input":973,"output":20,"cacheRead":3840,"cacheWrite":5,"reasoning":9,"totalTokens":4838,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"stop","timestamp":1785664077508}}
|
||||
Vendored
+8
@@ -0,0 +1,8 @@
|
||||
{"type":"session","version":3,"id":"tool-probe","timestamp":"2026-08-02T09:42:38.031Z","cwd":"/tmp/pi-project"}
|
||||
{"type":"session_info","id":"eacdf41f","parentId":null,"timestamp":"2026-08-02T09:42:38.032Z","name":"Tool probe"}
|
||||
{"type":"model_change","id":"fba5d21a","parentId":"eacdf41f","timestamp":"2026-08-02T09:42:38.041Z","provider":"obelisk-probe","modelId":"probe-model"}
|
||||
{"type":"thinking_level_change","id":"e32523fb","parentId":"fba5d21a","timestamp":"2026-08-02T09:42:38.041Z","thinkingLevel":"off"}
|
||||
{"type":"message","id":"7e0576ed","parentId":"e32523fb","timestamp":"2026-08-02T09:42:38.043Z","message":{"role":"user","content":[{"type":"text","text":"Read probe.txt and report it"}],"timestamp":1785663758043}}
|
||||
{"type":"message","id":"c2be25cd","parentId":"7e0576ed","timestamp":"2026-08-02T09:42:38.069Z","message":{"role":"assistant","content":[{"type":"toolCall","id":"call_obelisk_probe","name":"read","arguments":{"path":"probe.txt"}}],"api":"openai-completions","provider":"obelisk-probe","model":"probe-model","usage":{"input":14,"output":5,"cacheRead":3,"cacheWrite":0,"reasoning":2,"totalTokens":22,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"toolUse","timestamp":1785663758058}}
|
||||
{"type":"message","id":"47e488af","parentId":"c2be25cd","timestamp":"2026-08-02T09:42:38.072Z","message":{"role":"toolResult","toolCallId":"call_obelisk_probe","toolName":"read","content":[{"type":"text","text":"real-pi-tool-result\n"}],"isError":false,"timestamp":1785663758072}}
|
||||
{"type":"message","id":"9db96a87","parentId":"47e488af","timestamp":"2026-08-02T09:42:38.075Z","message":{"role":"assistant","content":[{"type":"text","text":"The read tool returned real-pi-tool-result."}],"api":"openai-completions","provider":"obelisk-probe","model":"probe-model","usage":{"input":19,"output":9,"cacheRead":4,"cacheWrite":0,"reasoning":0,"totalTokens":32,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"stop","timestamp":1785663758072}}
|
||||
+10
-2
@@ -11,6 +11,7 @@ import { join } from 'node:path';
|
||||
|
||||
import { parse } from '../packages/core/src/providers/claude.ts';
|
||||
import { persist } from '../packages/core/src/persist.ts';
|
||||
import { storedProviderCursor } from '../packages/core/src/provider-indexing.ts';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const { DatabaseSync } = require('node:sqlite');
|
||||
@@ -47,6 +48,10 @@ test('persist writes all record kinds from one claude parse', () => {
|
||||
assert.equal(db.prepare('SELECT COUNT(*) c FROM tool_calls').get().c, 1);
|
||||
assert.equal(db.prepare('SELECT COUNT(*) c FROM tool_results').get().c, 1);
|
||||
assert.equal(db.prepare('SELECT COUNT(*) c FROM summaries').get().c, 1);
|
||||
assert.deepEqual(
|
||||
{ ...db.prepare('SELECT visibility,input_tokens,output_tokens FROM summaries').get() },
|
||||
{ visibility: 'visible', input_tokens: null, output_tokens: null },
|
||||
);
|
||||
|
||||
const ses = db.prepare('SELECT * FROM sessions WHERE id=?').get('sid-p');
|
||||
assert.equal(ses.title, 'Persist Session');
|
||||
@@ -60,9 +65,12 @@ test('persist writes all record kinds from one claude parse', () => {
|
||||
assert.equal(usage.input_tokens, 60);
|
||||
assert.equal(usage.output_tokens, 5);
|
||||
|
||||
// Cursor persisted into index_state (mtime:lines → two columns).
|
||||
const state = db.prepare('SELECT lines_processed FROM index_state WHERE jsonl_path=?').get(unit.key);
|
||||
// The legacy numeric columns remain queryable, while the opaque token
|
||||
// round-trips byte-for-byte for snapshot providers.
|
||||
const state = db.prepare('SELECT lines_processed,cursor FROM index_state WHERE jsonl_path=?').get(unit.key);
|
||||
assert.equal(state.lines_processed, 6);
|
||||
assert.equal(state.cursor, cursor);
|
||||
assert.equal(storedProviderCursor(db, unit.key), cursor);
|
||||
assert.equal(cursor.split(':')[1], '6');
|
||||
});
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,250 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { dirname, join } from 'node:path';
|
||||
|
||||
import { createPiProvider } from '../packages/core/src/providers/pi.ts';
|
||||
import {
|
||||
buildCodingAgentContextEntries,
|
||||
buildCodingAgentSessionPath,
|
||||
buildAgentCoreSessionPath,
|
||||
defaultAgentCoreContextEntryTransform,
|
||||
PI_CONTEXT_ORACLE_VERSION,
|
||||
pi083LeafId,
|
||||
projectCanonicalEvidence,
|
||||
} from './fixtures/pi/pi-0.83.0-context-oracle.mjs';
|
||||
|
||||
const CASES = 512;
|
||||
const SEED = 0x5eedc0de;
|
||||
|
||||
function randomGenerator(seed) {
|
||||
let state = seed >>> 0;
|
||||
return () => {
|
||||
state = (Math.imul(state, 1664525) + 1013904223) >>> 0;
|
||||
return state / 0x1_0000_0000;
|
||||
};
|
||||
}
|
||||
|
||||
function integer(random, min, max) {
|
||||
return min + Math.floor(random() * (max - min + 1));
|
||||
}
|
||||
|
||||
function timestamp(caseIndex, entryIndex) {
|
||||
return new Date(Date.UTC(2026, 7, 2, 12, caseIndex % 30, entryIndex)).toISOString();
|
||||
}
|
||||
|
||||
function actualEvidence(records) {
|
||||
return records.flatMap((record) => {
|
||||
if (record.kind === 'message' && record.visibility === 'visible') {
|
||||
return [JSON.stringify({
|
||||
kind: 'message',
|
||||
source: null,
|
||||
role: record.role,
|
||||
content: record.text,
|
||||
})];
|
||||
}
|
||||
if (record.kind === 'summary' && record.visibility === 'visible') {
|
||||
return [JSON.stringify({
|
||||
kind: 'summary',
|
||||
source: record.source,
|
||||
role: null,
|
||||
content: record.content,
|
||||
})];
|
||||
}
|
||||
return [];
|
||||
});
|
||||
}
|
||||
|
||||
function drain(generator) {
|
||||
const records = [];
|
||||
for (;;) {
|
||||
const step = generator.next();
|
||||
if (step.done) return records;
|
||||
records.push(step.value);
|
||||
}
|
||||
}
|
||||
|
||||
function createCase(random, caseIndex, totals) {
|
||||
const entries = [];
|
||||
const ids = [];
|
||||
let headId = null;
|
||||
const entryCount = integer(random, 8, 64);
|
||||
for (let entryIndex = 0; entryIndex < entryCount; entryIndex++) {
|
||||
const id = `case-${caseIndex}-entry-${entryIndex}`;
|
||||
const time = timestamp(caseIndex, entryIndex);
|
||||
const roll = random();
|
||||
let entry;
|
||||
|
||||
if (roll < 0.08 && ids.length > 0) {
|
||||
const targetId = random() < 0.12 ? null : ids[integer(random, 0, ids.length - 1)];
|
||||
entry = { type: 'leaf', id, parentId: headId, timestamp: time, targetId };
|
||||
headId = targetId;
|
||||
totals.leaves++;
|
||||
if (targetId === null) totals.nullLeaves++;
|
||||
} else {
|
||||
let parentId = headId;
|
||||
if (random() < 0.06) {
|
||||
parentId = `omitted-${caseIndex}-${entryIndex}`;
|
||||
totals.orphanParents++;
|
||||
} else if (ids.length > 0 && random() < 0.28) {
|
||||
parentId = ids[integer(random, 0, ids.length - 1)];
|
||||
}
|
||||
|
||||
if (roll < 0.72) {
|
||||
entry = {
|
||||
type: 'message',
|
||||
id,
|
||||
parentId,
|
||||
timestamp: time,
|
||||
message: {
|
||||
role: 'user',
|
||||
content: `entry:${id}`,
|
||||
timestamp: Date.parse(time),
|
||||
},
|
||||
};
|
||||
totals.messages++;
|
||||
} else if (roll < 0.84) {
|
||||
entry = {
|
||||
type: 'branch_summary',
|
||||
id,
|
||||
parentId,
|
||||
timestamp: time,
|
||||
fromId: parentId ?? id,
|
||||
summary: `branch:${id}`,
|
||||
};
|
||||
totals.branchSummaries++;
|
||||
} else if (roll < 0.96) {
|
||||
const retained = random() < 0.5;
|
||||
entry = {
|
||||
type: 'compaction',
|
||||
id,
|
||||
parentId,
|
||||
timestamp: time,
|
||||
summary: `compaction:${id}`,
|
||||
tokensBefore: integer(random, 0, 100_000),
|
||||
...(retained
|
||||
? {
|
||||
retainedTail: [{
|
||||
role: 'user',
|
||||
content: `tail:${id}`,
|
||||
timestamp: Date.parse(time),
|
||||
}],
|
||||
}
|
||||
: { firstKeptEntryId: parentId }),
|
||||
};
|
||||
totals.compactions++;
|
||||
if (retained) totals.retainedTailCompactions++;
|
||||
} else {
|
||||
entry = {
|
||||
type: 'model_change',
|
||||
id,
|
||||
parentId,
|
||||
timestamp: time,
|
||||
provider: 'probe',
|
||||
modelId: 'probe',
|
||||
};
|
||||
}
|
||||
headId = id;
|
||||
}
|
||||
|
||||
entries.push(entry);
|
||||
ids.push(id);
|
||||
totals.entries++;
|
||||
}
|
||||
return {
|
||||
header: {
|
||||
type: 'session',
|
||||
version: 3,
|
||||
id: `differential-${caseIndex}`,
|
||||
timestamp: timestamp(caseIndex, 0),
|
||||
cwd: `/tmp/obelisk-pi-differential/project-${caseIndex}`,
|
||||
},
|
||||
entries,
|
||||
};
|
||||
}
|
||||
|
||||
test('fixed-seed randomized differential matches vendored Pi 0.83.0 context oracles', () => {
|
||||
assert.equal(PI_CONTEXT_ORACLE_VERSION, '0.83.0');
|
||||
const random = randomGenerator(SEED);
|
||||
const root = mkdtempSync(join(tmpdir(), 'obelisk-pi-randomized-differential-'));
|
||||
const generated = [];
|
||||
const totals = {
|
||||
cases: CASES,
|
||||
entries: 0,
|
||||
messages: 0,
|
||||
leaves: 0,
|
||||
nullLeaves: 0,
|
||||
compactions: 0,
|
||||
retainedTailCompactions: 0,
|
||||
branchSummaries: 0,
|
||||
orphanParents: 0,
|
||||
retainedCheckpointPathCases: 0,
|
||||
mixedCheckpointLegacyCases: 0,
|
||||
};
|
||||
|
||||
for (let caseIndex = 0; caseIndex < CASES; caseIndex++) {
|
||||
const generatedCase = createCase(random, caseIndex, totals);
|
||||
generated.push(generatedCase);
|
||||
const path = join(root, `case-${caseIndex}`, 'session.jsonl');
|
||||
mkdirSync(dirname(path), { recursive: true });
|
||||
writeFileSync(
|
||||
path,
|
||||
`${[generatedCase.header, ...generatedCase.entries].map(record => JSON.stringify(record)).join('\n')}\n`,
|
||||
);
|
||||
}
|
||||
|
||||
const provider = createPiProvider({ rootDir: root });
|
||||
const units = provider.discover({ lastCursor: () => null });
|
||||
assert.equal(units.length, CASES);
|
||||
for (const unit of units) {
|
||||
const caseIndex = Number(/case-(\d+)/.exec(unit.key)?.[1]);
|
||||
const entries = generated[caseIndex].entries;
|
||||
const leafId = pi083LeafId(entries);
|
||||
const fullPath = buildCodingAgentSessionPath(entries, leafId);
|
||||
const codingAgentContext = buildCodingAgentContextEntries(entries, leafId);
|
||||
const agentCoreContext = defaultAgentCoreContextEntryTransform(
|
||||
buildAgentCoreSessionPath(entries, leafId),
|
||||
);
|
||||
// Pi 0.83's CLI owns legacy firstKeptEntryId semantics. retainedTail is the
|
||||
// agent-core storage checkpoint format, so mixed/new chains use its bounded
|
||||
// storage path before the context transform.
|
||||
let checkpointIndex = -1;
|
||||
for (let index = 0; index < fullPath.length; index++) {
|
||||
const entry = fullPath[index];
|
||||
if (entry.type === 'compaction' && entry.retainedTail !== undefined) {
|
||||
checkpointIndex = index;
|
||||
}
|
||||
}
|
||||
if (checkpointIndex >= 0) {
|
||||
totals.retainedCheckpointPathCases++;
|
||||
if (fullPath.slice(checkpointIndex + 1).some(entry => (
|
||||
entry.type === 'compaction' && entry.retainedTail === undefined
|
||||
))) {
|
||||
totals.mixedCheckpointLegacyCases++;
|
||||
}
|
||||
}
|
||||
const expectedContext = checkpointIndex >= 0
|
||||
? agentCoreContext
|
||||
: codingAgentContext;
|
||||
assert.deepEqual(
|
||||
actualEvidence(drain(provider.parse(unit, null))),
|
||||
projectCanonicalEvidence(entries, expectedContext),
|
||||
`seed 0x${SEED.toString(16)}, case ${caseIndex}`,
|
||||
);
|
||||
}
|
||||
|
||||
assert.deepEqual(totals, {
|
||||
cases: 512,
|
||||
entries: 18124,
|
||||
messages: 11586,
|
||||
leaves: 1457,
|
||||
nullLeaves: 167,
|
||||
compactions: 2230,
|
||||
retainedTailCompactions: 1137,
|
||||
branchSummaries: 2191,
|
||||
orphanParents: 1060,
|
||||
retainedCheckpointPathCases: 175,
|
||||
mixedCheckpointLegacyCases: 32,
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,203 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import { DatabaseSync } from 'node:sqlite';
|
||||
|
||||
import { piSessionId } from '../packages/core/src/providers/pi.ts';
|
||||
import { runCli } from './cli-test-helpers.mjs';
|
||||
|
||||
test('passive-pull runtime indexes Pi sessions from the default home', () => {
|
||||
const home = mkdtempSync(join(tmpdir(), 'obelisk-pi-runtime-'));
|
||||
const sessionPath = join(
|
||||
home,
|
||||
'.pi',
|
||||
'agent',
|
||||
'sessions',
|
||||
'--tmp-pi-project--',
|
||||
'runtime.jsonl',
|
||||
);
|
||||
mkdirSync(dirname(sessionPath), { recursive: true });
|
||||
writeFileSync(
|
||||
sessionPath,
|
||||
readFileSync(new URL('./fixtures/pi/tool-session.jsonl', import.meta.url)),
|
||||
);
|
||||
|
||||
const coreUrl = pathToFileURL(join(process.cwd(), 'packages/core/src/core.ts')).href;
|
||||
const script = `
|
||||
import { executeQuery } from ${JSON.stringify(coreUrl)};
|
||||
const result = await executeQuery("return sessions({ source: 'pi', limit: 5 });");
|
||||
process.stdout.write(JSON.stringify(result));
|
||||
`;
|
||||
const env = { ...process.env, HOME: home, USERPROFILE: home };
|
||||
delete env.PI_CODING_AGENT_DIR;
|
||||
delete env.PI_CODING_AGENT_SESSION_DIR;
|
||||
const run = spawnSync(process.execPath, ['--experimental-strip-types', '--input-type=module', '-e', script], {
|
||||
cwd: process.cwd(),
|
||||
env,
|
||||
encoding: 'utf8',
|
||||
});
|
||||
|
||||
assert.equal(run.status, 0, run.stderr);
|
||||
const sessions = JSON.parse(run.stdout);
|
||||
assert.equal(sessions.length, 1);
|
||||
const fixtureHeader = JSON.parse(
|
||||
readFileSync(new URL('./fixtures/pi/tool-session.jsonl', import.meta.url), 'utf8').split('\n')[0],
|
||||
);
|
||||
assert.equal(sessions[0].id, piSessionId(fixtureHeader));
|
||||
assert.deepEqual(
|
||||
(({ title, source, message_count }) => ({ title, source, message_count }))(sessions[0]),
|
||||
{ title: 'Tool probe', source: 'pi', message_count: 4 },
|
||||
);
|
||||
});
|
||||
|
||||
test('passive-pull runtime honors the same persisted Pi root as the app', () => {
|
||||
const home = mkdtempSync(join(tmpdir(), 'obelisk-pi-custom-runtime-'));
|
||||
const defaultRoot = join(home, '.pi', 'agent', 'sessions');
|
||||
const customRoot = join(home, 'custom-pi-sessions');
|
||||
const sessionPath = join(customRoot, '--tmp-pi-real-tool--', 'session.jsonl');
|
||||
mkdirSync(defaultRoot, { recursive: true });
|
||||
mkdirSync(dirname(sessionPath), { recursive: true });
|
||||
writeFileSync(
|
||||
sessionPath,
|
||||
readFileSync(new URL('./fixtures/pi/tool-session.jsonl', import.meta.url)),
|
||||
);
|
||||
const settingsPath = join(home, '.obelisk', 'settings.json');
|
||||
mkdirSync(dirname(settingsPath), { recursive: true });
|
||||
writeFileSync(settingsPath, JSON.stringify({
|
||||
providerRoots: { pi: customRoot },
|
||||
}));
|
||||
|
||||
const coreUrl = pathToFileURL(join(process.cwd(), 'packages/core/src/core.ts')).href;
|
||||
const script = `
|
||||
import { executeQuery } from ${JSON.stringify(coreUrl)};
|
||||
const result = await executeQuery("return sessions({ source: 'pi', limit: 5 });");
|
||||
process.stdout.write(JSON.stringify(result));
|
||||
`;
|
||||
const env = { ...process.env, HOME: home, USERPROFILE: home };
|
||||
delete env.PI_CODING_AGENT_DIR;
|
||||
delete env.PI_CODING_AGENT_SESSION_DIR;
|
||||
const run = spawnSync(process.execPath, ['--experimental-strip-types', '--input-type=module', '-e', script], {
|
||||
cwd: process.cwd(),
|
||||
env,
|
||||
encoding: 'utf8',
|
||||
});
|
||||
|
||||
assert.equal(run.status, 0, run.stderr);
|
||||
const sessions = JSON.parse(run.stdout);
|
||||
assert.equal(sessions.length, 1);
|
||||
assert.equal(sessions[0].source, 'pi');
|
||||
assert.equal(sessions[0].title, 'Tool probe');
|
||||
});
|
||||
|
||||
test('passive CLI operations report an incomplete Pi inventory without hiding partial results', () => {
|
||||
for (const command of ['search', 'query', 'attune']) {
|
||||
const home = mkdtempSync(join(tmpdir(), `obelisk-pi-partial-${command}-`));
|
||||
const piRoot = join(home, '.pi', 'agent', 'sessions');
|
||||
mkdirSync(dirname(piRoot), { recursive: true });
|
||||
writeFileSync(piRoot, 'not a directory');
|
||||
const queryPath = join(home, 'query.mjs');
|
||||
writeFileSync(
|
||||
queryPath,
|
||||
command === 'attune'
|
||||
? 'return null;'
|
||||
: "return sessions({ source: 'pi', limit: 5 });",
|
||||
);
|
||||
const args = command === 'search'
|
||||
? ['--search', 'partial-probe']
|
||||
: [`--${command}`, queryPath];
|
||||
|
||||
const result = runCli(args, {
|
||||
home,
|
||||
env: { PI_CODING_AGENT_DIR: '', PI_CODING_AGENT_SESSION_DIR: '' },
|
||||
});
|
||||
|
||||
assert.equal(result.status, 0, result.stderr || result.stdout);
|
||||
assert.deepEqual(JSON.parse(result.stdout), command === 'attune' ? null : []);
|
||||
assert.match(result.stderr, /Warning: incomplete pi source inventory/);
|
||||
assert.match(
|
||||
result.stderr,
|
||||
new RegExp(piRoot.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')),
|
||||
);
|
||||
assert.match(result.stderr, /ENOTDIR|not a directory/i);
|
||||
}
|
||||
});
|
||||
|
||||
test('CLI force rebuild rejects a structurally invalid Pi snapshot and preserves the last good index', () => {
|
||||
const home = mkdtempSync(join(tmpdir(), 'obelisk-pi-cli-force-'));
|
||||
const sessionPath = join(home, '.pi', 'agent', 'sessions', 'project', 'session.jsonl');
|
||||
mkdirSync(dirname(sessionPath), { recursive: true });
|
||||
const fixture = readFileSync(new URL('./fixtures/pi/tool-session.jsonl', import.meta.url), 'utf8');
|
||||
writeFileSync(sessionPath, fixture);
|
||||
const env = { PI_CODING_AGENT_DIR: '', PI_CODING_AGENT_SESSION_DIR: '' };
|
||||
|
||||
const first = runCli(['--build'], { home, env });
|
||||
assert.equal(first.status, 0, first.stderr || first.stdout);
|
||||
const dbPath = join(home, '.obelisk', 'obelisk.sqlite');
|
||||
let db = new DatabaseSync(dbPath, { readOnly: true });
|
||||
const before = {
|
||||
sessions: db.prepare("SELECT id,title,message_count,jsonl_path FROM sessions WHERE source='pi'").all()
|
||||
.map(row => ({ ...row })),
|
||||
messages: db.prepare("SELECT uuid,text FROM messages WHERE source='pi' ORDER BY uuid").all()
|
||||
.map(row => ({ ...row })),
|
||||
};
|
||||
db.close();
|
||||
|
||||
writeFileSync(sessionPath, [
|
||||
fixture.split('\n')[0],
|
||||
JSON.stringify({
|
||||
type: 'message',
|
||||
id: 'invalid-message',
|
||||
parentId: null,
|
||||
timestamp: '2026-08-02T10:00:01.000Z',
|
||||
message: null,
|
||||
}),
|
||||
'',
|
||||
].join('\n'));
|
||||
const failed = runCli(['--build'], { home, env });
|
||||
assert.equal(failed.status, 1, failed.stderr || failed.stdout);
|
||||
assert.match(JSON.parse(failed.stdout).error, /provider_failure/);
|
||||
|
||||
db = new DatabaseSync(dbPath, { readOnly: true });
|
||||
const after = {
|
||||
sessions: db.prepare("SELECT id,title,message_count,jsonl_path FROM sessions WHERE source='pi'").all()
|
||||
.map(row => ({ ...row })),
|
||||
messages: db.prepare("SELECT uuid,text FROM messages WHERE source='pi' ORDER BY uuid").all()
|
||||
.map(row => ({ ...row })),
|
||||
};
|
||||
db.close();
|
||||
assert.deepEqual(after, before);
|
||||
});
|
||||
|
||||
test('malformed official Pi settings use Pi 0.83 default-root fallback', () => {
|
||||
const home = mkdtempSync(join(tmpdir(), 'obelisk-pi-cli-settings-'));
|
||||
const agentDir = join(home, '.pi', 'agent');
|
||||
const customRoot = join(home, 'custom-pi-sessions');
|
||||
const customPath = join(customRoot, 'project', 'custom.jsonl');
|
||||
const defaultPath = join(agentDir, 'sessions', 'project', 'default.jsonl');
|
||||
const settingsPath = join(agentDir, 'settings.json');
|
||||
const fixture = readFileSync(new URL('./fixtures/pi/tool-session.jsonl', import.meta.url), 'utf8');
|
||||
mkdirSync(dirname(customPath), { recursive: true });
|
||||
mkdirSync(dirname(defaultPath), { recursive: true });
|
||||
writeFileSync(customPath, fixture);
|
||||
writeFileSync(settingsPath, JSON.stringify({ sessionDir: customRoot }));
|
||||
const env = { PI_CODING_AGENT_DIR: '', PI_CODING_AGENT_SESSION_DIR: '' };
|
||||
assert.equal(runCli(['--build'], { home, env }).status, 0);
|
||||
|
||||
const lure = fixture.trim().split('\n').map(line => JSON.parse(line));
|
||||
lure[0] = { ...lure[0], id: 'default-lure', cwd: '/tmp/default-lure' };
|
||||
writeFileSync(defaultPath, `${lure.map(record => JSON.stringify(record)).join('\n')}\n`);
|
||||
writeFileSync(settingsPath, '{broken');
|
||||
const rebuilt = runCli(['--build'], { home, env });
|
||||
assert.equal(rebuilt.status, 0, rebuilt.stderr || rebuilt.stdout);
|
||||
|
||||
const db = new DatabaseSync(join(home, '.obelisk', 'obelisk.sqlite'), { readOnly: true });
|
||||
assert.deepEqual(
|
||||
db.prepare("SELECT jsonl_path FROM sessions WHERE source='pi'").all().map(row => row.jsonl_path),
|
||||
[defaultPath],
|
||||
);
|
||||
db.close();
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import { createClaudeProvider } from '../packages/core/src/providers/claude.ts';
|
||||
import { createCodexProvider } from '../packages/core/src/providers/codex.ts';
|
||||
import { createKimiProvider } from '../packages/core/src/providers/kimi.ts';
|
||||
|
||||
const providers = [
|
||||
['claude', createClaudeProvider, 'projects'],
|
||||
['codex', createCodexProvider, 'sessions'],
|
||||
['kimi', createKimiProvider, 'sessions'],
|
||||
];
|
||||
|
||||
for (const [name, createProvider, inventoryDir] of providers) {
|
||||
test(`${name} reports directory enumeration failures`, () => {
|
||||
const root = mkdtempSync(join(tmpdir(), `obelisk-${name}-inventory-`));
|
||||
const sourcePath = join(root, inventoryDir);
|
||||
writeFileSync(sourcePath, 'not a directory');
|
||||
let issue;
|
||||
try {
|
||||
const units = createProvider({ rootDir: root }).discover({
|
||||
lastCursor: () => null,
|
||||
reportIncompleteInventory(value) { issue = value; },
|
||||
});
|
||||
assert.deepEqual(units, []);
|
||||
assert.equal(issue.path, sourcePath);
|
||||
assert.match(issue.error, /ENOTDIR|not a directory/i);
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test(`${name} treats a missing source as incomplete only when prior sessions exist`, () => {
|
||||
const root = join(mkdtempSync(join(tmpdir(), `obelisk-${name}-missing-`)), 'absent');
|
||||
const provider = createProvider({ rootDir: root });
|
||||
const issues = [];
|
||||
const context = {
|
||||
lastCursor: () => null,
|
||||
reportIncompleteInventory(value) { issues.push(value); },
|
||||
};
|
||||
|
||||
assert.deepEqual(provider.discover(context), []);
|
||||
assert.deepEqual(issues, []);
|
||||
assert.deepEqual(provider.discover({
|
||||
...context,
|
||||
indexedSessions: () => [{ sessionId: 'prior', jsonlPath: '/prior/source' }],
|
||||
}), []);
|
||||
assert.deepEqual(issues, [{
|
||||
path: join(root, inventoryDir),
|
||||
error: 'Source folder is unavailable',
|
||||
}]);
|
||||
});
|
||||
}
|
||||
@@ -61,12 +61,14 @@ test('built-in provider registry exposes every source without caller-side branch
|
||||
claude: '/sources/claude',
|
||||
codex: '/sources/codex',
|
||||
kimi: '/sources/kimi',
|
||||
pi: '/sources/pi',
|
||||
});
|
||||
|
||||
assert.deepEqual(registry.catalog().map(({ id, name }) => ({ id, name })), [
|
||||
{ id: 'claude', name: 'Claude Code' },
|
||||
{ id: 'codex', name: 'Codex' },
|
||||
{ id: 'kimi', name: 'Kimi Code' },
|
||||
{ id: 'pi', name: 'Pi' },
|
||||
]);
|
||||
assert.deepEqual(registry.watchRoots(), [
|
||||
'/sources/claude/projects',
|
||||
@@ -75,5 +77,6 @@ test('built-in provider registry exposes every source without caller-side branch
|
||||
'/sources/codex/session_index.jsonl',
|
||||
'/sources/kimi/sessions',
|
||||
'/sources/kimi/session_index.jsonl',
|
||||
'/sources/pi',
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -7,6 +7,6 @@ test('canonical transcript persistence schema changes only by explicit decision'
|
||||
const schema = readFileSync(new URL('../packages/core/src/schema.sql', import.meta.url));
|
||||
assert.equal(
|
||||
createHash('sha256').update(schema).digest('hex'),
|
||||
'ef5d0eea6f91c50e78ca5e28ecdc7b3ed5db83db59200642cc25866158f9d307',
|
||||
'0218f39cb41dabd055eed895cd08906d9f93a856fc2a30850d2d6cb8ee63e1cf',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -148,6 +148,10 @@ test('provider-classified hidden context never reaches session detail', () => {
|
||||
records.filter(record => record.kind === 'message' && record.visibility === 'hidden').length,
|
||||
2,
|
||||
);
|
||||
assert.equal(
|
||||
records.find(record => record.kind === 'session').message_count,
|
||||
1,
|
||||
);
|
||||
});
|
||||
|
||||
test('provider normalization removes only structural image wrappers before deduplication', () => {
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import {
|
||||
createConfiguredBuiltinProviderRuntime,
|
||||
readPersistedProviderSettings,
|
||||
} from '../packages/core/src/provider-settings.ts';
|
||||
import { createProviderRegistry } from '../packages/core/src/providers/registry.ts';
|
||||
import {
|
||||
buildSourceCatalog,
|
||||
@@ -8,10 +15,10 @@ import {
|
||||
setPersistedSetting,
|
||||
} from '../app/src/main/provider-settings.ts';
|
||||
|
||||
function provider(id, defaultRoot, color) {
|
||||
function provider(id, defaultRoot, color, descriptor = {}) {
|
||||
return {
|
||||
name: id,
|
||||
descriptor: { id, name: `${id} name`, vendor: `${id} vendor`, defaultRoot, color },
|
||||
descriptor: { id, name: `${id} name`, vendor: `${id} vendor`, defaultRoot, color, ...descriptor },
|
||||
watchRoots: () => [],
|
||||
discover: () => [],
|
||||
*parse() { yield* []; return null; },
|
||||
@@ -73,3 +80,147 @@ test('removing a generic provider root restores its descriptor default', () => {
|
||||
assert.equal(setPersistedSetting(persisted, 'providerRoots.gamma', null), true);
|
||||
assert.deepEqual(resolveProviderRoots(registry, persisted), { gamma: '/default/gamma' });
|
||||
});
|
||||
|
||||
test('source catalog surfaces exact provider issues without hiding indexed sessions', () => {
|
||||
const registry = createProviderRegistry([
|
||||
provider('alpha', '/default/alpha', '#112233'),
|
||||
]);
|
||||
|
||||
assert.deepEqual(buildSourceCatalog({
|
||||
registry,
|
||||
roots: { alpha: '/custom/alpha' },
|
||||
stats: new Map([
|
||||
['alpha', { sessionCount: 2, lastIndexed: '2026-07-20T10:00:00.000Z' }],
|
||||
]),
|
||||
sourceIssues: [{
|
||||
provider: 'alpha',
|
||||
path: '/custom/alpha/locked',
|
||||
error: 'EACCES: permission denied',
|
||||
}],
|
||||
pathExists: () => true,
|
||||
}), [{
|
||||
id: 'alpha',
|
||||
name: 'alpha name',
|
||||
vendor: 'alpha vendor',
|
||||
color: '#112233',
|
||||
path: '/custom/alpha',
|
||||
settingKey: 'providerRoots.alpha',
|
||||
exists: true,
|
||||
sessionCount: 2,
|
||||
lastIndexed: '2026-07-20T10:00:00.000Z',
|
||||
status: 'warn',
|
||||
statusText: 'Index issue: /custom/alpha/locked — EACCES: permission denied',
|
||||
}]);
|
||||
});
|
||||
|
||||
test('an ambiguous provider default stays omitted until the user selects a root', () => {
|
||||
const registry = createProviderRegistry([
|
||||
provider('relative', '/fallback/relative', '#999999', {
|
||||
requiresExplicitRoot: true,
|
||||
rootResolutionReason: 'Relative runtime setting needs an explicit folder',
|
||||
}),
|
||||
]);
|
||||
|
||||
assert.deepEqual(resolveProviderRoots(registry), {});
|
||||
assert.deepEqual(resolveProviderRoots(registry, {
|
||||
providerRoots: { relative: '/fallback/relative' },
|
||||
}), {
|
||||
relative: '/fallback/relative',
|
||||
});
|
||||
assert.deepEqual(buildSourceCatalog({
|
||||
registry,
|
||||
roots: {},
|
||||
pathExists: () => true,
|
||||
}), [{
|
||||
id: 'relative',
|
||||
name: 'relative name',
|
||||
vendor: 'relative vendor',
|
||||
color: '#999999',
|
||||
path: '/fallback/relative',
|
||||
settingKey: 'providerRoots.relative',
|
||||
exists: true,
|
||||
sessionCount: 0,
|
||||
lastIndexed: '',
|
||||
status: 'error',
|
||||
statusText: 'Relative runtime setting needs an explicit folder',
|
||||
}]);
|
||||
});
|
||||
|
||||
test('provider roots expand a persisted home-relative path before registry construction', () => {
|
||||
const registry = createProviderRegistry([
|
||||
provider('alpha', '/default/alpha', '#112233'),
|
||||
]);
|
||||
|
||||
assert.deepEqual(resolveProviderRoots(
|
||||
registry,
|
||||
{ providerRoots: { alpha: '~/custom/sessions' } },
|
||||
{ homeDir: '/home/probe' },
|
||||
), {
|
||||
alpha: '/home/probe/custom/sessions',
|
||||
});
|
||||
});
|
||||
|
||||
test('relative provider roots never depend on the Obelisk process cwd', () => {
|
||||
const registry = createProviderRegistry([
|
||||
provider('alpha', '/default/alpha', '#112233'),
|
||||
provider('explicit', '/fallback/explicit', '#445566', { requiresExplicitRoot: true }),
|
||||
]);
|
||||
|
||||
assert.deepEqual(resolveProviderRoots(registry, {
|
||||
providerRoots: { alpha: './alpha', explicit: '../explicit' },
|
||||
}), {});
|
||||
});
|
||||
|
||||
test('an invalid persisted root disables that provider instead of selecting its default', () => {
|
||||
const runtime = createConfiguredBuiltinProviderRuntime({
|
||||
providerRoots: { claude: './relative-claude' },
|
||||
}, {
|
||||
homeDir: '/home/probe',
|
||||
baseRoots: { claude: '/default/claude' },
|
||||
});
|
||||
const claude = runtime.registry.get('claude');
|
||||
let issue;
|
||||
|
||||
assert.equal(runtime.roots.claude, undefined);
|
||||
assert.equal(claude.descriptor.requiresExplicitRoot, true);
|
||||
assert.deepEqual(claude.watchRoots('/default/claude'), []);
|
||||
assert.deepEqual(claude.discover({
|
||||
lastCursor: () => null,
|
||||
reportIncompleteInventory(value) {
|
||||
issue = value;
|
||||
},
|
||||
}), []);
|
||||
assert.deepEqual(issue, {
|
||||
path: '/default/claude',
|
||||
error: 'Configured claude root must be absolute or start with ~',
|
||||
});
|
||||
});
|
||||
|
||||
test('malformed provider root containers cannot select defaults and are repairable', () => {
|
||||
const registry = createProviderRegistry([
|
||||
provider('alpha', '/default/alpha', '#112233'),
|
||||
]);
|
||||
|
||||
assert.deepEqual(resolveProviderRoots(registry, { providerRoots: [] }), {});
|
||||
assert.deepEqual(resolveProviderRoots(registry, { providerRoots: 'invalid' }), {});
|
||||
|
||||
const persisted = { providerRoots: [] };
|
||||
assert.equal(setPersistedSetting(persisted, 'providerRoots.alpha', '/custom/alpha'), true);
|
||||
assert.deepEqual(persisted.providerRoots, { alpha: '/custom/alpha' });
|
||||
});
|
||||
|
||||
test('settings reader rejects malformed provider root containers', () => {
|
||||
const directory = mkdtempSync(join(tmpdir(), 'obelisk-provider-settings-'));
|
||||
const settingsPath = join(directory, 'settings.json');
|
||||
try {
|
||||
for (const providerRoots of [[], 'invalid']) {
|
||||
writeFileSync(settingsPath, JSON.stringify({ providerRoots }));
|
||||
const result = readPersistedProviderSettings(settingsPath);
|
||||
assert.equal(result.ok, false);
|
||||
assert.deepEqual(result.settings, {});
|
||||
assert.match(result.error, /providerRoots are not an object/);
|
||||
}
|
||||
} finally {
|
||||
rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -31,6 +31,8 @@ test('raw query delegates source semantics to the registered provider', () => {
|
||||
.run('alpha:message', 'alpha:session', 'alpha:agent', 'alpha');
|
||||
db.prepare('INSERT INTO subagents (agent_id,session_id,description) VALUES (?,?,?)')
|
||||
.run('alpha:agent', 'alpha:session', 'agent metadata');
|
||||
db.prepare('INSERT INTO index_state (jsonl_path,mtime,lines_processed,cursor) VALUES (?,?,?,?)')
|
||||
.run('/alpha/session.data', 10, 1, 'committed-alpha-cursor');
|
||||
|
||||
const result = createQueryApi(db, { providerRegistry: registry }).raw('alpha:message', {
|
||||
offset: 2,
|
||||
@@ -38,11 +40,12 @@ test('raw query delegates source semantics to the registered provider', () => {
|
||||
});
|
||||
|
||||
assert.deepEqual(result, {
|
||||
text: '2345', totalLength: 10, offset: 2, limit: 4, hasMore: true,
|
||||
text: '2345', totalLength: 10, offset: 2, limit: 4, hasMore: true, visibility: 'visible',
|
||||
});
|
||||
assert.equal(calls.length, 1);
|
||||
assert.equal(calls[0].source, 'alpha');
|
||||
assert.equal(calls[0].session.id, 'alpha:session');
|
||||
assert.equal(calls[0].cursor, 'committed-alpha-cursor');
|
||||
assert.equal(calls[0].subagent.description, 'agent metadata');
|
||||
db.close();
|
||||
});
|
||||
|
||||
+287
-7
@@ -42,7 +42,8 @@ function searchDb() {
|
||||
CREATE TABLE messages (
|
||||
uuid TEXT PRIMARY KEY, session_id TEXT, text TEXT, role TEXT,
|
||||
timestamp TEXT, model TEXT, cwd TEXT, content_type TEXT,
|
||||
is_meta INTEGER DEFAULT 0, source TEXT DEFAULT 'claude'
|
||||
is_meta INTEGER DEFAULT 0, visibility TEXT DEFAULT 'visible',
|
||||
source TEXT DEFAULT 'claude'
|
||||
);
|
||||
CREATE VIRTUAL TABLE messages_fts USING fts5(
|
||||
uuid UNINDEXED, session_id UNINDEXED, text,
|
||||
@@ -54,13 +55,16 @@ function searchDb() {
|
||||
VALUES (?, ?, ?, ?)
|
||||
`).run('sid-search', 'Search session', 'quiet-zero', '2026-06-10T10:00:00Z');
|
||||
const insert = db.prepare(`
|
||||
INSERT INTO messages (uuid, session_id, text, role, timestamp, model, cwd, content_type, is_meta)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
INSERT INTO messages (uuid, session_id, text, role, timestamp, model, cwd, content_type, is_meta, visibility)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
insert.run('msg-meta', 'sid-search', 'needle injected caveat', 'user', '2026-06-10T10:00:30Z', null, '/tmp/quiet-zero', 'text', 1);
|
||||
insert.run('msg-text', 'sid-search', 'needle visible reply', 'assistant', '2026-06-10T10:01:00Z', 'claude-opus', '/tmp/quiet-zero', 'text', 0);
|
||||
insert.run('msg-meta-near', 'sid-search', '<command-name>/exit</command-name>', 'user', '2026-06-10T10:01:30Z', null, '/tmp/quiet-zero', 'text', 1);
|
||||
insert.run('msg-thinking', 'sid-search', 'nearby reasoning trace', 'assistant', '2026-06-10T10:02:00Z', 'claude-opus', '/tmp/quiet-zero', 'thinking', 0);
|
||||
insert.run('msg-meta', 'sid-search', 'needle injected caveat', 'user', '2026-06-10T10:00:30Z', null, '/tmp/quiet-zero', 'text', 1, 'visible');
|
||||
insert.run('msg-text', 'sid-search', 'needle visible reply', 'assistant', '2026-06-10T10:01:00Z', 'claude-opus', '/tmp/quiet-zero', 'text', 0, 'visible');
|
||||
insert.run('msg-meta-near', 'sid-search', '<command-name>/exit</command-name>', 'user', '2026-06-10T10:01:30Z', null, '/tmp/quiet-zero', 'text', 1, 'visible');
|
||||
insert.run('msg-thinking', 'sid-search', 'nearby reasoning trace', 'assistant', '2026-06-10T10:02:00Z', 'claude-opus', '/tmp/quiet-zero', 'thinking', 0, 'visible');
|
||||
insert.run('msg-inactive', 'sid-search', 'needle superseded experiment', 'assistant', '2026-06-10T10:02:30Z', 'claude-opus', '/tmp/quiet-zero', 'text', 0, 'inactive');
|
||||
insert.run('msg-inactive-meta', 'sid-search', 'needle superseded injected', 'user', '2026-06-10T10:02:40Z', null, '/tmp/quiet-zero', 'text', 1, 'inactive');
|
||||
insert.run('msg-hidden', 'sid-search', 'needle abandoned branch', 'assistant', '2026-06-10T10:03:00Z', 'claude-opus', '/tmp/quiet-zero', 'text', 0, 'hidden');
|
||||
db.exec("INSERT INTO messages_fts(messages_fts) VALUES('rebuild')");
|
||||
return db;
|
||||
}
|
||||
@@ -102,16 +106,292 @@ test('search and thread omit meta messages by default and expose them on request
|
||||
const withMeta = api.search('injected', { includeMeta: true, limit: 5 });
|
||||
assert.equal(withMeta[0].message.uuid, 'msg-meta');
|
||||
assert.equal(withMeta[0].message.is_meta, 1);
|
||||
assert.deepEqual(api.search('abandoned', { includeMeta: true, limit: 5 }), []);
|
||||
|
||||
assert.deepEqual(api.thread('sid-search').map(m => m.uuid), ['msg-text', 'msg-thinking']);
|
||||
assert.deepEqual(
|
||||
api.thread('sid-search', { includeMeta: true }).map(m => m.uuid),
|
||||
['msg-meta', 'msg-text', 'msg-meta-near', 'msg-thinking'],
|
||||
);
|
||||
assert.deepEqual(
|
||||
api.thread('sid-search', { includeInactive: true }).map(m => [m.uuid, m.visibility]),
|
||||
[
|
||||
['msg-text', 'visible'],
|
||||
['msg-thinking', 'visible'],
|
||||
['msg-inactive', 'inactive'],
|
||||
],
|
||||
);
|
||||
|
||||
db.close();
|
||||
});
|
||||
|
||||
test('inactive search is opt-in, orthogonal to meta filtering, and always labeled', () => {
|
||||
const db = searchDb();
|
||||
const api = createQueryApi(db);
|
||||
|
||||
assert.deepEqual(api.search('superseded', { limit: 5 }), []);
|
||||
const inactive = api.search('superseded', { includeInactive: true, limit: 5 });
|
||||
assert.deepEqual(inactive.map(row => [row.message.uuid, row.message.visibility]), [
|
||||
['msg-inactive', 'inactive'],
|
||||
]);
|
||||
assert.equal(
|
||||
inactive[0].context.every(row => row.visibility === 'visible' || row.visibility === 'inactive'),
|
||||
true,
|
||||
);
|
||||
|
||||
const withMeta = api.search('superseded', {
|
||||
includeInactive: true,
|
||||
includeMeta: true,
|
||||
limit: 5,
|
||||
});
|
||||
assert.deepEqual(
|
||||
withMeta.map(row => [row.message.uuid, row.message.visibility]).sort(),
|
||||
[
|
||||
['msg-inactive', 'inactive'],
|
||||
['msg-inactive-meta', 'inactive'],
|
||||
],
|
||||
);
|
||||
assert.deepEqual(api.search('abandoned', { includeInactive: true, includeMeta: true }), []);
|
||||
db.close();
|
||||
});
|
||||
|
||||
test('context and trace reject hidden targets and omit hidden ancestors', () => {
|
||||
const db = new DatabaseSync(':memory:');
|
||||
db.exec(SCHEMA);
|
||||
db.prepare('INSERT INTO sessions (id,title,source) VALUES (?,?,?)')
|
||||
.run('sid-chain', 'Visibility chain', 'pi');
|
||||
const insert = db.prepare(`
|
||||
INSERT INTO messages (
|
||||
uuid,session_id,type,parent_uuid,role,text,timestamp,visibility,source
|
||||
) VALUES (?,?,?,?,?,?,?,?,?)
|
||||
`);
|
||||
insert.run('visible-root', 'sid-chain', 'user', null, 'user', 'root', '2026-08-02T10:00:00Z', 'visible', 'pi');
|
||||
insert.run('hidden-parent', 'sid-chain', 'assistant', 'visible-root', 'assistant', 'secret', '2026-08-02T10:00:01Z', 'hidden', 'pi');
|
||||
insert.run('visible-child', 'sid-chain', 'user', 'hidden-parent', 'user', 'continue', '2026-08-02T10:00:02Z', 'visible', 'pi');
|
||||
insert.run('inactive-child', 'sid-chain', 'assistant', 'visible-root', 'assistant', 'superseded', '2026-08-02T10:00:03Z', 'inactive', 'pi');
|
||||
|
||||
const api = createQueryApi(db);
|
||||
assert.equal(api.context('hidden-parent'), null);
|
||||
assert.equal(api.context('hidden-parent', { includeInactive: true }), null);
|
||||
assert.deepEqual(api.trace('hidden-parent'), []);
|
||||
assert.deepEqual(api.trace('hidden-parent', { includeInactive: true }), []);
|
||||
assert.equal(api.context('inactive-child'), null);
|
||||
assert.deepEqual(api.trace('inactive-child'), []);
|
||||
assert.deepEqual(
|
||||
api.context('inactive-child', { includeInactive: true }).parentChain
|
||||
.map(message => [message.uuid, message.visibility]),
|
||||
[['visible-root', 'visible']],
|
||||
);
|
||||
assert.deepEqual(
|
||||
api.trace('inactive-child', { includeInactive: true })
|
||||
.map(message => [message.uuid, message.visibility]),
|
||||
[
|
||||
['visible-root', 'visible'],
|
||||
['inactive-child', 'inactive'],
|
||||
],
|
||||
);
|
||||
assert.deepEqual(
|
||||
api.context('visible-child').parentChain.map(message => message.uuid),
|
||||
['visible-root'],
|
||||
);
|
||||
assert.deepEqual(
|
||||
api.trace('visible-child').map(message => message.uuid),
|
||||
['visible-root', 'visible-child'],
|
||||
);
|
||||
db.close();
|
||||
});
|
||||
|
||||
test('raw rejects hidden targets and labels explicitly included inactive evidence', () => {
|
||||
const db = searchDb();
|
||||
const providerRegistry = {
|
||||
raw: ({ messageUuid }) => ({
|
||||
text: `raw:${messageUuid}`,
|
||||
totalLength: `raw:${messageUuid}`.length,
|
||||
}),
|
||||
};
|
||||
const api = createQueryApi(db, { providerRegistry });
|
||||
|
||||
assert.equal(api.raw('msg-hidden'), null);
|
||||
assert.equal(api.raw('msg-hidden', { includeInactive: true }), null);
|
||||
assert.equal(api.raw('msg-inactive'), null);
|
||||
assert.deepEqual(
|
||||
api.raw('msg-inactive', { includeInactive: true }),
|
||||
{
|
||||
text: 'raw:msg-inactive',
|
||||
totalLength: 16,
|
||||
offset: 0,
|
||||
limit: 10000,
|
||||
hasMore: false,
|
||||
visibility: 'inactive',
|
||||
},
|
||||
);
|
||||
assert.equal(api.raw('msg-text').text, 'raw:msg-text');
|
||||
assert.equal(api.raw('msg-text').visibility, 'visible');
|
||||
db.close();
|
||||
});
|
||||
|
||||
test('failures nextMessages does not leak hidden branch messages', () => {
|
||||
const db = new DatabaseSync(':memory:');
|
||||
db.exec(SCHEMA);
|
||||
db.prepare('INSERT INTO sessions (id,title,source) VALUES (?,?,?)')
|
||||
.run('sid-failure', 'Failure branch', 'pi');
|
||||
const insertMessage = db.prepare(`
|
||||
INSERT INTO messages (uuid,session_id,type,role,text,timestamp,visibility,source)
|
||||
VALUES (?,?,?,?,?,?,?,?)
|
||||
`);
|
||||
insertMessage.run('failure-result', 'sid-failure', 'user', 'toolResult', 'failed', '2026-08-02T10:00:00Z', 'visible', 'pi');
|
||||
insertMessage.run('hidden-next', 'sid-failure', 'assistant', 'assistant', 'abandoned', '2026-08-02T10:00:01Z', 'hidden', 'pi');
|
||||
insertMessage.run('inactive-next', 'sid-failure', 'assistant', 'assistant', 'superseded', '2026-08-02T10:00:02Z', 'inactive', 'pi');
|
||||
insertMessage.run('visible-next', 'sid-failure', 'assistant', 'assistant', 'recovered', '2026-08-02T10:00:03Z', 'visible', 'pi');
|
||||
db.prepare(`
|
||||
INSERT INTO tool_calls (id,message_uuid,session_id,name,input_json)
|
||||
VALUES (?,?,?,?,?)
|
||||
`).run('call-failure', 'failure-result', 'sid-failure', 'read', '{}');
|
||||
db.prepare(`
|
||||
INSERT INTO tool_results (tool_use_id,message_uuid,session_id,content,is_error)
|
||||
VALUES (?,?,?,?,?)
|
||||
`).run('call-failure', 'failure-result', 'sid-failure', 'failed', 1);
|
||||
|
||||
const row = createQueryApi(db).failures('sid-failure')[0];
|
||||
assert.deepEqual(row.nextMessages.map(message => message.uuid), ['visible-next']);
|
||||
assert.equal(row.visibility, 'visible');
|
||||
assert.deepEqual(
|
||||
createQueryApi(db).failures({ sessionId: 'sid-failure', includeInactive: true })[0]
|
||||
.nextMessages.map(message => [message.uuid, message.visibility]),
|
||||
[
|
||||
['inactive-next', 'inactive'],
|
||||
['visible-next', 'visible'],
|
||||
],
|
||||
);
|
||||
db.close();
|
||||
});
|
||||
|
||||
test('failures gates both result and linked call message visibility', () => {
|
||||
const db = new DatabaseSync(':memory:');
|
||||
db.exec(SCHEMA);
|
||||
db.prepare('INSERT INTO sessions (id,title,source) VALUES (?,?,?)')
|
||||
.run('sid-edge-visibility', 'Tool edge visibility', 'pi');
|
||||
const insertMessage = db.prepare(`
|
||||
INSERT INTO messages (uuid,session_id,type,role,text,timestamp,visibility,source)
|
||||
VALUES (?,?,?,?,?,?,?,?)
|
||||
`);
|
||||
const insertCall = db.prepare(`
|
||||
INSERT INTO tool_calls (id,message_uuid,session_id,name,input_json,file_path)
|
||||
VALUES (?,?,?,?,?,?)
|
||||
`);
|
||||
const insertResult = db.prepare(`
|
||||
INSERT INTO tool_results (tool_use_id,message_uuid,session_id,content,is_error)
|
||||
VALUES (?,?,?,?,?)
|
||||
`);
|
||||
for (const [index, callVisibility] of ['visible', 'inactive', 'hidden'].entries()) {
|
||||
const callId = `call-${callVisibility}`;
|
||||
insertMessage.run(
|
||||
`message-${callVisibility}`,
|
||||
'sid-edge-visibility',
|
||||
'assistant',
|
||||
'assistant',
|
||||
null,
|
||||
`2026-08-02T10:00:0${index * 2}Z`,
|
||||
callVisibility,
|
||||
'pi',
|
||||
);
|
||||
insertMessage.run(
|
||||
`result-${callVisibility}`,
|
||||
'sid-edge-visibility',
|
||||
'user',
|
||||
'toolResult',
|
||||
`failed-${callVisibility}`,
|
||||
`2026-08-02T10:00:0${index * 2 + 1}Z`,
|
||||
'visible',
|
||||
'pi',
|
||||
);
|
||||
insertCall.run(
|
||||
callId,
|
||||
`message-${callVisibility}`,
|
||||
'sid-edge-visibility',
|
||||
'read',
|
||||
JSON.stringify({ path: `/${callVisibility}` }),
|
||||
`/${callVisibility}`,
|
||||
);
|
||||
insertResult.run(
|
||||
callId,
|
||||
`result-${callVisibility}`,
|
||||
'sid-edge-visibility',
|
||||
`failed-${callVisibility}`,
|
||||
1,
|
||||
);
|
||||
}
|
||||
const api = createQueryApi(db);
|
||||
|
||||
assert.deepEqual(
|
||||
api.failures('sid-edge-visibility').map(record => record.toolCall.id),
|
||||
['call-visible'],
|
||||
);
|
||||
assert.deepEqual(
|
||||
api.failures({ sessionId: 'sid-edge-visibility', includeInactive: true })
|
||||
.map(record => record.toolCall.id)
|
||||
.sort(),
|
||||
['call-inactive', 'call-visible'],
|
||||
);
|
||||
db.close();
|
||||
});
|
||||
|
||||
test('summaries, file history, and failures expose inactive rows only on request', () => {
|
||||
const db = new DatabaseSync(':memory:');
|
||||
db.exec(SCHEMA);
|
||||
db.prepare('INSERT INTO sessions (id,title,source) VALUES (?,?,?)')
|
||||
.run('sid-structured', 'Structured visibility', 'pi');
|
||||
const insertMessage = db.prepare(`
|
||||
INSERT INTO messages (uuid,session_id,type,role,text,timestamp,visibility,source)
|
||||
VALUES (?,?,?,?,?,?,?,?)
|
||||
`);
|
||||
const insertCall = db.prepare(`
|
||||
INSERT INTO tool_calls (id,message_uuid,session_id,name,input_json,file_path)
|
||||
VALUES (?,?,?,?,?,?)
|
||||
`);
|
||||
const insertResult = db.prepare(`
|
||||
INSERT INTO tool_results (tool_use_id,message_uuid,session_id,content,is_error)
|
||||
VALUES (?,?,?,?,?)
|
||||
`);
|
||||
const insertSummary = db.prepare(`
|
||||
INSERT INTO summaries (id,session_id,timestamp,source,content,visibility)
|
||||
VALUES (?,?,?,?,?,?)
|
||||
`);
|
||||
for (const [index, visibility] of ['visible', 'inactive', 'hidden'].entries()) {
|
||||
const suffix = visibility;
|
||||
const uuid = `message-${suffix}`;
|
||||
const callId = `call-${suffix}`;
|
||||
const timestamp = `2026-08-02T10:00:0${index}Z`;
|
||||
insertMessage.run(uuid, 'sid-structured', 'user', 'toolResult', suffix, timestamp, visibility, 'pi');
|
||||
insertCall.run(callId, uuid, 'sid-structured', 'read', '{}', '/tmp/visibility.ts');
|
||||
insertResult.run(callId, uuid, 'sid-structured', `failed-${suffix}`, 1);
|
||||
insertSummary.run(`summary-${suffix}`, 'sid-structured', timestamp, 'pi:branch_summary', suffix, visibility);
|
||||
}
|
||||
const api = createQueryApi(db);
|
||||
|
||||
assert.deepEqual(api.fileHistory('/tmp/visibility.ts').map(row => row.visibility), ['visible']);
|
||||
assert.deepEqual(
|
||||
api.fileHistory('/tmp/visibility.ts', { includeInactive: true }).map(row => row.visibility),
|
||||
['visible', 'inactive'],
|
||||
);
|
||||
assert.deepEqual(api.failures('sid-structured').map(row => row.visibility), ['visible']);
|
||||
assert.deepEqual(
|
||||
api.failures({ sessionId: 'sid-structured', includeInactive: true })
|
||||
.map(row => [row.visibility, row.result.visibility]),
|
||||
[
|
||||
['inactive', 'inactive'],
|
||||
['visible', 'visible'],
|
||||
],
|
||||
);
|
||||
assert.deepEqual(api.summaries('sid-structured').map(row => row.visibility), ['visible']);
|
||||
assert.deepEqual(
|
||||
api.summaries({ sessionId: 'sid-structured', includeInactive: true })
|
||||
.map(row => row.visibility),
|
||||
['inactive', 'visible'],
|
||||
);
|
||||
db.close();
|
||||
});
|
||||
|
||||
test('memories follows list-helper scalar opts and filters by query within scope', () => {
|
||||
const db = memoryDb();
|
||||
const api = createQueryApi(db);
|
||||
|
||||
@@ -31,6 +31,44 @@ test('runtime query scripts cannot call attune helpers', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('malformed Obelisk settings skip refresh without disabling provider-backed queries', () => {
|
||||
const home = tempHome();
|
||||
const projectDir = join(home, '.claude', 'projects', '-tmp-settings-recovery');
|
||||
const transcriptPath = join(projectDir, 'settings-recovery.jsonl');
|
||||
const scriptPath = join(home, 'query.mjs');
|
||||
mkdirSync(projectDir, { recursive: true });
|
||||
writeFileSync(transcriptPath, `${JSON.stringify({
|
||||
uuid: 'settings-recovery-user',
|
||||
type: 'user',
|
||||
timestamp: '2026-08-04T10:00:00.000Z',
|
||||
cwd: '/tmp/settings-recovery',
|
||||
message: { role: 'user', content: 'settings recovery evidence' },
|
||||
})}\n`);
|
||||
writeFileSync(scriptPath, `
|
||||
const hit = search('settings recovery evidence', { limit: 1 })[0];
|
||||
return {
|
||||
uuid: hit?.message.uuid ?? null,
|
||||
raw: hit ? raw(hit.message.uuid)?.text ?? null : null
|
||||
};
|
||||
`);
|
||||
|
||||
const indexed = runRuntime(['--query', scriptPath], { home });
|
||||
assert.equal(indexed.status, 0, indexed.stderr || indexed.stdout);
|
||||
assert.equal(JSON.parse(indexed.stdout).uuid, 'settings-recovery-user');
|
||||
|
||||
writeFileSync(join(home, '.obelisk', 'settings.json'), '{broken');
|
||||
const recovered = runRuntime(['--query', scriptPath], { home });
|
||||
assert.equal(recovered.status, 0, recovered.stderr || recovered.stdout);
|
||||
assert.match(recovered.stderr, /index refresh skipped/);
|
||||
assert.equal(JSON.parse(recovered.stdout).uuid, 'settings-recovery-user');
|
||||
assert.match(JSON.parse(recovered.stdout).raw, /settings recovery evidence/);
|
||||
|
||||
const rebuild = runRuntime(['--build'], { home });
|
||||
assert.equal(rebuild.status, 1);
|
||||
assert.match(JSON.parse(rebuild.stdout).error, /settings_unavailable/);
|
||||
assert.match(JSON.parse(rebuild.stdout).error, /Unable to read Obelisk settings/);
|
||||
});
|
||||
|
||||
test('runtime attune scripts expose only memory mutation helpers', () => {
|
||||
const home = tempHome();
|
||||
const memoryPath = join(home, 'memory.md');
|
||||
@@ -208,6 +246,76 @@ test('runtime indexes Codex root sessions into the shared query helpers', () =>
|
||||
assert.ok(payload.overviewSources.some(s => s.source === 'codex' && s.session_count === 1));
|
||||
});
|
||||
|
||||
test('runtime raw lookup uses the configured Codex root for child sessions', () => {
|
||||
const home = tempHome();
|
||||
const codexDir = join(home, 'custom-codex');
|
||||
const codexSessionDir = join(codexDir, 'sessions', '2026', '08', '04');
|
||||
const parentId = '019ec6ee-cebd-7431-9c93-ceec89a98a5f';
|
||||
const childId = '019ec739-9f75-7a02-ba2a-371986e23823';
|
||||
mkdirSync(codexSessionDir, { recursive: true });
|
||||
mkdirSync(join(home, '.obelisk'), { recursive: true });
|
||||
writeFileSync(join(home, '.obelisk', 'settings.json'), JSON.stringify({
|
||||
providerRoots: { codex: codexDir },
|
||||
}));
|
||||
writeFileSync(join(codexSessionDir, `a-parent-${parentId}.jsonl`), [
|
||||
JSON.stringify({
|
||||
timestamp: '2026-08-04T10:00:00.000Z',
|
||||
type: 'session_meta',
|
||||
payload: {
|
||||
id: parentId,
|
||||
timestamp: '2026-08-04T10:00:00.000Z',
|
||||
cwd: '/tmp/custom-codex-runtime',
|
||||
source: 'cli',
|
||||
},
|
||||
}),
|
||||
'',
|
||||
].join('\n'));
|
||||
writeFileSync(join(codexSessionDir, `b-child-${childId}.jsonl`), [
|
||||
JSON.stringify({
|
||||
timestamp: '2026-08-04T10:00:01.000Z',
|
||||
type: 'session_meta',
|
||||
payload: {
|
||||
id: childId,
|
||||
timestamp: '2026-08-04T10:00:01.000Z',
|
||||
cwd: '/tmp/custom-codex-runtime',
|
||||
source: {
|
||||
subagent: {
|
||||
thread_spawn: {
|
||||
parent_thread_id: parentId,
|
||||
agent_nickname: 'Plato',
|
||||
agent_role: 'worker',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
JSON.stringify({
|
||||
timestamp: '2026-08-04T10:00:02.000Z',
|
||||
type: 'event_msg',
|
||||
payload: {
|
||||
type: 'user_message',
|
||||
message: 'custom Codex child raw sentinel',
|
||||
images: [],
|
||||
local_images: [],
|
||||
text_elements: [],
|
||||
},
|
||||
}),
|
||||
'',
|
||||
].join('\n'));
|
||||
const scriptPath = join(home, 'query.mjs');
|
||||
writeFileSync(
|
||||
scriptPath,
|
||||
`return raw(${JSON.stringify(`codex:${childId}:000002`)}, { limit: 1000 });`,
|
||||
);
|
||||
|
||||
const result = runRuntime(['--query', scriptPath], { home });
|
||||
|
||||
assert.equal(result.status, 0, result.stderr || result.stdout);
|
||||
const raw = JSON.parse(result.stdout);
|
||||
assert.match(raw.text, /custom Codex child raw sentinel/);
|
||||
assert.equal(raw.visibility, 'visible');
|
||||
});
|
||||
|
||||
test('runtime skips Codex guardian review threads', () => {
|
||||
const home = tempHome();
|
||||
const codexSessionDir = join(home, '.codex', 'sessions', '2026', '06', '15');
|
||||
|
||||
@@ -103,6 +103,68 @@ test('canonical ordering is stable across provider and SQLite iteration order',
|
||||
assert.deepEqual(detail.messages.map(message => message.uuid), ['a', 'b']);
|
||||
});
|
||||
|
||||
test('session detail remains active-only across direct and persisted visibility values', () => {
|
||||
const message = (uuid, visibility) => ({
|
||||
kind: 'message',
|
||||
uuid,
|
||||
session_id: 'session',
|
||||
type: 'user',
|
||||
parent_uuid: null,
|
||||
timestamp: `2026-06-10T10:00:0${uuid.length}Z`,
|
||||
role: 'user',
|
||||
text: uuid,
|
||||
content_type: 'text',
|
||||
is_meta: 0,
|
||||
visibility,
|
||||
model: null,
|
||||
is_sidechain: 0,
|
||||
agent_id: null,
|
||||
input_tokens: null,
|
||||
output_tokens: null,
|
||||
cwd: null,
|
||||
skill: null,
|
||||
source: 'pi',
|
||||
});
|
||||
const summary = (id, visibility) => ({
|
||||
kind: 'summary',
|
||||
id,
|
||||
session_id: 'session',
|
||||
timestamp: null,
|
||||
source: 'pi:branch_summary',
|
||||
content: id,
|
||||
visibility,
|
||||
input_tokens: null,
|
||||
output_tokens: null,
|
||||
});
|
||||
const direct = assembleSessionDetail([
|
||||
message('visible', 'visible'),
|
||||
message('inactive', 'inactive'),
|
||||
message('hidden', 'hidden'),
|
||||
summary('visible-summary', 'visible'),
|
||||
summary('inactive-summary', 'inactive'),
|
||||
summary('hidden-summary', 'hidden'),
|
||||
]);
|
||||
assert.deepEqual(direct.messages.map(row => row.text), ['visible']);
|
||||
assert.deepEqual(direct.summaries.map(row => row.content), ['visible-summary']);
|
||||
|
||||
const persisted = assembleSessionDetail({
|
||||
messages: [
|
||||
{ ...message('visible', 'visible'), kind: undefined },
|
||||
{ ...message('inactive', 'inactive'), kind: undefined },
|
||||
{ ...message('hidden', 'hidden'), kind: undefined },
|
||||
{ ...message('unknown', 'future-state'), kind: undefined },
|
||||
],
|
||||
summaries: [
|
||||
{ ...summary('visible-summary', 'visible'), kind: undefined },
|
||||
{ ...summary('inactive-summary', 'inactive'), kind: undefined },
|
||||
{ ...summary('hidden-summary', 'hidden'), kind: undefined },
|
||||
{ ...summary('unknown-summary', 'future-state'), kind: undefined },
|
||||
],
|
||||
});
|
||||
assert.deepEqual(persisted.messages.map(row => row.text), ['visible']);
|
||||
assert.deepEqual(persisted.summaries.map(row => row.content), ['visible-summary']);
|
||||
});
|
||||
|
||||
test('direct session assembly rejects an incomplete provider delta', () => {
|
||||
assert.throws(() => assembleSessionDetail([{
|
||||
kind: 'session', id: 'session', title: null, project: null,
|
||||
|
||||
Reference in New Issue
Block a user