ai-hist 0.3.7 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +100 -2
- package/dist/cloud-client.d.ts +1 -0
- package/dist/cloud-client.d.ts.map +1 -1
- package/dist/cloud-client.js +2 -0
- package/dist/cloud-client.js.map +1 -1
- package/dist/cloud-push.d.ts +51 -0
- package/dist/cloud-push.d.ts.map +1 -0
- package/dist/cloud-push.js +113 -0
- package/dist/cloud-push.js.map +1 -0
- package/dist/cloud-push.test.d.ts +2 -0
- package/dist/cloud-push.test.d.ts.map +1 -0
- package/dist/cloud-push.test.js +82 -0
- package/dist/cloud-push.test.js.map +1 -0
- package/dist/index.d.ts +119 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +304 -10
- package/dist/index.js.map +1 -1
- package/dist/jsonl-sources.d.ts +4 -6
- package/dist/jsonl-sources.d.ts.map +1 -1
- package/dist/jsonl-sources.js +9 -12
- package/dist/jsonl-sources.js.map +1 -1
- package/dist/mcp-server.js +116 -1
- package/dist/mcp-server.js.map +1 -1
- package/dist/mcp-smoke.test.js +194 -44
- package/dist/mcp-smoke.test.js.map +1 -1
- package/dist/session-catalog.d.ts +257 -0
- package/dist/session-catalog.d.ts.map +1 -0
- package/dist/session-catalog.js +340 -0
- package/dist/session-catalog.js.map +1 -0
- package/dist/session-catalog.test.d.ts +2 -0
- package/dist/session-catalog.test.d.ts.map +1 -0
- package/dist/session-catalog.test.js +710 -0
- package/dist/session-catalog.test.js.map +1 -0
- package/dist/session-events.test.d.ts +2 -0
- package/dist/session-events.test.d.ts.map +1 -0
- package/dist/session-events.test.js +177 -0
- package/dist/session-events.test.js.map +1 -0
- package/package.json +1 -1
|
@@ -0,0 +1,710 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { execFile } from 'node:child_process';
|
|
4
|
+
import { EventEmitter } from 'node:events';
|
|
5
|
+
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
|
|
6
|
+
import { tmpdir } from 'node:os';
|
|
7
|
+
import { join } from 'node:path';
|
|
8
|
+
import { promisify } from 'node:util';
|
|
9
|
+
import initSqlJs from 'sql.js';
|
|
10
|
+
import { SESSION_CATALOG_CONTRACT_VERSION, DiscoveryError, discoverSessions, openAiHist, } from './index.js';
|
|
11
|
+
const execFileAsync = promisify(execFile);
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
// Fixtures
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
const NEW_SHAPE_DDL = `CREATE TABLE sessions (
|
|
16
|
+
session_id TEXT NOT NULL,
|
|
17
|
+
source TEXT NOT NULL,
|
|
18
|
+
cwd TEXT,
|
|
19
|
+
git_branch TEXT,
|
|
20
|
+
first_activity_ms INTEGER,
|
|
21
|
+
last_activity_ms INTEGER,
|
|
22
|
+
last_assistant_text TEXT,
|
|
23
|
+
raw_path TEXT,
|
|
24
|
+
parser_version INTEGER NOT NULL DEFAULT 1,
|
|
25
|
+
first_prompt TEXT,
|
|
26
|
+
models_json TEXT,
|
|
27
|
+
originator TEXT,
|
|
28
|
+
agent_version TEXT,
|
|
29
|
+
repo_url TEXT,
|
|
30
|
+
initial_commit TEXT,
|
|
31
|
+
workspace_roots_json TEXT,
|
|
32
|
+
source_stamp TEXT,
|
|
33
|
+
discovery_state TEXT,
|
|
34
|
+
PRIMARY KEY (session_id, source)
|
|
35
|
+
)`;
|
|
36
|
+
/** The pre-catalog `sessions` table, as an ai-hist 0.5.0 database has it. */
|
|
37
|
+
const OLD_SHAPE_DDL = `CREATE TABLE sessions (
|
|
38
|
+
session_id TEXT NOT NULL,
|
|
39
|
+
source TEXT NOT NULL,
|
|
40
|
+
cwd TEXT,
|
|
41
|
+
git_branch TEXT,
|
|
42
|
+
first_activity_ms INTEGER,
|
|
43
|
+
last_activity_ms INTEGER,
|
|
44
|
+
last_assistant_text TEXT,
|
|
45
|
+
raw_path TEXT,
|
|
46
|
+
parser_version INTEGER NOT NULL DEFAULT 1,
|
|
47
|
+
PRIMARY KEY (session_id, source)
|
|
48
|
+
)`;
|
|
49
|
+
const HISTORY_DDL = `CREATE TABLE history (
|
|
50
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
51
|
+
source TEXT NOT NULL,
|
|
52
|
+
session_id TEXT,
|
|
53
|
+
project TEXT,
|
|
54
|
+
prompt TEXT NOT NULL,
|
|
55
|
+
timestamp_ms INTEGER NOT NULL,
|
|
56
|
+
git_branch TEXT
|
|
57
|
+
)`;
|
|
58
|
+
async function writeDb(dbPath, build) {
|
|
59
|
+
const SQL = await initSqlJs();
|
|
60
|
+
const db = new SQL.Database();
|
|
61
|
+
try {
|
|
62
|
+
db.run(HISTORY_DDL);
|
|
63
|
+
build(db);
|
|
64
|
+
await writeFile(dbPath, Buffer.from(db.export()));
|
|
65
|
+
}
|
|
66
|
+
finally {
|
|
67
|
+
db.close();
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
/** A catalog covering every quirk the native contract calls out. */
|
|
71
|
+
async function writeCatalogDb(dbPath) {
|
|
72
|
+
await writeDb(dbPath, (db) => {
|
|
73
|
+
db.run(NEW_SHAPE_DDL);
|
|
74
|
+
const insert = db.prepare(`INSERT INTO sessions (session_id, source, cwd, git_branch, first_activity_ms,
|
|
75
|
+
last_activity_ms, last_assistant_text, raw_path, parser_version, first_prompt,
|
|
76
|
+
models_json, originator, agent_version, repo_url, initial_commit,
|
|
77
|
+
workspace_roots_json, source_stamp, discovery_state)
|
|
78
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`);
|
|
79
|
+
try {
|
|
80
|
+
// Newest. Fully ingested, with models and workspace roots.
|
|
81
|
+
insert.run([
|
|
82
|
+
'codex-1', 'codex', '/work/api', 'dev', 3_000, 5_000, 'done', '/raw/codex-1.jsonl', 2,
|
|
83
|
+
'add a retry', '["gpt-5.4","gpt-5.4-mini"]', 'codex_cli_rs', '0.148.0',
|
|
84
|
+
'git@github.com:acme/api.git', 'abc123', '["/work/api","/work/shared"]',
|
|
85
|
+
'v1:mtime-1', 'full',
|
|
86
|
+
]);
|
|
87
|
+
// Shallow-only: no last_assistant_text, no models.
|
|
88
|
+
insert.run([
|
|
89
|
+
'claude-1', 'claude', '/work/app', 'main', 1_000, 4_000, null, '/raw/claude-1.jsonl', 1,
|
|
90
|
+
'first human prompt', null, null, '1.2.3', null, null, null, 'v1:mtime-2', 'shallow',
|
|
91
|
+
]);
|
|
92
|
+
// Cursor: never a first_activity_ms; last_activity_ms is mtime-derived.
|
|
93
|
+
insert.run([
|
|
94
|
+
'cursor-1', 'cursor', '/work/app', null, null, 2_000, null, '/raw/cursor-1.db', 1,
|
|
95
|
+
null, 'not json at all', null, null, null, null, null, 'v1:mtime-3', 'shallow',
|
|
96
|
+
]);
|
|
97
|
+
// Relay: no cwd, and a legacy NULL discovery_state.
|
|
98
|
+
insert.run([
|
|
99
|
+
'relay-1', 'relay', null, null, 500, 1_000, 'relay reply', null, 1,
|
|
100
|
+
'relay thread', null, null, null, null, null, null, null, null,
|
|
101
|
+
]);
|
|
102
|
+
// No timestamps at all — must sort last, and must not surface via beforeMs.
|
|
103
|
+
insert.run([
|
|
104
|
+
'grok-1', 'grok', '/work/app', null, null, null, null, null, 1,
|
|
105
|
+
null, null, null, null, null, null, null, null, 'shallow',
|
|
106
|
+
]);
|
|
107
|
+
// Trajectories are derived records, not sessions: never listed.
|
|
108
|
+
insert.run([
|
|
109
|
+
'traj-1', 'trajectory', '/work/app', null, 9_000, 9_000, null, null, 1,
|
|
110
|
+
null, null, null, null, null, null, null, null, 'full',
|
|
111
|
+
]);
|
|
112
|
+
}
|
|
113
|
+
finally {
|
|
114
|
+
insert.free();
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* A catalog where one discovery pass stamped many sessions with the same
|
|
120
|
+
* millisecond, plus an undated tail — the shape a recency-only cursor drops
|
|
121
|
+
* rows from.
|
|
122
|
+
*/
|
|
123
|
+
async function writeTiedCatalogDb(dbPath) {
|
|
124
|
+
await writeDb(dbPath, (db) => {
|
|
125
|
+
db.run(NEW_SHAPE_DDL);
|
|
126
|
+
const insert = db.prepare(`INSERT INTO sessions (session_id, source, last_activity_ms, discovery_state)
|
|
127
|
+
VALUES (?, ?, ?, 'shallow')`);
|
|
128
|
+
try {
|
|
129
|
+
const rows = [
|
|
130
|
+
['tie-a', 'claude', 2_000],
|
|
131
|
+
['tie-b', 'claude', 2_000],
|
|
132
|
+
['tie-c', 'codex', 2_000],
|
|
133
|
+
['tie-d', 'codex', 2_000],
|
|
134
|
+
['old-a', 'claude', 1_000],
|
|
135
|
+
['old-b', 'cursor', 1_000],
|
|
136
|
+
['undated-a', 'claude', null],
|
|
137
|
+
['undated-b', 'codex', null],
|
|
138
|
+
['undated-c', 'codex', null],
|
|
139
|
+
];
|
|
140
|
+
for (const row of rows)
|
|
141
|
+
insert.run(row);
|
|
142
|
+
}
|
|
143
|
+
finally {
|
|
144
|
+
insert.free();
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
function ids(sessions) {
|
|
149
|
+
return sessions.map((session) => session.sessionId);
|
|
150
|
+
}
|
|
151
|
+
/** Identity of a row in the catalog's total order. */
|
|
152
|
+
function keys(sessions) {
|
|
153
|
+
return sessions.map((session) => `${session.source}:${session.sessionId}`);
|
|
154
|
+
}
|
|
155
|
+
async function withCatalog(prefix, write, body) {
|
|
156
|
+
const dir = await mkdtemp(join(tmpdir(), prefix));
|
|
157
|
+
const dbPath = join(dir, 'history.db');
|
|
158
|
+
try {
|
|
159
|
+
await write(dbPath);
|
|
160
|
+
await body(dbPath);
|
|
161
|
+
}
|
|
162
|
+
finally {
|
|
163
|
+
await rm(dir, { recursive: true, force: true });
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
// ---------------------------------------------------------------------------
|
|
167
|
+
// listSessionCatalog
|
|
168
|
+
// ---------------------------------------------------------------------------
|
|
169
|
+
test('listSessionCatalog orders by recency, excludes trajectories, and parses JSON columns', async () => {
|
|
170
|
+
await withCatalog('ai-hist-catalog-', writeCatalogDb, async (dbPath) => {
|
|
171
|
+
const hist = await openAiHist({ dbPath });
|
|
172
|
+
try {
|
|
173
|
+
const rows = hist.listSessionCatalog();
|
|
174
|
+
assert.deepEqual(ids(rows), ['codex-1', 'claude-1', 'cursor-1', 'relay-1', 'grok-1']);
|
|
175
|
+
assert.ok(!rows.some((row) => row.source === 'trajectory'));
|
|
176
|
+
const codex = rows[0];
|
|
177
|
+
assert.deepEqual(codex, {
|
|
178
|
+
source: 'codex',
|
|
179
|
+
sessionId: 'codex-1',
|
|
180
|
+
cwd: '/work/api',
|
|
181
|
+
gitBranch: 'dev',
|
|
182
|
+
firstActivityMs: 3_000,
|
|
183
|
+
lastActivityMs: 5_000,
|
|
184
|
+
firstPrompt: 'add a retry',
|
|
185
|
+
lastAssistantText: 'done',
|
|
186
|
+
models: ['gpt-5.4', 'gpt-5.4-mini'],
|
|
187
|
+
originator: 'codex_cli_rs',
|
|
188
|
+
agentVersion: '0.148.0',
|
|
189
|
+
repoUrl: 'git@github.com:acme/api.git',
|
|
190
|
+
initialCommit: 'abc123',
|
|
191
|
+
workspaceRoots: ['/work/api', '/work/shared'],
|
|
192
|
+
rawPath: '/raw/codex-1.jsonl',
|
|
193
|
+
sourceStamp: 'v1:mtime-1',
|
|
194
|
+
discoveryState: 'full',
|
|
195
|
+
fromCache: true,
|
|
196
|
+
parserVersion: 2,
|
|
197
|
+
});
|
|
198
|
+
// NULL and malformed JSON columns both mean "nothing observed".
|
|
199
|
+
const claude = rows[1];
|
|
200
|
+
assert.deepEqual(claude.models, []);
|
|
201
|
+
assert.deepEqual(claude.workspaceRoots, []);
|
|
202
|
+
assert.equal(claude.lastAssistantText, null);
|
|
203
|
+
assert.equal(claude.discoveryState, 'shallow');
|
|
204
|
+
assert.deepEqual(rows[2].models, []);
|
|
205
|
+
// Provider quirks: cursor has no first activity, relay has no cwd.
|
|
206
|
+
assert.equal(rows[2].firstActivityMs, null);
|
|
207
|
+
assert.equal(rows[3].cwd, null);
|
|
208
|
+
// A NULL discovery_state predates the catalog and reads as 'full'.
|
|
209
|
+
assert.equal(rows[3].discoveryState, 'full');
|
|
210
|
+
// Rows with no last activity sort last.
|
|
211
|
+
assert.equal(rows[4].lastActivityMs, null);
|
|
212
|
+
}
|
|
213
|
+
finally {
|
|
214
|
+
hist.close();
|
|
215
|
+
}
|
|
216
|
+
});
|
|
217
|
+
});
|
|
218
|
+
test('listSessionCatalog filters by source and limit, and paginates with beforeMs', async () => {
|
|
219
|
+
await withCatalog('ai-hist-catalog-filter-', writeCatalogDb, async (dbPath) => {
|
|
220
|
+
const hist = await openAiHist({ dbPath });
|
|
221
|
+
try {
|
|
222
|
+
assert.deepEqual(ids(hist.listSessionCatalog({ sources: ['claude'] })), ['claude-1']);
|
|
223
|
+
assert.deepEqual(ids(hist.listSessionCatalog({ sources: ['claude', 'codex'] })), [
|
|
224
|
+
'codex-1',
|
|
225
|
+
'claude-1',
|
|
226
|
+
]);
|
|
227
|
+
// A trajectory filter cannot smuggle trajectory rows back in.
|
|
228
|
+
assert.deepEqual(hist.listSessionCatalog({ sources: ['trajectory'] }), []);
|
|
229
|
+
assert.deepEqual(ids(hist.listSessionCatalog({ limit: 2 })), ['codex-1', 'claude-1']);
|
|
230
|
+
const page1 = hist.listSessionCatalog({ limit: 2 });
|
|
231
|
+
const page2 = hist.listSessionCatalog({ limit: 2, beforeMs: page1[1].lastActivityMs });
|
|
232
|
+
assert.deepEqual(ids(page2), ['cursor-1', 'relay-1']);
|
|
233
|
+
// Keyset pagination never resurfaces null-timestamp rows.
|
|
234
|
+
assert.deepEqual(ids(hist.listSessionCatalog({ beforeMs: 1_000 })), []);
|
|
235
|
+
}
|
|
236
|
+
finally {
|
|
237
|
+
hist.close();
|
|
238
|
+
}
|
|
239
|
+
});
|
|
240
|
+
});
|
|
241
|
+
test('listSessionCatalogPage walks tied and undated rows exactly once', async () => {
|
|
242
|
+
await withCatalog('ai-hist-catalog-page-', writeTiedCatalogDb, async (dbPath) => {
|
|
243
|
+
const hist = await openAiHist({ dbPath });
|
|
244
|
+
try {
|
|
245
|
+
const everything = hist.listSessionCatalog({ limit: 100 });
|
|
246
|
+
assert.equal(everything.length, 9);
|
|
247
|
+
// Total order: recency first, then source, then session id; undated last.
|
|
248
|
+
assert.deepEqual(ids(everything), [
|
|
249
|
+
'tie-a',
|
|
250
|
+
'tie-b',
|
|
251
|
+
'tie-c',
|
|
252
|
+
'tie-d',
|
|
253
|
+
'old-a',
|
|
254
|
+
'old-b',
|
|
255
|
+
'undated-a',
|
|
256
|
+
'undated-b',
|
|
257
|
+
'undated-c',
|
|
258
|
+
]);
|
|
259
|
+
// A walk in pages of two must reproduce that list exactly: a page
|
|
260
|
+
// boundary lands inside a group of tied rows, which is where a
|
|
261
|
+
// recency-only cursor loses (or repeats) rows.
|
|
262
|
+
const walked = [];
|
|
263
|
+
let after;
|
|
264
|
+
let pages = 0;
|
|
265
|
+
for (;;) {
|
|
266
|
+
const page = hist.listSessionCatalogPage({ limit: 2, after });
|
|
267
|
+
walked.push(...keys(page.sessions));
|
|
268
|
+
pages += 1;
|
|
269
|
+
assert.ok(pages < 20, 'pagination did not terminate');
|
|
270
|
+
if (!page.nextCursor)
|
|
271
|
+
break;
|
|
272
|
+
after = page.nextCursor;
|
|
273
|
+
}
|
|
274
|
+
assert.deepEqual(walked, keys(everything));
|
|
275
|
+
assert.equal(new Set(walked).size, walked.length, 'a row was returned twice');
|
|
276
|
+
assert.equal(pages, 5); // 2+2+2+2+1
|
|
277
|
+
}
|
|
278
|
+
finally {
|
|
279
|
+
hist.close();
|
|
280
|
+
}
|
|
281
|
+
});
|
|
282
|
+
});
|
|
283
|
+
test('a dated cursor still reaches the undated tail', async () => {
|
|
284
|
+
await withCatalog('ai-hist-catalog-tail-', writeTiedCatalogDb, async (dbPath) => {
|
|
285
|
+
const hist = await openAiHist({ dbPath });
|
|
286
|
+
try {
|
|
287
|
+
const fromLastDated = hist.listSessionCatalog({
|
|
288
|
+
after: { lastActivityMs: 1_000, source: 'cursor', sessionId: 'old-b' },
|
|
289
|
+
});
|
|
290
|
+
assert.deepEqual(ids(fromLastDated), ['undated-a', 'undated-b', 'undated-c']);
|
|
291
|
+
// And an undated cursor continues within the tail, by identity alone.
|
|
292
|
+
const withinTail = hist.listSessionCatalog({
|
|
293
|
+
after: { lastActivityMs: null, source: 'claude', sessionId: 'undated-a' },
|
|
294
|
+
});
|
|
295
|
+
assert.deepEqual(ids(withinTail), ['undated-b', 'undated-c']);
|
|
296
|
+
// A cursor sitting on a tie returns the rest of the tie, not the next
|
|
297
|
+
// timestamp — the failure mode `before_ms` alone cannot avoid.
|
|
298
|
+
const midTie = hist.listSessionCatalog({
|
|
299
|
+
limit: 2,
|
|
300
|
+
after: { lastActivityMs: 2_000, source: 'claude', sessionId: 'tie-b' },
|
|
301
|
+
});
|
|
302
|
+
assert.deepEqual(ids(midTie), ['tie-c', 'tie-d']);
|
|
303
|
+
}
|
|
304
|
+
finally {
|
|
305
|
+
hist.close();
|
|
306
|
+
}
|
|
307
|
+
});
|
|
308
|
+
});
|
|
309
|
+
test('nextCursor is set only when a page fills its limit', async () => {
|
|
310
|
+
await withCatalog('ai-hist-catalog-cursor-', writeTiedCatalogDb, async (dbPath) => {
|
|
311
|
+
const hist = await openAiHist({ dbPath });
|
|
312
|
+
try {
|
|
313
|
+
// Short page: the catalog is exhausted, so there is nothing to continue.
|
|
314
|
+
const short = hist.listSessionCatalogPage({ limit: 50 });
|
|
315
|
+
assert.equal(short.sessions.length, 9);
|
|
316
|
+
assert.equal(short.nextCursor, null);
|
|
317
|
+
// Exactly-full page: the cursor is handed out even though the next page
|
|
318
|
+
// turns out to be empty — the same fill rule the native page uses.
|
|
319
|
+
const exact = hist.listSessionCatalogPage({ limit: 9 });
|
|
320
|
+
assert.deepEqual(exact.nextCursor, {
|
|
321
|
+
lastActivityMs: null,
|
|
322
|
+
source: 'codex',
|
|
323
|
+
sessionId: 'undated-c',
|
|
324
|
+
});
|
|
325
|
+
const beyond = hist.listSessionCatalogPage({ limit: 9, after: exact.nextCursor });
|
|
326
|
+
assert.deepEqual(beyond.sessions, []);
|
|
327
|
+
assert.equal(beyond.nextCursor, null);
|
|
328
|
+
// The cursor carries the last row of the page, ties included.
|
|
329
|
+
const first = hist.listSessionCatalogPage({ limit: 2 });
|
|
330
|
+
assert.deepEqual(first.nextCursor, {
|
|
331
|
+
lastActivityMs: 2_000,
|
|
332
|
+
source: 'claude',
|
|
333
|
+
sessionId: 'tie-b',
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
finally {
|
|
337
|
+
hist.close();
|
|
338
|
+
}
|
|
339
|
+
});
|
|
340
|
+
});
|
|
341
|
+
test('a negative limit is rejected rather than dumping the catalog', async () => {
|
|
342
|
+
await withCatalog('ai-hist-catalog-limit-', writeTiedCatalogDb, async (dbPath) => {
|
|
343
|
+
const hist = await openAiHist({ dbPath });
|
|
344
|
+
try {
|
|
345
|
+
assert.throws(() => hist.listSessionCatalog({ limit: -1 }), RangeError);
|
|
346
|
+
assert.throws(() => hist.listSessionCatalogPage({ limit: -1 }), RangeError);
|
|
347
|
+
}
|
|
348
|
+
finally {
|
|
349
|
+
hist.close();
|
|
350
|
+
}
|
|
351
|
+
});
|
|
352
|
+
});
|
|
353
|
+
test('a half-built cursor is rejected rather than silently restarting the walk', async () => {
|
|
354
|
+
await withCatalog('ai-hist-catalog-halfcursor-', writeTiedCatalogDb, async (dbPath) => {
|
|
355
|
+
const hist = await openAiHist({ dbPath });
|
|
356
|
+
try {
|
|
357
|
+
// A timestamp on its own cannot separate tied rows; dropping it would
|
|
358
|
+
// hand back page one forever.
|
|
359
|
+
assert.throws(() => hist.listSessionCatalog({ after: { lastActivityMs: 2_000 } }), TypeError);
|
|
360
|
+
assert.throws(() => hist.listSessionCatalog({ after: { lastActivityMs: 2_000, source: 'claude' } }), TypeError);
|
|
361
|
+
assert.throws(() => hist.listSessionCatalog({ after: { source: '', sessionId: 'tie-a' } }), TypeError);
|
|
362
|
+
}
|
|
363
|
+
finally {
|
|
364
|
+
hist.close();
|
|
365
|
+
}
|
|
366
|
+
});
|
|
367
|
+
});
|
|
368
|
+
test('listSessionCatalog reads a pre-catalog database through the in-memory migration', async () => {
|
|
369
|
+
const writeOld = (dbPath) => writeDb(dbPath, (db) => {
|
|
370
|
+
db.run(OLD_SHAPE_DDL);
|
|
371
|
+
db.run(`INSERT INTO sessions (session_id, source, cwd, git_branch, first_activity_ms,
|
|
372
|
+
last_activity_ms, last_assistant_text, raw_path, parser_version)
|
|
373
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, ['legacy-1', 'claude', '/work/app', 'main', 1_000, 2_000, 'older reply', '/raw/legacy.jsonl', 1]);
|
|
374
|
+
});
|
|
375
|
+
await withCatalog('ai-hist-catalog-legacy-', writeOld, async (dbPath) => {
|
|
376
|
+
const hist = await openAiHist({ dbPath });
|
|
377
|
+
try {
|
|
378
|
+
const rows = hist.listSessionCatalog();
|
|
379
|
+
assert.equal(rows.length, 1);
|
|
380
|
+
const row = rows[0];
|
|
381
|
+
// Old columns still read.
|
|
382
|
+
assert.equal(row.sessionId, 'legacy-1');
|
|
383
|
+
assert.equal(row.cwd, '/work/app');
|
|
384
|
+
assert.equal(row.lastAssistantText, 'older reply');
|
|
385
|
+
// Columns the ALTER added exist and read as "nothing observed".
|
|
386
|
+
assert.equal(row.firstPrompt, null);
|
|
387
|
+
assert.equal(row.originator, null);
|
|
388
|
+
assert.deepEqual(row.models, []);
|
|
389
|
+
assert.deepEqual(row.workspaceRoots, []);
|
|
390
|
+
assert.equal(row.sourceStamp, null);
|
|
391
|
+
// A row written before discovery existed is a fully ingested row.
|
|
392
|
+
assert.equal(row.discoveryState, 'full');
|
|
393
|
+
}
|
|
394
|
+
finally {
|
|
395
|
+
hist.close();
|
|
396
|
+
}
|
|
397
|
+
});
|
|
398
|
+
});
|
|
399
|
+
test('a project scope constrains the catalog listing by cwd', async () => {
|
|
400
|
+
await withCatalog('ai-hist-catalog-scope-', writeCatalogDb, async (dbPath) => {
|
|
401
|
+
const scoped = await openAiHist({ dbPath, projectScope: '/work/app' });
|
|
402
|
+
try {
|
|
403
|
+
// /work/api is outside the scope; relay has no cwd, so it drops out too.
|
|
404
|
+
assert.deepEqual(ids(scoped.listSessionCatalog()), ['claude-1', 'cursor-1', 'grok-1']);
|
|
405
|
+
}
|
|
406
|
+
finally {
|
|
407
|
+
scoped.close();
|
|
408
|
+
}
|
|
409
|
+
});
|
|
410
|
+
});
|
|
411
|
+
test('the catalog is empty in JSONL fallback mode', async () => {
|
|
412
|
+
// The fallback scanner resolves ~/.claude, ~/.codex, ~/.cursor and ~/.grok
|
|
413
|
+
// from os.homedir() at module load, so overriding HOME inside this process
|
|
414
|
+
// would come too late and the scan would read the developer's real history.
|
|
415
|
+
// Run it in a child with an empty HOME instead: fully isolated, and it also
|
|
416
|
+
// proves the fallback path builds no catalog rather than merely finding none.
|
|
417
|
+
const dir = await mkdtemp(join(tmpdir(), 'ai-hist-catalog-fallback-'));
|
|
418
|
+
const home = join(dir, 'home');
|
|
419
|
+
await mkdir(home, { recursive: true });
|
|
420
|
+
const probe = join(dir, 'probe.mjs');
|
|
421
|
+
const indexUrl = new URL('./index.js', import.meta.url).href;
|
|
422
|
+
await writeFile(probe, `import { openAiHist } from ${JSON.stringify(indexUrl)};\n` +
|
|
423
|
+
`const hist = await openAiHist({ dbPath: process.env.AI_HIST_DB });\n` +
|
|
424
|
+
`console.log(JSON.stringify({ kind: hist.sourceKind, catalog: hist.listSessionCatalog() }));\n` +
|
|
425
|
+
`hist.close();\n`);
|
|
426
|
+
try {
|
|
427
|
+
const { stdout } = await execFileAsync(process.execPath, [probe], {
|
|
428
|
+
env: {
|
|
429
|
+
...process.env,
|
|
430
|
+
HOME: home,
|
|
431
|
+
USERPROFILE: home,
|
|
432
|
+
AI_HIST_DB: join(dir, 'missing.db'),
|
|
433
|
+
TRAJECTORY_ROOT: join(dir, 'missing-trajectories'),
|
|
434
|
+
OPENCODE_DB: join(dir, 'missing-opencode.db'),
|
|
435
|
+
},
|
|
436
|
+
timeout: 30_000,
|
|
437
|
+
});
|
|
438
|
+
const result = JSON.parse(stdout);
|
|
439
|
+
assert.equal(result.kind, 'jsonl');
|
|
440
|
+
assert.deepEqual(result.catalog, []);
|
|
441
|
+
}
|
|
442
|
+
finally {
|
|
443
|
+
await rm(dir, { recursive: true, force: true });
|
|
444
|
+
}
|
|
445
|
+
});
|
|
446
|
+
// ---------------------------------------------------------------------------
|
|
447
|
+
// discoverSessions
|
|
448
|
+
// ---------------------------------------------------------------------------
|
|
449
|
+
/**
|
|
450
|
+
* Minimal fake child process that scripts stdout/stderr/exit for one run.
|
|
451
|
+
*
|
|
452
|
+
* `kill` is real rather than absent: an aborted run has to actually stop the
|
|
453
|
+
* child, and a fake without the method would swallow `child.kill?.()` as a
|
|
454
|
+
* no-op, so the abort tests would keep passing if the implementation quietly
|
|
455
|
+
* stopped killing it. Recorded in `kills`, and a killed child stops producing
|
|
456
|
+
* output the way a real one does.
|
|
457
|
+
*/
|
|
458
|
+
function fakeSpawn(script) {
|
|
459
|
+
const calls = [];
|
|
460
|
+
const kills = [];
|
|
461
|
+
const spawnFn = ((bin, args) => {
|
|
462
|
+
calls.push({ bin, args });
|
|
463
|
+
const child = new EventEmitter();
|
|
464
|
+
child.stdout = new EventEmitter();
|
|
465
|
+
child.stderr = new EventEmitter();
|
|
466
|
+
child.kill = (signal) => {
|
|
467
|
+
kills.push(signal);
|
|
468
|
+
return true;
|
|
469
|
+
};
|
|
470
|
+
setImmediate(() => {
|
|
471
|
+
if (script.errorCode) {
|
|
472
|
+
const err = new Error('spawn failed');
|
|
473
|
+
err.code = script.errorCode;
|
|
474
|
+
child.emit('error', err);
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
477
|
+
for (const chunk of script.chunks ?? []) {
|
|
478
|
+
if (kills.length > 0)
|
|
479
|
+
break; // Killed: no more output from this child.
|
|
480
|
+
child.stdout.emit('data', Buffer.from(chunk, 'utf8'));
|
|
481
|
+
}
|
|
482
|
+
if (script.stderr)
|
|
483
|
+
child.stderr.emit('data', script.stderr);
|
|
484
|
+
child.emit('close', script.code ?? 0);
|
|
485
|
+
});
|
|
486
|
+
return child;
|
|
487
|
+
});
|
|
488
|
+
return { spawnFn, calls, kills };
|
|
489
|
+
}
|
|
490
|
+
const DISCOVER_SESSION_LINE = JSON.stringify({
|
|
491
|
+
type: 'session',
|
|
492
|
+
source: 'codex',
|
|
493
|
+
session_id: 'codex-cli',
|
|
494
|
+
cwd: '/work/api',
|
|
495
|
+
git_branch: 'dev',
|
|
496
|
+
first_activity_ms: 1_000,
|
|
497
|
+
last_activity_ms: 2_000,
|
|
498
|
+
first_prompt: 'add a retry',
|
|
499
|
+
last_assistant_text: null,
|
|
500
|
+
models: ['gpt-5.4'],
|
|
501
|
+
originator: 'codex_cli_rs',
|
|
502
|
+
agent_version: '0.148.0',
|
|
503
|
+
repo_url: null,
|
|
504
|
+
initial_commit: null,
|
|
505
|
+
workspace_roots: [],
|
|
506
|
+
raw_path: '/raw/codex.jsonl',
|
|
507
|
+
source_stamp: 'v1:stamp',
|
|
508
|
+
discovery_state: 'shallow',
|
|
509
|
+
from_cache: false,
|
|
510
|
+
});
|
|
511
|
+
const DISCOVER_SUMMARY_LINE = JSON.stringify({
|
|
512
|
+
type: 'summary',
|
|
513
|
+
contract_version: 1,
|
|
514
|
+
discovered: 1,
|
|
515
|
+
skipped_unchanged: 1,
|
|
516
|
+
providers: {
|
|
517
|
+
codex: { candidates: 2, discovered: 1, skipped_unchanged: 1, failed: false },
|
|
518
|
+
grok: { candidates: 0, discovered: 0, skipped_unchanged: 0, failed: true },
|
|
519
|
+
},
|
|
520
|
+
exempt_sources: [{ source: 'trajectory', reason: 'derived trajectory records, not provider sessions' }],
|
|
521
|
+
counters: {
|
|
522
|
+
candidates_enumerated: 2,
|
|
523
|
+
shallow_reads: 1,
|
|
524
|
+
skipped_unchanged: 1,
|
|
525
|
+
files_opened: 1,
|
|
526
|
+
bytes_read: 4096,
|
|
527
|
+
},
|
|
528
|
+
});
|
|
529
|
+
const DISCOVER_DIAGNOSTIC_LINE = JSON.stringify({
|
|
530
|
+
type: 'diagnostic',
|
|
531
|
+
source: 'grok',
|
|
532
|
+
locator: '/raw/broken.json',
|
|
533
|
+
error: 'unreadable',
|
|
534
|
+
});
|
|
535
|
+
test('discoverSessions parses the JSONL stream into sessions, diagnostics, and a summary', async () => {
|
|
536
|
+
// Split mid-line so the incremental line buffering is exercised.
|
|
537
|
+
const stream = `${DISCOVER_SESSION_LINE}\n${DISCOVER_DIAGNOSTIC_LINE}\n${DISCOVER_SUMMARY_LINE}\n`;
|
|
538
|
+
const split = Math.floor(DISCOVER_SESSION_LINE.length / 2);
|
|
539
|
+
const { spawnFn, calls, kills } = fakeSpawn({ chunks: [stream.slice(0, split), stream.slice(split)] });
|
|
540
|
+
const streamed = [];
|
|
541
|
+
const result = await discoverSessions({
|
|
542
|
+
binPath: '/bin/echo',
|
|
543
|
+
spawnFn,
|
|
544
|
+
sources: ['codex', 'grok'],
|
|
545
|
+
limit: 5,
|
|
546
|
+
onSession: (session) => streamed.push(session.sessionId),
|
|
547
|
+
});
|
|
548
|
+
assert.deepEqual(calls[0].args, [
|
|
549
|
+
'sessions',
|
|
550
|
+
'discover',
|
|
551
|
+
'--json',
|
|
552
|
+
'--source',
|
|
553
|
+
'codex',
|
|
554
|
+
'--source',
|
|
555
|
+
'grok',
|
|
556
|
+
'--limit',
|
|
557
|
+
'5',
|
|
558
|
+
]);
|
|
559
|
+
assert.deepEqual(streamed, ['codex-cli']);
|
|
560
|
+
assert.equal(result.sessions.length, 1);
|
|
561
|
+
assert.deepEqual(result.sessions[0], {
|
|
562
|
+
source: 'codex',
|
|
563
|
+
sessionId: 'codex-cli',
|
|
564
|
+
cwd: '/work/api',
|
|
565
|
+
gitBranch: 'dev',
|
|
566
|
+
firstActivityMs: 1_000,
|
|
567
|
+
lastActivityMs: 2_000,
|
|
568
|
+
firstPrompt: 'add a retry',
|
|
569
|
+
lastAssistantText: null,
|
|
570
|
+
models: ['gpt-5.4'],
|
|
571
|
+
originator: 'codex_cli_rs',
|
|
572
|
+
agentVersion: '0.148.0',
|
|
573
|
+
repoUrl: null,
|
|
574
|
+
initialCommit: null,
|
|
575
|
+
workspaceRoots: [],
|
|
576
|
+
rawPath: '/raw/codex.jsonl',
|
|
577
|
+
sourceStamp: 'v1:stamp',
|
|
578
|
+
discoveryState: 'shallow',
|
|
579
|
+
fromCache: false,
|
|
580
|
+
});
|
|
581
|
+
assert.deepEqual(result.diagnostics, [
|
|
582
|
+
{ source: 'grok', locator: '/raw/broken.json', error: 'unreadable' },
|
|
583
|
+
]);
|
|
584
|
+
assert.equal(result.summary?.contractVersion, SESSION_CATALOG_CONTRACT_VERSION);
|
|
585
|
+
assert.equal(result.summary?.discovered, 1);
|
|
586
|
+
assert.equal(result.summary?.skippedUnchanged, 1);
|
|
587
|
+
assert.deepEqual(result.summary?.providers.codex, {
|
|
588
|
+
candidates: 2,
|
|
589
|
+
discovered: 1,
|
|
590
|
+
skippedUnchanged: 1,
|
|
591
|
+
failed: false,
|
|
592
|
+
});
|
|
593
|
+
assert.equal(result.summary?.providers.grok?.failed, true);
|
|
594
|
+
assert.deepEqual(result.summary?.exemptSources, [
|
|
595
|
+
{ source: 'trajectory', reason: 'derived trajectory records, not provider sessions' },
|
|
596
|
+
]);
|
|
597
|
+
assert.deepEqual(result.summary?.counters, {
|
|
598
|
+
candidatesEnumerated: 2,
|
|
599
|
+
shallowReads: 1,
|
|
600
|
+
skippedUnchanged: 1,
|
|
601
|
+
filesOpened: 1,
|
|
602
|
+
bytesRead: 4096,
|
|
603
|
+
});
|
|
604
|
+
// A run that completes is never killed — only an aborted one is.
|
|
605
|
+
assert.deepEqual(kills, []);
|
|
606
|
+
});
|
|
607
|
+
test('discoverSessions skips unparseable and unknown lines instead of failing', async () => {
|
|
608
|
+
const { spawnFn, calls } = fakeSpawn({
|
|
609
|
+
chunks: [
|
|
610
|
+
`{ truncated\n`,
|
|
611
|
+
`${JSON.stringify({ type: 'future_line_type', hello: 'world' })}\n`,
|
|
612
|
+
`\n`,
|
|
613
|
+
// No trailing newline: the last line still has to be parsed on close.
|
|
614
|
+
`${DISCOVER_SESSION_LINE}\n${DISCOVER_SUMMARY_LINE}`,
|
|
615
|
+
],
|
|
616
|
+
});
|
|
617
|
+
const result = await discoverSessions({ binPath: '/bin/echo', spawnFn });
|
|
618
|
+
assert.deepEqual(calls[0].args, ['sessions', 'discover', '--json']);
|
|
619
|
+
assert.deepEqual(result.sessions.map((session) => session.sessionId), ['codex-cli']);
|
|
620
|
+
assert.equal(result.summary?.discovered, 1);
|
|
621
|
+
});
|
|
622
|
+
test('discoverSessions resolves with diagnostics when a provider fails but the run exits 0', async () => {
|
|
623
|
+
const { spawnFn } = fakeSpawn({
|
|
624
|
+
chunks: [`${DISCOVER_DIAGNOSTIC_LINE}\n${DISCOVER_SUMMARY_LINE}\n`],
|
|
625
|
+
code: 0,
|
|
626
|
+
});
|
|
627
|
+
const seen = [];
|
|
628
|
+
const result = await discoverSessions({
|
|
629
|
+
binPath: '/bin/echo',
|
|
630
|
+
spawnFn,
|
|
631
|
+
onDiagnostic: (diagnostic) => seen.push(diagnostic.source),
|
|
632
|
+
});
|
|
633
|
+
assert.deepEqual(result.sessions, []);
|
|
634
|
+
assert.deepEqual(seen, ['grok']);
|
|
635
|
+
assert.equal(result.summary?.providers.grok?.failed, true);
|
|
636
|
+
});
|
|
637
|
+
test('discoverSessions rejects with a clear install hint when the binary is missing', async () => {
|
|
638
|
+
const { spawnFn } = fakeSpawn({ errorCode: 'ENOENT' });
|
|
639
|
+
await assert.rejects(discoverSessions({ binPath: '/nope/ai-hist', spawnFn }), /could not run the ai-hist binary.*AI_HIST_RUST_BIN/s);
|
|
640
|
+
});
|
|
641
|
+
test('discoverSessions rejects on a non-zero exit, carrying the diagnostics and summary', async () => {
|
|
642
|
+
// Every provider failed: the CLI still writes its diagnostics and the summary
|
|
643
|
+
// trailer before exiting non-zero, so the error must keep them.
|
|
644
|
+
const { spawnFn } = fakeSpawn({
|
|
645
|
+
chunks: [`${DISCOVER_DIAGNOSTIC_LINE}\n${DISCOVER_SUMMARY_LINE}\n`],
|
|
646
|
+
code: 2,
|
|
647
|
+
stderr: 'every provider failed',
|
|
648
|
+
});
|
|
649
|
+
await assert.rejects(discoverSessions({ binPath: '/bin/echo', spawnFn }), (err) => {
|
|
650
|
+
assert.ok(err instanceof DiscoveryError);
|
|
651
|
+
assert.match(err.message, /ai-hist sessions discover failed \(exit 2\).*every provider failed/);
|
|
652
|
+
assert.equal(err.exitCode, 2);
|
|
653
|
+
assert.deepEqual(err.diagnostics.map((d) => d.source), ['grok']);
|
|
654
|
+
assert.equal(err.summary?.providers.grok?.failed, true);
|
|
655
|
+
return true;
|
|
656
|
+
});
|
|
657
|
+
});
|
|
658
|
+
test('discoverSessions rejects when the run produces no summary trailer', async () => {
|
|
659
|
+
const { spawnFn } = fakeSpawn({ chunks: [`${DISCOVER_SESSION_LINE}\n`] });
|
|
660
|
+
await assert.rejects(discoverSessions({ binPath: '/bin/echo', spawnFn }), /produced no summary line/);
|
|
661
|
+
});
|
|
662
|
+
test('discoverSessions rejects a summary from an unsupported contract version', async () => {
|
|
663
|
+
const future = JSON.stringify({
|
|
664
|
+
...JSON.parse(DISCOVER_SUMMARY_LINE),
|
|
665
|
+
contract_version: 2,
|
|
666
|
+
});
|
|
667
|
+
const { spawnFn } = fakeSpawn({ chunks: [`${DISCOVER_SESSION_LINE}\n${future}\n`] });
|
|
668
|
+
await assert.rejects(discoverSessions({ binPath: '/bin/echo', spawnFn }), (err) => {
|
|
669
|
+
assert.ok(err instanceof DiscoveryError);
|
|
670
|
+
assert.match(err.message, /unsupported session-catalog contract version 2/);
|
|
671
|
+
// The rows the run did emit are still on the error's summary.
|
|
672
|
+
assert.equal(err.summary?.discovered, 1);
|
|
673
|
+
return true;
|
|
674
|
+
});
|
|
675
|
+
// A summary with no contract_version at all is equally unusable.
|
|
676
|
+
const versionless = JSON.stringify({ type: 'summary', discovered: 0 });
|
|
677
|
+
const { spawnFn: spawnVersionless } = fakeSpawn({ chunks: [`${versionless}\n`] });
|
|
678
|
+
await assert.rejects(discoverSessions({ binPath: '/bin/echo', spawnFn: spawnVersionless }), /unsupported session-catalog contract version 0/);
|
|
679
|
+
});
|
|
680
|
+
test('an exception from onSession aborts the run instead of escaping the stream', async () => {
|
|
681
|
+
const { spawnFn, kills } = fakeSpawn({
|
|
682
|
+
chunks: [`${DISCOVER_SESSION_LINE}\n`, `${DISCOVER_SUMMARY_LINE}\n`],
|
|
683
|
+
});
|
|
684
|
+
const boom = new Error('host render failed');
|
|
685
|
+
await assert.rejects(discoverSessions({
|
|
686
|
+
binPath: '/bin/echo',
|
|
687
|
+
spawnFn,
|
|
688
|
+
onSession: () => {
|
|
689
|
+
throw boom;
|
|
690
|
+
},
|
|
691
|
+
}), (err) => err === boom);
|
|
692
|
+
// Rejecting is only half of it: the child has to be stopped, or an aborted
|
|
693
|
+
// run leaves a process reading the user's disk with nobody listening.
|
|
694
|
+
assert.equal(kills.length, 1);
|
|
695
|
+
});
|
|
696
|
+
test('an exception from onDiagnostic aborts the run too', async () => {
|
|
697
|
+
const { spawnFn, kills } = fakeSpawn({
|
|
698
|
+
chunks: [`${DISCOVER_DIAGNOSTIC_LINE}\n`, `${DISCOVER_SUMMARY_LINE}\n`],
|
|
699
|
+
});
|
|
700
|
+
const boom = new Error('host logger failed');
|
|
701
|
+
await assert.rejects(discoverSessions({
|
|
702
|
+
binPath: '/bin/echo',
|
|
703
|
+
spawnFn,
|
|
704
|
+
onDiagnostic: () => {
|
|
705
|
+
throw boom;
|
|
706
|
+
},
|
|
707
|
+
}), (err) => err === boom);
|
|
708
|
+
assert.equal(kills.length, 1);
|
|
709
|
+
});
|
|
710
|
+
//# sourceMappingURL=session-catalog.test.js.map
|