diff --git a/package-lock.json b/package-lock.json index 05fb673cd..d4ee7ec1a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -479,9 +479,9 @@ } }, "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", "dev": true, "license": "MIT", "engines": { @@ -489,9 +489,9 @@ } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", "dev": true, "license": "MIT", "engines": { @@ -499,13 +499,13 @@ } }, "node_modules/@babel/parser": { - "version": "7.29.3", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.3.tgz", - "integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.29.0" + "@babel/types": "^7.29.8" }, "bin": { "parser": "bin/babel-parser.js" @@ -524,14 +524,14 @@ } }, "node_modules/@babel/types": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", - "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5" + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -2320,6 +2320,37 @@ "@vitest-evals/core": "0.15.0" } }, + "node_modules/@vitest/coverage-v8": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.9.tgz", + "integrity": "sha512-G9/lgqibheLVBDRuya45EbsEXTYcWoSG+TLg7i2axuzx0Eq62eXn+aWXyaVdV5vKvFSWd6ywcX8hA7la9Pvu8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^1.0.2", + "@vitest/utils": "4.1.9", + "ast-v8-to-istanbul": "^1.0.0", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.2.0", + "magicast": "^0.5.2", + "obug": "^2.1.1", + "std-env": "^4.0.0-rc.1", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "4.1.9", + "vitest": "4.1.9" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, "node_modules/@vitest/expect": { "version": "4.1.9", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.9.tgz", @@ -2468,6 +2499,18 @@ "node": ">=12" } }, + "node_modules/ast-v8-to-istanbul": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.5.tgz", + "integrity": "sha512-UPAgKJFSEGMWSDr3LX4tqnAb4f7KGT8O40Tyx8wbYmmZ/yn58lNCm8h3svs3eXgiGd5AXxz8NDOvXWvicq+rJA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^10.0.0" + } + }, "node_modules/balanced-match": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", @@ -3856,6 +3899,18 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/magicast": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.4.tgz", + "integrity": "sha512-llBEhWm1SacoRwgHUoQJYtwp4PBLF4faQi5TCpIGyGs9n4y5+juI0tDgyKIfpqxckRHaHzouUEph3THklWh03w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "source-map-js": "^1.2.1" + } + }, "node_modules/make-dir": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", @@ -5397,61 +5452,6 @@ "undici-types": "~7.16.0" } }, - "packages/agent/node_modules/@vitest/coverage-v8": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.9.tgz", - "integrity": "sha512-G9/lgqibheLVBDRuya45EbsEXTYcWoSG+TLg7i2axuzx0Eq62eXn+aWXyaVdV5vKvFSWd6ywcX8hA7la9Pvu8g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@bcoe/v8-coverage": "^1.0.2", - "@vitest/utils": "4.1.9", - "ast-v8-to-istanbul": "^1.0.0", - "istanbul-lib-coverage": "^3.2.2", - "istanbul-lib-report": "^3.0.1", - "istanbul-reports": "^3.2.0", - "magicast": "^0.5.2", - "obug": "^2.1.1", - "std-env": "^4.0.0-rc.1", - "tinyrainbow": "^3.1.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "@vitest/browser": "4.1.9", - "vitest": "4.1.9" - }, - "peerDependenciesMeta": { - "@vitest/browser": { - "optional": true - } - } - }, - "packages/agent/node_modules/ast-v8-to-istanbul": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.4.tgz", - "integrity": "sha512-0bC0/4bTSrnwdhU3IsZDwEdojvuPrSg59OYZfKsLRtJZ0u8VBx9DebfqqG8bRdCC0I7vjgxmPi41P0lpkhJHtA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/trace-mapping": "^0.3.31", - "estree-walker": "^3.0.3", - "js-tokens": "^10.0.0" - } - }, - "packages/agent/node_modules/magicast": { - "version": "0.5.3", - "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.3.tgz", - "integrity": "sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.29.3", - "@babel/types": "^7.29.0", - "source-map-js": "^1.2.1" - } - }, "packages/agent/node_modules/undici-types": { "version": "7.16.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", @@ -5733,6 +5733,10 @@ "@earendil-works/pi-agent-core": "^0.83.0", "@earendil-works/pi-ai": "^0.83.0" }, + "devDependencies": { + "@vitest/coverage-v8": "4.1.9", + "vitest": "4.1.9" + }, "engines": { "node": ">=22.19.0" } diff --git a/packages/agent/src/harness/session/testing/conformance.ts b/packages/agent/src/harness/session/testing/conformance.ts index 5d9e0e323..84edad47b 100644 --- a/packages/agent/src/harness/session/testing/conformance.ts +++ b/packages/agent/src/harness/session/testing/conformance.ts @@ -466,6 +466,7 @@ export function createSessionBackendConformance( await session.setName("Second"); await session.setLabel("user", "keep"); await session.setLabel("user", undefined); + await rejectsWithCode(session.setLabel("missing", "checkpoint"), "not_found"); strictEqual(await session.getName(), "Second"); strictEqual(await session.getLabel("user"), undefined); diff --git a/packages/agent/test/harness/session-test-utils.ts b/packages/agent/test/harness/session-test-utils.ts index ee17d43d2..5634abb96 100644 --- a/packages/agent/test/harness/session-test-utils.ts +++ b/packages/agent/test/harness/session-test-utils.ts @@ -1,127 +1,7 @@ import { existsSync, mkdirSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import type { - AgentMessage, - BranchSummaryEntry, - CompactionEntry, - Session as CoreSession, - Entry, - MessageEntry, -} from "@earendil-works/pi-agent-core"; -import type { Usage } from "@earendil-works/pi-ai"; import { afterEach } from "vitest"; -import type { SqliteSessionMetadata } from "../../../storage/sqlite-node/src/index.ts"; -export function createUserMessage(text: string): AgentMessage { - return { - role: "user", - content: [{ type: "text", text }], - timestamp: Date.now(), - }; -} - -export function createAssistantMessage(text: string): AgentMessage { - return { - role: "assistant", - content: [{ type: "text", text }], - api: "anthropic-messages", - provider: "anthropic", - model: "claude-sonnet-4-5", - usage: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 0, - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, - }, - stopReason: "stop", - timestamp: Date.now(), - }; -} - -export type SqliteTestSession = CoreSession; -export type SqliteTestMessage = MessageEntry["message"]; - -export async function appendSqliteCompaction( - session: SqliteTestSession, - summary: string, - tokensBefore: number, - details?: unknown, - usage?: Usage, - retainedTail: SqliteTestMessage[] = [], -): Promise { - const provisioned = { - type: "compaction", - id: session.idGenerator.next(), - summary, - retainedTail, - tokensBefore, - ...(details === undefined ? {} : { details }), - ...(usage === undefined ? {} : { usage }), - } satisfies Omit; - const entry = await session.appendEntry(provisioned, "main"); - return entry.id; -} - -export async function moveSqliteMainLane( - session: SqliteTestSession, - entryId: string | null, - summary?: { summary: string; details?: unknown; usage?: Usage }, -): Promise { - await session.moveLane("main", entryId); - if (!summary) return undefined; - const provisioned = { - type: "branch_summary", - id: session.idGenerator.next(), - fromId: entryId ?? "root", - summary: summary.summary, - ...(summary.details === undefined ? {} : { details: summary.details }), - ...(summary.usage === undefined ? {} : { usage: summary.usage }), - } satisfies Omit; - const entry = await session.appendEntry(provisioned, "main"); - return entry.id; -} - -export async function getSqliteBranch(session: SqliteTestSession, fromId?: string | null): Promise { - const start = fromId === undefined ? await session.getLeafId() : fromId; - if (start === null) return []; - const newestWindow = await session.findEntriesOnBranch({ start, stopAtType: "compaction" }); - return newestWindow.reverse(); -} - -export async function getSqliteEntries( - session: SqliteTestSession, - options?: { afterEntrySeq?: number; limit?: number }, -): Promise { - return session.findEntries({ - order: "oldestFirst", - limit: options?.limit, - cursor: options?.afterEntrySeq === undefined ? undefined : { afterSeq: options.afterEntrySeq }, - }); -} - -export async function appendSqliteSessionName(session: SqliteTestSession, name: string): Promise { - await session.setName(name.replace(/[\r\n]+/g, " ").trim()); -} - -export async function appendSqliteLabel( - session: SqliteTestSession, - targetId: string, - label: string | undefined, -): Promise { - await session.setLabel(targetId, label); -} - -export async function buildSqliteContext(session: SqliteTestSession): Promise<{ messages: SqliteTestMessage[] }> { - const entries = await getSqliteBranch(session); - const messages = entries.flatMap((entry): SqliteTestMessage[] => { - if (entry.type === "message") return [entry.message]; - if (entry.type === "compaction") return entry.retainedTail; - return []; - }); - return { messages }; -} const tempDirs: string[] = []; @@ -135,8 +15,6 @@ export function createTempDir(): string { afterEach(() => { while (tempDirs.length > 0) { const dir = tempDirs.pop()!; - if (existsSync(dir)) { - rmSync(dir, { recursive: true, force: true }); - } + if (existsSync(dir)) rmSync(dir, { recursive: true, force: true }); } }); diff --git a/packages/storage/sqlite-node/package.json b/packages/storage/sqlite-node/package.json index d5a4603a9..4eb8f3def 100644 --- a/packages/storage/sqlite-node/package.json +++ b/packages/storage/sqlite-node/package.json @@ -19,6 +19,8 @@ "scripts": { "clean": "node -e \"import('node:fs/promises').then(fs=>fs.rm('dist',{recursive:true,force:true}))\"", "build": "tsgo -p tsconfig.build.json && node ./scripts/prepare-dist.mjs copy-sqlite-migrations", + "test": "vitest --run", + "coverage": "vitest --run --coverage", "prepublishOnly": "npm run clean && npm run build" }, "author": "Earendil Works", @@ -34,5 +36,9 @@ "dependencies": { "@earendil-works/pi-ai": "^0.83.0", "@earendil-works/pi-agent-core": "^0.83.0" + }, + "devDependencies": { + "@vitest/coverage-v8": "4.1.9", + "vitest": "4.1.9" } } diff --git a/packages/storage/sqlite-node/src/sqlite/repo.ts b/packages/storage/sqlite-node/src/sqlite/repo.ts index f15b7a071..8701ade7d 100644 --- a/packages/storage/sqlite-node/src/sqlite/repo.ts +++ b/packages/storage/sqlite-node/src/sqlite/repo.ts @@ -629,7 +629,9 @@ function metadataFromRow(row: SessionRow, path: string): SqliteSessionMetadata { } export class SqliteSessionRepository - implements SessionRepository + implements + SessionRepository, + AsyncDisposable { private databasePath: string | undefined; private database: SqliteDatabase | undefined; diff --git a/packages/agent/test/harness/sqlite-adapter.test.ts b/packages/storage/sqlite-node/test/adapter.test.ts similarity index 54% rename from packages/agent/test/harness/sqlite-adapter.test.ts rename to packages/storage/sqlite-node/test/adapter.test.ts index 34daaf286..b94e309a9 100644 --- a/packages/agent/test/harness/sqlite-adapter.test.ts +++ b/packages/storage/sqlite-node/test/adapter.test.ts @@ -1,8 +1,8 @@ import { describe, expect, it } from "vitest"; -import { createNodeSqliteFactory } from "../../../storage/sqlite-node/src/index.ts"; +import { createNodeSqliteFactory } from "../src/index.ts"; describe("node:sqlite adapter", () => { - it("runs transaction callbacks synchronously", async () => { + it("commits a synchronous transaction and returns its result", async () => { const db = await createNodeSqliteFactory().open(":memory:"); try { db.exec("CREATE TABLE values_table (value INTEGER NOT NULL)"); @@ -18,6 +18,27 @@ describe("node:sqlite adapter", () => { } }); + it("forwards positional and named statement parameters", async () => { + const db = await createNodeSqliteFactory().open(":memory:"); + try { + db.exec("CREATE TABLE values_table (id INTEGER PRIMARY KEY, value TEXT NOT NULL)"); + expect(db.prepare("INSERT INTO values_table (value) VALUES (?)").run("positional")).toEqual({ + changes: 1, + lastInsertRowid: 1, + }); + expect(db.prepare("INSERT INTO values_table (value) VALUES (:value)").run({ value: "named" })).toEqual({ + changes: 1, + lastInsertRowid: 2, + }); + expect(db.prepare("SELECT value FROM values_table WHERE id = ?").get(1)).toEqual({ value: "positional" }); + expect(db.prepare("SELECT value FROM values_table WHERE id >= :id ORDER BY id").all({ id: 2 })).toEqual([ + { value: "named" }, + ]); + } finally { + db.close(); + } + }); + it("rejects asynchronous transaction callbacks", async () => { const db = await createNodeSqliteFactory().open(":memory:"); try { diff --git a/packages/agent/test/harness/sqlite-branch-cache.test.ts b/packages/storage/sqlite-node/test/branch-cache.test.ts similarity index 76% rename from packages/agent/test/harness/sqlite-branch-cache.test.ts rename to packages/storage/sqlite-node/test/branch-cache.test.ts index 18688ef6d..7b21941e1 100644 --- a/packages/agent/test/harness/sqlite-branch-cache.test.ts +++ b/packages/storage/sqlite-node/test/branch-cache.test.ts @@ -1,28 +1,23 @@ -import { mkdtempSync } from "node:fs"; -import { tmpdir } from "node:os"; import { join } from "node:path"; +import { NodeExecutionEnv } from "@earendil-works/pi-agent-core/node"; import { describe, expect, it } from "vitest"; -import { createNodeSqliteFactory, SqliteSessionRepository } from "../../../storage/sqlite-node/src/index.ts"; -import { NodeExecutionEnv } from "../../src/harness/env/nodejs.ts"; +import { createNodeSqliteFactory, SqliteSessionRepository } from "../src/index.ts"; import { appendSqliteCompaction, createAssistantMessage, + createTempDir, createUserMessage, getSqliteBranch, moveSqliteMainLane, -} from "./session-test-utils.ts"; - -function createTempDir(): string { - return mkdtempSync(join(tmpdir(), "pi-agent-sqlite-branch-cache-")); -} +} from "./test-utils.ts"; describe("SQLite branch cache", () => { - it("collections complete root paths for branches created after compaction", async () => { + it("collects complete root paths for branches created after compaction", async () => { const root = createTempDir(); const databasePath = join(root, "sessions.sqlite"); const env = new NodeExecutionEnv({ cwd: root }); const sqlite = createNodeSqliteFactory(); - const repo = new SqliteSessionRepository({ env, sqlite, databasePath }); + await using repo = new SqliteSessionRepository({ env, sqlite, databasePath }); const session = await repo.create({ cwd: root, id: "session-1" }); const rootId = await session.appendMessage(createUserMessage("root")); const keptId = await session.appendMessage(createUserMessage("kept")); @@ -51,7 +46,7 @@ describe("SQLite branch cache", () => { const databasePath = join(root, "sessions.sqlite"); const env = new NodeExecutionEnv({ cwd: root }); const sqlite = createNodeSqliteFactory(); - const repo = new SqliteSessionRepository({ env, sqlite, databasePath }); + await using repo = new SqliteSessionRepository({ env, sqlite, databasePath }); const session = await repo.create({ cwd: root, id: "session-1" }); const oldId = await session.appendMessage(createUserMessage("old")); await session.appendMessage(createUserMessage("kept")); @@ -74,49 +69,26 @@ describe("SQLite branch cache", () => { const root = createTempDir(); const databasePath = join(root, "sessions.sqlite"); const env = new NodeExecutionEnv({ cwd: root }); - const repo = new SqliteSessionRepository({ env, sqlite: createNodeSqliteFactory(), databasePath }); + await using repo = new SqliteSessionRepository({ env, sqlite: createNodeSqliteFactory(), databasePath }); const session = await repo.create({ cwd: root, id: "session-1" }); await session.appendMessage(createUserMessage("root")); - const firstCompactionId = await appendSqliteCompaction(session, "first summary", 100, undefined, undefined, []); - const middleId = await session.appendMessage(createUserMessage("middle")); + await appendSqliteCompaction(session, "first summary", 100, undefined, undefined, []); + await session.appendMessage(createUserMessage("middle")); const secondCompactionId = await appendSqliteCompaction(session, "second summary", 200); const leafId = await session.appendMessage(createAssistantMessage("new")); - expect(firstCompactionId).not.toBe(secondCompactionId); - expect(middleId).not.toBe(leafId); expect((await getSqliteBranch(session)).map((entry) => entry.id)).toEqual([secondCompactionId, leafId]); }); - it("fails loudly when the private branch cache is missing", async () => { + it("rejects reads and writes without repairing a missing private branch cache", async () => { const root = createTempDir(); const databasePath = join(root, "sessions.sqlite"); const env = new NodeExecutionEnv({ cwd: root }); const sqlite = createNodeSqliteFactory(); - const repo = new SqliteSessionRepository({ env, sqlite, databasePath }); - const session = await repo.create({ cwd: root, id: "session-1" }); - const rootId = await session.appendMessage(createUserMessage("root")); - const childId = await session.appendMessage(createAssistantMessage("child")); - - const db = await sqlite.open(databasePath); - try { - await db.prepare("DELETE FROM branch_tips WHERE session_id = ?").run("session-1"); - await db.prepare("DELETE FROM branch_entries WHERE session_id = ?").run("session-1"); - } finally { - await db.close(); - } - - expect(rootId).not.toBe(childId); - await expect(getSqliteBranch(session)).rejects.toMatchObject({ code: "invalid_entry" }); - }); - - it("does not repair the private branch cache during normal reads", async () => { - const root = createTempDir(); - const databasePath = join(root, "sessions.sqlite"); - const env = new NodeExecutionEnv({ cwd: root }); - const sqlite = createNodeSqliteFactory(); - const repo = new SqliteSessionRepository({ env, sqlite, databasePath }); + await using repo = new SqliteSessionRepository({ env, sqlite, databasePath }); const session = await repo.create({ cwd: root, id: "session-1" }); await session.appendMessage(createUserMessage("root")); + await session.appendMessage(createAssistantMessage("child")); const db = await sqlite.open(databasePath); try { @@ -127,6 +99,10 @@ describe("SQLite branch cache", () => { } await expect(getSqliteBranch(session)).rejects.toMatchObject({ code: "invalid_entry" }); + await expect(session.appendMessage(createAssistantMessage("later"))).rejects.toMatchObject({ + code: "invalid_entry", + message: expect.stringContaining("has no branch containing parent entry"), + }); const inspection = await sqlite.open(databasePath); try { @@ -143,7 +119,7 @@ describe("SQLite branch cache", () => { const databasePath = join(root, "sessions.sqlite"); const env = new NodeExecutionEnv({ cwd: root }); const sqlite = createNodeSqliteFactory(); - const repo = new SqliteSessionRepository({ env, sqlite, databasePath }); + await using repo = new SqliteSessionRepository({ env, sqlite, databasePath }); const session = await repo.create({ cwd: root, id: "session-1" }); const rootId = await session.appendMessage(createUserMessage("root")); const childId = await session.appendMessage(createAssistantMessage("child")); @@ -169,7 +145,7 @@ describe("SQLite branch cache", () => { const databasePath = join(root, "sessions.sqlite"); const env = new NodeExecutionEnv({ cwd: root }); const sqlite = createNodeSqliteFactory(); - const repo = new SqliteSessionRepository({ env, sqlite, databasePath }); + await using repo = new SqliteSessionRepository({ env, sqlite, databasePath }); const source = await repo.create({ cwd: root, id: "source" }); const rootId = await source.appendMessage(createUserMessage("root")); const childId = await source.appendMessage(createAssistantMessage("child")); @@ -198,7 +174,7 @@ describe("SQLite branch cache", () => { const databasePath = join(root, "sessions.sqlite"); const env = new NodeExecutionEnv({ cwd: root }); const sqlite = createNodeSqliteFactory(); - const repo = new SqliteSessionRepository({ env, sqlite, databasePath }); + await using repo = new SqliteSessionRepository({ env, sqlite, databasePath }); const session = await repo.create({ cwd: root, id: "session-1" }); const rootId = await session.appendMessage(createUserMessage("root")); const staleId = await session.appendMessage(createAssistantMessage("stale")); @@ -224,7 +200,7 @@ describe("SQLite branch cache", () => { const databasePath = join(root, "sessions.sqlite"); const env = new NodeExecutionEnv({ cwd: root }); const sqlite = createNodeSqliteFactory(); - const repo = new SqliteSessionRepository({ env, sqlite, databasePath }); + await using repo = new SqliteSessionRepository({ env, sqlite, databasePath }); const session = await repo.create({ cwd: root, id: "session-1" }); await session.appendMessage(createUserMessage("root")); const metadata = await session.getMetadata(); diff --git a/packages/agent/test/harness/session/sqlite.test.ts b/packages/storage/sqlite-node/test/conformance.test.ts similarity index 84% rename from packages/agent/test/harness/session/sqlite.test.ts rename to packages/storage/sqlite-node/test/conformance.test.ts index 2934dde68..ac5a20c27 100644 --- a/packages/agent/test/harness/session/sqlite.test.ts +++ b/packages/storage/sqlite-node/test/conformance.test.ts @@ -1,17 +1,13 @@ import { join } from "node:path"; import type { SessionMetadata, SessionRepo } from "@earendil-works/pi-agent-core"; -import { describe, it } from "vitest"; -import { - createNodeSqliteFactory, - type SqliteSessionMetadata, - SqliteSessionRepository, -} from "../../../../storage/sqlite-node/src/index.ts"; -import { NodeExecutionEnv } from "../../../src/harness/env/nodejs.ts"; +import { NodeExecutionEnv } from "@earendil-works/pi-agent-core/node"; import { createSessionBackendConformance, type SessionBackendFixture, -} from "../../../src/harness/session/testing/index.ts"; -import { createTempDir } from "../session-test-utils.ts"; +} from "@earendil-works/pi-agent-core/session/testing"; +import { describe, it } from "vitest"; +import { createNodeSqliteFactory, type SqliteSessionMetadata, SqliteSessionRepository } from "../src/index.ts"; +import { createTempDir } from "./test-utils.ts"; function requireSqliteMetadata(metadata: SessionMetadata): SqliteSessionMetadata { const cwd = "cwd" in metadata ? metadata.cwd : undefined; diff --git a/packages/agent/test/harness/sqlite-leases.test.ts b/packages/storage/sqlite-node/test/leases.test.ts similarity index 67% rename from packages/agent/test/harness/sqlite-leases.test.ts rename to packages/storage/sqlite-node/test/leases.test.ts index 9ea4f9c6a..e6c405e38 100644 --- a/packages/agent/test/harness/sqlite-leases.test.ts +++ b/packages/storage/sqlite-node/test/leases.test.ts @@ -1,14 +1,8 @@ import { join } from "node:path"; -import { afterEach, describe, expect, it, vi } from "vitest"; -import { - createNodeSqliteFactory, - type SqliteSessionMetadata, - SqliteSessionRepository, -} from "../../../storage/sqlite-node/src/index.ts"; -import { NodeExecutionEnv } from "../../src/harness/env/nodejs.ts"; -import { createTempDir, createUserMessage } from "./session-test-utils.ts"; - -const repositories: SqliteSessionRepository[] = []; +import { NodeExecutionEnv } from "@earendil-works/pi-agent-core/node"; +import { describe, expect, it, vi } from "vitest"; +import { createNodeSqliteFactory, type SqliteSessionMetadata, SqliteSessionRepository } from "../src/index.ts"; +import { createTempDir, createUserMessage } from "./test-utils.ts"; function createRepository(root: string, databasePath: string, lease?: { ttlMs: number; heartbeatIntervalMs: number }) { const repository = new SqliteSessionRepository({ @@ -17,21 +11,49 @@ function createRepository(root: string, databasePath: string, lease?: { ttlMs: n databasePath, writerLease: lease, }); - repositories.push(repository); return repository; } -afterEach(async () => { - vi.useRealTimers(); - for (const repository of repositories.splice(0)) await repository.close(); -}); - describe("SQLite session writer leases", () => { + it("shares one write queue across repeated opens in one repository", async () => { + const root = createTempDir(); + const databasePath = join(root, "sessions.sqlite"); + await using repository = createRepository(root, databasePath); + const session = await repository.create({ cwd: root, id: "session" }); + const reopened = await repository.open(await session.getMetadata()); + + const [first, second] = await Promise.all([ + session.appendMessage(createUserMessage("first")), + reopened.appendMessage(createUserMessage("second")), + ]); + + expect((await session.findEntries({ order: "oldestFirst" })).map((entry) => entry.id)).toEqual([first, second]); + }); + + it.each([ + [{ ttlMs: 0, heartbeatIntervalMs: 1 }, "writerLease.ttlMs must be positive"], + [ + { ttlMs: 100, heartbeatIntervalMs: 100 }, + "writerLease.heartbeatIntervalMs must be positive and less than ttlMs", + ], + ])("rejects invalid lease timing %o", (writerLease, message) => { + const root = createTempDir(); + expect( + () => + new SqliteSessionRepository({ + env: new NodeExecutionEnv({ cwd: root }), + sqlite: createNodeSqliteFactory(), + databasePath: join(root, "sessions.sqlite"), + writerLease, + }), + ).toThrow(message); + }); + it("rejects a second writer until the first session releases its claim", async () => { const root = createTempDir(); const databasePath = join(root, "sessions.sqlite"); - const firstRepository = createRepository(root, databasePath); - const secondRepository = createRepository(root, databasePath); + await using firstRepository = createRepository(root, databasePath); + await using secondRepository = createRepository(root, databasePath); const first = await firstRepository.create({ cwd: root, id: "session-1" }); const metadata = await first.getMetadata(); @@ -49,8 +71,8 @@ describe("SQLite session writer leases", () => { const root = createTempDir(); const databasePath = join(root, "sessions.sqlite"); const lease = { ttlMs: 120_000, heartbeatIntervalMs: 60_000 }; - const firstRepository = createRepository(root, databasePath, lease); - const secondRepository = createRepository(root, databasePath, lease); + await using firstRepository = createRepository(root, databasePath, lease); + await using secondRepository = createRepository(root, databasePath, lease); const first = await firstRepository.create({ cwd: root, id: "session-1" }); const metadata = await first.getMetadata(); const sqlite = createNodeSqliteFactory(); @@ -94,7 +116,7 @@ describe("SQLite session writer leases", () => { it("serializes lease-checked writes for sessions sharing one database connection", async () => { const root = createTempDir(); const databasePath = join(root, "sessions.sqlite"); - const repository = createRepository(root, databasePath); + await using repository = createRepository(root, databasePath); const first = await repository.create({ cwd: root, id: "session-1" }); const second = await repository.create({ cwd: root, id: "session-2" }); @@ -111,7 +133,7 @@ describe("SQLite session writer leases", () => { vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z")); const root = createTempDir(); const databasePath = join(root, "sessions.sqlite"); - const repository = createRepository(root, databasePath, { ttlMs: 30_000, heartbeatIntervalMs: 10_000 }); + await using repository = createRepository(root, databasePath, { ttlMs: 30_000, heartbeatIntervalMs: 10_000 }); const session = await repository.create({ cwd: root, id: "session-1" }); const metadata = (await session.getMetadata()) as SqliteSessionMetadata; const sqlite = createNodeSqliteFactory(); diff --git a/packages/storage/sqlite-node/test/migrations.test.ts b/packages/storage/sqlite-node/test/migrations.test.ts new file mode 100644 index 000000000..051b985d8 --- /dev/null +++ b/packages/storage/sqlite-node/test/migrations.test.ts @@ -0,0 +1,41 @@ +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { applyMigrations, createNodeSqliteFactory } from "../src/index.ts"; +import { createTempDir } from "./test-utils.ts"; + +describe("SQLite migrations", () => { + it("applies the current schema once and records its migration", async () => { + const databasePath = join(createTempDir(), "sessions.sqlite"); + const db = await createNodeSqliteFactory().open(databasePath); + try { + await applyMigrations(db); + await applyMigrations(db); + + const rows = db.prepare("SELECT id FROM migrations ORDER BY id").all<{ id: string }>(); + expect(rows.map((row) => row.id)).toEqual(["001_initial.sql"]); + const tables = db + .prepare("SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name") + .all<{ name: string }>(); + expect(tables.map((row) => row.name)).toEqual( + expect.arrayContaining([ + "migrations", + "sessions", + "entries", + "session_sequences", + "session_stats", + "branch_entries", + "branch_tips", + "lanes", + "records", + "lane_moves", + "facts", + "leases", + ]), + ); + const sessionColumns = db.prepare("PRAGMA table_info(sessions)").all<{ name: string }>(); + expect(sessionColumns.map((column) => column.name)).not.toContain("leaf_id"); + } finally { + db.close(); + } + }); +}); diff --git a/packages/agent/test/harness/sqlite-migrations.test.ts b/packages/storage/sqlite-node/test/repository.test.ts similarity index 53% rename from packages/agent/test/harness/sqlite-migrations.test.ts rename to packages/storage/sqlite-node/test/repository.test.ts index 76e478be2..72755a1f4 100644 --- a/packages/agent/test/harness/sqlite-migrations.test.ts +++ b/packages/storage/sqlite-node/test/repository.test.ts @@ -1,31 +1,23 @@ -import { mkdtempSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; import { join } from "node:path"; +import { NodeExecutionEnv } from "@earendil-works/pi-agent-core/node"; import { describe, expect, it } from "vitest"; import { createNodeSqliteFactory, type SqliteDatabase, type SqliteDatabaseFactory, type SqliteRunResult, - type SqliteSessionMetadata, SqliteSessionRepository, type SqliteStatement, -} from "../../../storage/sqlite-node/src/index.ts"; -import { NodeExecutionEnv } from "../../src/harness/env/nodejs.ts"; +} from "../src/index.ts"; import { appendSqliteCompaction, - appendSqliteLabel, - appendSqliteSessionName, - buildSqliteContext, createAssistantMessage, + createSetupFailureSqlite, + createTempDir, createUserMessage, getSqliteEntries, moveSqliteMainLane, -} from "./session-test-utils.ts"; - -function createTempDir(): string { - return mkdtempSync(join(tmpdir(), "pi-agent-sqlite-")); -} +} from "./test-utils.ts"; class ThrowingStatement implements SqliteStatement { private readonly onRun: () => SqliteRunResult; @@ -96,66 +88,12 @@ function createCloseCountingSqliteFactory(): { }; } -describe("SQLite migrations", () => { - it("applies file-based migrations and records them", async () => { - const root = createTempDir(); - const databasePath = join(root, "sessions.sqlite"); - const env = new NodeExecutionEnv({ cwd: root }); - const sqlite = createNodeSqliteFactory(); - const repo = new SqliteSessionRepository({ env, sqlite, databasePath }); - await repo.create({ cwd: root, id: "session-1" }); - - const db = await sqlite.open(databasePath); - try { - const rows = await db.prepare("SELECT id FROM migrations ORDER BY id").all<{ id: string }>(); - expect(rows.map((row) => row.id)).toEqual(["001_initial.sql"]); - const tables = await db - .prepare("SELECT name, sql FROM sqlite_master WHERE type = 'table' ORDER BY name") - .all<{ name: string; sql: string | null }>(); - expect(tables.map((row) => row.name)).toEqual( - expect.arrayContaining([ - "migrations", - "sessions", - "entries", - "session_sequences", - "branch_entries", - "branch_tips", - ]), - ); - const sessionColumns = await db.prepare("PRAGMA table_info(sessions)").all<{ name: string }>(); - expect(sessionColumns.map((column) => column.name)).not.toContain("leaf_id"); - expect(tables.map((row) => row.name)).toEqual( - expect.arrayContaining(["lanes", "records", "lane_moves", "facts", "leases", "session_stats"]), - ); - const branchIndexes = await db - .prepare("SELECT name FROM sqlite_master WHERE type = 'index' AND tbl_name = 'branch_entries'") - .all<{ name: string }>(); - expect(branchIndexes.map((index) => index.name)).toContain("idx_branch_entries_session_branch_seq"); - expect(branchIndexes.map((index) => index.name)).not.toContain("idx_branch_entries_session_branch"); - for (const tableName of [ - "sessions", - "session_sequences", - "session_stats", - "branch_entries", - "branch_tips", - "lanes", - "records", - "lane_moves", - "facts", - ]) { - const table = tables.find((row) => row.name === tableName); - expect(table?.sql).toContain("WITHOUT ROWID"); - } - } finally { - await db.close(); - } - }); - +describe("SQLite session repository", () => { it("persists session metadata through create, list, open, and fork", async () => { const root = createTempDir(); const databasePath = join(root, "sessions.sqlite"); const env = new NodeExecutionEnv({ cwd: root }); - const repo = new SqliteSessionRepository({ env, sqlite: createNodeSqliteFactory(), databasePath }); + await using repo = new SqliteSessionRepository({ env, sqlite: createNodeSqliteFactory(), databasePath }); const source = await repo.create({ cwd: root, id: "session-1", @@ -181,7 +119,7 @@ describe("SQLite migrations", () => { const databasePath = join(root, "sessions.sqlite"); const env = new NodeExecutionEnv({ cwd: root }); const sqlite = createNodeSqliteFactory(); - const repo = new SqliteSessionRepository({ env, sqlite, databasePath }); + await using repo = new SqliteSessionRepository({ env, sqlite, databasePath }); const source = await repo.create({ cwd: root, id: "source" }); await source.appendMessage(createUserMessage("one")); await source.appendMessage(createAssistantMessage("two")); @@ -215,94 +153,7 @@ END; } }); - it("materializes the main lane leaf transactionally", async () => { - const root = createTempDir(); - const databasePath = join(root, "sessions.sqlite"); - const env = new NodeExecutionEnv({ cwd: root }); - const sqlite = createNodeSqliteFactory(); - const repo = new SqliteSessionRepository({ env, sqlite, databasePath }); - const session = await repo.create({ cwd: root, id: "session-1" }); - const rootId = await session.appendMessage(createUserMessage("root")); - const childId = await session.appendMessage(createAssistantMessage("child")); - await moveSqliteMainLane(session, rootId); - - const db = await sqlite.open(databasePath); - try { - const row = await db - .prepare("SELECT leaf_id FROM lanes WHERE session_id = ? AND lane = ?") - .get<{ leaf_id: string | null }>("session-1", "main"); - expect(row?.leaf_id).toBe(rootId); - const latestLaneMove = await db - .prepare("SELECT lane, leaf_id FROM lane_moves WHERE session_id = ? ORDER BY seq DESC LIMIT 1") - .get<{ lane: string; leaf_id: string | null }>("session-1"); - expect(latestLaneMove).toEqual({ lane: "main", leaf_id: rootId }); - } finally { - await db.close(); - } - - const reopened = await repo.open(await session.getMetadata()); - expect(await reopened.getLeafId()).toBe(rootId); - expect(childId).not.toBe(rootId); - }); - - it("materializes a new branch when appending from a parent with an existing child", async () => { - const root = createTempDir(); - const databasePath = join(root, "sessions.sqlite"); - const env = new NodeExecutionEnv({ cwd: root }); - const sqlite = createNodeSqliteFactory(); - const repo = new SqliteSessionRepository({ env, sqlite, databasePath }); - const session = await repo.create({ cwd: root, id: "session-1" }); - const rootId = await session.appendMessage(createUserMessage("root")); - const firstChildId = await session.appendMessage(createAssistantMessage("first child")); - await moveSqliteMainLane(session, rootId); - const secondChildId = await session.appendMessage(createAssistantMessage("second child")); - - const db = await sqlite.open(databasePath); - try { - const branchRows = await db - .prepare( - "SELECT branch_id, entry_id, entry_seq FROM branch_entries WHERE session_id = ? ORDER BY branch_id, entry_seq", - ) - .all<{ branch_id: string; entry_id: string; entry_seq: number }>("session-1"); - const branchIds = [...new Set(branchRows.map((row) => row.branch_id))]; - expect(branchIds).toHaveLength(2); - expect(branchRows.filter((row) => row.entry_id === rootId)).toHaveLength(2); - expect(branchRows.filter((row) => row.entry_id === firstChildId)).toHaveLength(1); - expect(branchRows.filter((row) => row.entry_id === secondChildId)).toHaveLength(1); - const tips = await db - .prepare("SELECT branch_id, tip_id FROM branch_tips WHERE session_id = ? ORDER BY branch_id") - .all<{ branch_id: string; tip_id: string }>("session-1"); - expect(tips.map((tip) => tip.branch_id)).toEqual(branchIds.sort()); - expect(new Set(tips.map((tip) => tip.tip_id)).size).toBe(tips.length); - } finally { - await db.close(); - } - }); - - it("reopens using branch materialization and session summary state", async () => { - const root = createTempDir(); - const databasePath = join(root, "sessions.sqlite"); - const env = new NodeExecutionEnv({ cwd: root }); - const repo = new SqliteSessionRepository({ env, sqlite: createNodeSqliteFactory(), databasePath }); - const session = await repo.create({ cwd: root, id: "session-1" }); - const rootId = await session.appendMessage(createUserMessage("root")); - await session.appendMessage(createAssistantMessage("first child")); - await appendSqliteSessionName(session, " Reopened Session "); - await moveSqliteMainLane(session, rootId); - await session.appendMessage(createAssistantMessage("branched child")); - - const reopened = await repo.open(await session.getMetadata()); - expect(await reopened.getName()).toBe("Reopened Session"); - expect((await buildSqliteContext(reopened)).messages.map((message) => message.role)).toEqual([ - "user", - "assistant", - ]); - expect((await buildSqliteContext(reopened)).messages.at(-1)).toMatchObject({ - content: [{ type: "text", text: "branched child" }], - }); - }); - - it("closes the database when create fails after openDatabase succeeds", async () => { + it("retains an opened database after a failed operation until disposal", async () => { const root = createTempDir(); const db = new CountingDatabase((sql) => { if (sql.startsWith("INSERT INTO sessions")) { @@ -316,7 +167,7 @@ END; open: async () => db, }; const env = new NodeExecutionEnv({ cwd: root }); - const repo = new SqliteSessionRepository({ env, sqlite, databasePath: join(root, "sessions.sqlite") }); + await using repo = new SqliteSessionRepository({ env, sqlite, databasePath: join(root, "sessions.sqlite") }); await expect(repo.create({ cwd: root, id: "session-1" })).rejects.toThrow("insert failed"); expect(db.closeCount).toBe(0); @@ -324,31 +175,17 @@ END; expect(db.closeCount).toBe(1); }); - it("closes the database when open fails after openDatabase succeeds", async () => { + it("closes the database when repository setup fails", async () => { const root = createTempDir(); - const db = new CountingDatabase((sql) => { - if (sql.includes("FROM sessions WHERE id = ?")) { - return new ThrowingStatement(() => ({ changes: 0 })); - } - return new ThrowingStatement(() => ({ changes: 1 })); + const { sqlite, counts } = createSetupFailureSqlite(); + await using repository = new SqliteSessionRepository({ + env: new NodeExecutionEnv({ cwd: root }), + sqlite, + databasePath: join(root, "sessions.sqlite"), }); - const sqlite: SqliteDatabaseFactory = { - open: async () => db, - }; - const env = new NodeExecutionEnv({ cwd: root }); - const repo = new SqliteSessionRepository({ env, sqlite, databasePath: join(root, "sessions.sqlite") }); - const metadata: SqliteSessionMetadata = { - id: "missing", - createdAt: Date.now(), - cwd: root, - path: join(root, "sessions.sqlite"), - }; - writeFileSync(metadata.path, ""); - await expect(repo.open(metadata)).rejects.toThrow("Session not found: missing"); - expect(db.closeCount).toBe(0); - await repo[Symbol.asyncDispose](); - expect(db.closeCount).toBe(1); + await expect(repository.create({ cwd: root, id: "session-1" })).rejects.toThrow("setup failed"); + expect(counts.closes).toBe(1); }); it("retains one connection for repeated session operations", async () => { @@ -356,7 +193,7 @@ END; const databasePath = join(root, "sessions.sqlite"); const env = new NodeExecutionEnv({ cwd: root }); const { sqlite, counts } = createCloseCountingSqliteFactory(); - const repo = new SqliteSessionRepository({ env, sqlite, databasePath }); + await using repo = new SqliteSessionRepository({ env, sqlite, databasePath }); const session = await repo.create({ cwd: root, id: "session-1" }); for (let i = 0; i < 10; i++) await session.appendMessage(createUserMessage(`message ${i}`)); @@ -368,27 +205,12 @@ END; expect(counts).toEqual({ opens: 1, closes: 1 }); }); - it("shares one connection across source and fork until the repository is disposed", async () => { - const root = createTempDir(); - const databasePath = join(root, "sessions.sqlite"); - const env = new NodeExecutionEnv({ cwd: root }); - const { sqlite, counts } = createCloseCountingSqliteFactory(); - const repo = new SqliteSessionRepository({ env, sqlite, databasePath }); - const source = await repo.create({ cwd: root, id: "session-1" }); - - const fork = await repo.fork(await source.getMetadata(), { cwd: root, id: "session-2" }); - await fork.appendMessage(createUserMessage("fork")); - expect(counts).toEqual({ opens: 1, closes: 0 }); - await repo[Symbol.asyncDispose](); - expect(counts).toEqual({ opens: 1, closes: 1 }); - }); - it("rejects a missing lane leaf when listing lanes and opening", async () => { const root = createTempDir(); const databasePath = join(root, "sessions.sqlite"); const env = new NodeExecutionEnv({ cwd: root }); const sqlite = createNodeSqliteFactory(); - const repo = new SqliteSessionRepository({ env, sqlite, databasePath }); + await using repo = new SqliteSessionRepository({ env, sqlite, databasePath }); const session = await repo.create({ cwd: root, id: "session-1" }); const metadata = await session.getMetadata(); @@ -411,12 +233,36 @@ END; }); }); + it.each([ + ["invalid JSON", "not json", "metadata is not valid JSON"], + ["a non-object value", "[]", "metadata must be an object"], + ])("rejects stored session metadata containing %s", async (_case, stored, message) => { + const root = createTempDir(); + const databasePath = join(root, "sessions.sqlite"); + const env = new NodeExecutionEnv({ cwd: root }); + const sqlite = createNodeSqliteFactory(); + await using repo = new SqliteSessionRepository({ env, sqlite, databasePath }); + await repo.create({ cwd: root, id: "session-1" }); + + const db = await sqlite.open(databasePath); + try { + await db.prepare("UPDATE sessions SET metadata = ? WHERE id = ?").run(stored, "session-1"); + } finally { + await db.close(); + } + + await expect(repo.list()).rejects.toMatchObject({ + code: "storage", + message: expect.stringContaining(message), + }); + }); + it("fails loudly when a stored entry is read and cannot be decoded", async () => { const root = createTempDir(); const databasePath = join(root, "sessions.sqlite"); const env = new NodeExecutionEnv({ cwd: root }); const sqlite = createNodeSqliteFactory(); - const repo = new SqliteSessionRepository({ env, sqlite, databasePath }); + await using repo = new SqliteSessionRepository({ env, sqlite, databasePath }); const session = await repo.create({ cwd: root, id: "session-1" }); const entryId = await session.appendMessage(createUserMessage("message")); const metadata = await session.getMetadata(); @@ -434,12 +280,42 @@ END; await expect(getSqliteEntries(reopened)).rejects.toMatchObject({ code: "invalid_entry" }); }); + it("fails loudly when a stored record cannot be decoded", async () => { + const root = createTempDir(); + const databasePath = join(root, "sessions.sqlite"); + const env = new NodeExecutionEnv({ cwd: root }); + const sqlite = createNodeSqliteFactory(); + await using repo = new SqliteSessionRepository({ env, sqlite, databasePath }); + const session = await repo.create({ cwd: root, id: "session-1" }); + await session.appendRecord({ + type: "operation_finished", + id: "record-1", + lane: "main", + runId: "run-1", + outcome: "completed", + }); + + const db = await sqlite.open(databasePath); + try { + await db + .prepare("UPDATE records SET payload = ? WHERE session_id = ? AND id = ?") + .run("not json", "session-1", "record-1"); + } finally { + await db.close(); + } + + await expect(session.findRecords()).rejects.toMatchObject({ + code: "storage", + message: expect.stringContaining("failed to decode payload"), + }); + }); + it("does not publish connection state when an append transaction fails", async () => { const root = createTempDir(); const databasePath = join(root, "sessions.sqlite"); const env = new NodeExecutionEnv({ cwd: root }); const sqlite = createNodeSqliteFactory(); - const repo = new SqliteSessionRepository({ env, sqlite, databasePath }); + await using repo = new SqliteSessionRepository({ env, sqlite, databasePath }); const session = await repo.create({ cwd: root, id: "session-1" }); const db = await sqlite.open(databasePath); try { @@ -466,12 +342,12 @@ END; expect(await session.getStats()).toMatchObject({ messageCount: 1 }); }); - it("materializes session summary fields transactionally", async () => { + it("accounts for assistant, compaction, and branch-summary usage", async () => { const root = createTempDir(); const databasePath = join(root, "sessions.sqlite"); const env = new NodeExecutionEnv({ cwd: root }); const sqlite = createNodeSqliteFactory(); - const repo = new SqliteSessionRepository({ env, sqlite, databasePath }); + await using repo = new SqliteSessionRepository({ env, sqlite, databasePath }); const session = await repo.create({ cwd: root, id: "session-1" }); const userId = await session.appendMessage(createUserMessage("one")); const assistant = { @@ -543,55 +419,12 @@ END; stopReason: "stop", usage: branchUsage, }); - await appendSqliteSessionName(session, " My Session "); - await appendSqliteLabel(session, userId, "checkpoint"); - - expect(await session.getStats()).toMatchObject({ + expect(await session.getStats()).toEqual({ messageCount: 2, cachedTokens: 50, uncachedTokens: 128, totalTokens: 211, costTotal: 0.73, }); - - const db = await sqlite.open(databasePath); - try { - expect( - await db - .prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'session_materialized'") - .get(), - ).toBeUndefined(); - expect( - await db - .prepare("SELECT type, COUNT(*) AS count FROM records WHERE session_id = ? AND type = ? GROUP BY type") - .get<{ type: string; count: number }>("session-1", "usage"), - ).toEqual({ type: "usage", count: 3 }); - expect( - await db - .prepare( - `SELECT message_count, cached_tokens, uncached_tokens, total_tokens, cost_total - FROM session_stats - WHERE session_id = ?`, - ) - .get("session-1"), - ).toEqual({ - message_count: 2, - cached_tokens: 50, - uncached_tokens: 128, - total_tokens: 211, - cost_total: 0.73, - }); - const nameFact = await db - .prepare("SELECT value FROM facts WHERE session_id = ? AND kind = 'name' ORDER BY seq DESC LIMIT 1") - .get<{ value: string }>("session-1"); - expect(JSON.parse(nameFact?.value ?? "null")).toBe("My Session"); - const labelFact = await db - .prepare("SELECT key, value FROM facts WHERE session_id = ? AND kind = 'label' ORDER BY seq DESC LIMIT 1") - .get<{ key: string; value: string }>("session-1"); - expect(labelFact?.key).toBe(userId); - expect(JSON.parse(labelFact?.value ?? "null")).toBe("checkpoint"); - } finally { - await db.close(); - } }); }); diff --git a/packages/agent/test/harness/sqlite-node.test.ts b/packages/storage/sqlite-node/test/search.test.ts similarity index 51% rename from packages/agent/test/harness/sqlite-node.test.ts rename to packages/storage/sqlite-node/test/search.test.ts index c01f6269f..d50de71df 100644 --- a/packages/agent/test/harness/sqlite-node.test.ts +++ b/packages/storage/sqlite-node/test/search.test.ts @@ -1,73 +1,28 @@ import { join } from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; -import { - createNodeSqliteFactory, - createSqliteSessionSearch, - type SqliteSessionMetadata, - SqliteSessionRepository, -} from "../../../storage/sqlite-node/src/index.ts"; -import { NodeExecutionEnv } from "../../src/harness/env/nodejs.ts"; -import type { SessionSearchOptions } from "../../src/harness/session/search.ts"; -import { createTempDir, createUserMessage, getSqliteEntries } from "./session-test-utils.ts"; - -const ownedRepositories: AsyncDisposable[] = []; - -afterEach(async () => { - for (const repository of ownedRepositories.splice(0)) await repository[Symbol.asyncDispose](); -}); +import { NodeExecutionEnv } from "@earendil-works/pi-agent-core/node"; +import { describe, expect, it } from "vitest"; +import { createNodeSqliteFactory, createSqliteSessionSearch, SqliteSessionRepository } from "../src/index.ts"; +import { createSetupFailureSqlite, createTempDir, createUserMessage, getSqliteEntries } from "./test-utils.ts"; function createSqliteFixture(options: ConstructorParameters[0]) { const repository = new SqliteSessionRepository(options); - ownedRepositories.push(repository); - return { repository, search: createSqliteSessionSearch(options) }; + return { + repository, + search: createSqliteSessionSearch(options), + [Symbol.asyncDispose]: () => repository[Symbol.asyncDispose](), + }; } -describe("SqliteSessionRepository writer leases", () => { - it("shares one storage queue for repeated opens in the same repository", async () => { +describe("SQLite FTS5 session search", () => { + it("matches trigrams within one cwd", async () => { const root = createTempDir(); const env = new NodeExecutionEnv({ cwd: root }); const sqlite = createNodeSqliteFactory(); const databasePath = join(root, "sessions.sqlite"); - const { repository: repo } = createSqliteFixture({ env, sqlite, databasePath }); - const session = await repo.create({ cwd: root, id: "session" }); - const reopened = await repo.open(await session.getMetadata()); - - const [first, second] = await Promise.all([ - session.appendMessage(createUserMessage("first")), - reopened.appendMessage(createUserMessage("second")), - ]); - - expect((await getSqliteEntries(session)).map((entry) => entry.id)).toEqual([first, second]); - }); - - it("rejects a second repository while a session lease is active", async () => { - const root = createTempDir(); - const env = new NodeExecutionEnv({ cwd: root }); - const sqlite = createNodeSqliteFactory(); - const databasePath = join(root, "sessions.sqlite"); - const { repository: firstRepo } = createSqliteFixture({ env, sqlite, databasePath }); - const session = await firstRepo.create({ cwd: root, id: "session" }); - const metadata = await session.getMetadata(); - const secondRepo = new SqliteSessionRepository({ env, sqlite, databasePath }); - ownedRepositories.push(secondRepo); - - await expect(secondRepo.open(metadata)).rejects.toMatchObject({ code: "storage" }); - - await firstRepo.close(); - await expect(secondRepo.open(metadata)).resolves.toBeDefined(); - }); -}); - -describe("SqliteSessionBackend with explicit SQLite FTS5 search", () => { - it("uses SQLite FTS5 when composed with its search implementation", async () => { - const root = createTempDir(); - const env = new NodeExecutionEnv({ cwd: root }); - const sqlite = createNodeSqliteFactory(); - const databasePath = join(root, "sessions.sqlite"); - const { repository: repo, search } = createSqliteFixture({ env, sqlite, databasePath }); + await using fixture = createSqliteFixture({ env, sqlite, databasePath }); + const { repository: repo, search } = fixture; const included = await repo.create({ cwd: root, id: "included" }); const excluded = await repo.create({ cwd: `${root}/other`, id: "excluded" }); - const metadata = await included.getMetadata(); const entryId = await included.appendMessage(createUserMessage("Find the auth defect")); await excluded.appendMessage(createUserMessage("Find the auth defect")); @@ -77,28 +32,47 @@ describe("SqliteSessionBackend with explicit SQLite FTS5 search", () => { await expect(search.search({ text: "uth", cwd: root })).resolves.toEqual([ expect.objectContaining({ entryId, metadata: expect.objectContaining({ id: "included" }) }), ]); - - const db = await sqlite.open(databasePath); - try { - const tables = await db - .prepare("SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name") - .all<{ name: string }>(); - expect(tables.map((row) => row.name)).toContain("session_search_fts"); - expect(tables.map((row) => row.name)).not.toContain("session_search_records"); - } finally { - await db.close(); - } - - await repo.delete(metadata); - await expect(search.search({ text: "auth", cwd: root })).resolves.toEqual([]); }); - it("creates an empty canonical session without initializing FTS", async () => { + it("handles quoted search text without exposing FTS syntax", async () => { + const root = createTempDir(); + await using fixture = createSqliteFixture({ + env: new NodeExecutionEnv({ cwd: root }), + sqlite: createNodeSqliteFactory(), + databasePath: join(root, "sessions.sqlite"), + }); + const { search } = fixture; + + await expect(search.search({ text: 'missing "phrase"' })).resolves.toEqual([]); + }); + + it("removes deleted session entries from the index", async () => { + const root = createTempDir(); + const env = new NodeExecutionEnv({ cwd: root }); + const databasePath = join(root, "sessions.sqlite"); + await using fixture = createSqliteFixture({ + env, + sqlite: createNodeSqliteFactory(), + databasePath, + }); + const { repository, search } = fixture; + const session = await repository.create({ cwd: root, id: "session-1" }); + await session.appendMessage(createUserMessage("Find the auth defect")); + await expect(search.search({ text: "auth" })).resolves.toHaveLength(1); + + await repository.delete(await session.getMetadata()); + + await expect(search.search({ text: "auth" })).resolves.toEqual([]); + }); + + it("does not initialize FTS for canonical writes or blank searches", async () => { const root = createTempDir(); const env = new NodeExecutionEnv({ cwd: root }); const sqlite = createNodeSqliteFactory(); const databasePath = join(root, "sessions.sqlite"); - const { repository: repo } = createSqliteFixture({ env, sqlite, databasePath }); + await using fixture = createSqliteFixture({ env, sqlite, databasePath }); + const { repository: repo, search } = fixture; + await expect(search.search({ text: " " })).resolves.toEqual([]); const session = await repo.create({ cwd: root, id: "session-1" }); const db = await sqlite.open(databasePath); @@ -118,7 +92,8 @@ describe("SqliteSessionBackend with explicit SQLite FTS5 search", () => { const env = new NodeExecutionEnv({ cwd: root }); const sqlite = createNodeSqliteFactory(); const databasePath = join(root, "sessions.sqlite"); - const { repository: repo, search } = createSqliteFixture({ env, sqlite, databasePath }); + await using fixture = createSqliteFixture({ env, sqlite, databasePath }); + const { repository: repo, search } = fixture; await search.search({ text: "initialize" }); const session = await repo.create({ cwd: root, id: "session-1" }); @@ -138,7 +113,8 @@ describe("SqliteSessionBackend with explicit SQLite FTS5 search", () => { const env = new NodeExecutionEnv({ cwd: root }); const sqlite = createNodeSqliteFactory(); const databasePath = join(root, "sessions.sqlite"); - const { repository: repo, search } = createSqliteFixture({ env, sqlite, databasePath }); + await using fixture = createSqliteFixture({ env, sqlite, databasePath }); + const { repository: repo, search } = fixture; await search.search({ text: "initialize" }); const session = await repo.create({ cwd: root, id: "session-1" }); await session.appendMessage(createUserMessage("must remain")); @@ -156,14 +132,28 @@ describe("SqliteSessionBackend with explicit SQLite FTS5 search", () => { await expect(getSqliteEntries(reopened)).resolves.toHaveLength(1); }); + it("closes the database when search setup fails", async () => { + const root = createTempDir(); + const { sqlite, counts } = createSetupFailureSqlite(); + const search = createSqliteSessionSearch({ + env: new NodeExecutionEnv({ cwd: root }), + sqlite, + databasePath: join(root, "sessions.sqlite"), + }); + + await expect(search.search({ text: "auth" })).rejects.toThrow("setup failed"); + expect(counts.closes).toBe(1); + }); + it("initializes canonical storage when searched before the first session is created", async () => { const root = createTempDir(); const env = new NodeExecutionEnv({ cwd: root }); - const { repository: repo, search } = createSqliteFixture({ + await using fixture = createSqliteFixture({ env, sqlite: createNodeSqliteFactory(), databasePath: join(root, "sessions.sqlite"), }); + const { repository: repo, search } = fixture; await expect(search.search({ text: "auth" })).resolves.toEqual([]); const session = await repo.create({ cwd: root, id: "session-1" }); @@ -175,30 +165,3 @@ describe("SqliteSessionBackend with explicit SQLite FTS5 search", () => { await expect(session.appendMessage(createUserMessage("Still writable"))).resolves.toBeTypeOf("string"); }); }); - -describe("SqliteSessionRepository with custom search", () => { - it("uses an independently supplied search implementation", async () => { - const root = createTempDir(); - const searches: SessionSearchOptions[] = []; - const search: { - search(options: SessionSearchOptions): Promise<{ metadata: SqliteSessionMetadata; entryId: string }[]>; - } = { - async search(options) { - searches.push(options); - return []; - }, - }; - const env = new NodeExecutionEnv({ cwd: root }); - const repo = new SqliteSessionRepository({ - env, - sqlite: createNodeSqliteFactory(), - databasePath: join(root, "sessions.sqlite"), - }); - ownedRepositories.push(repo); - const session = await repo.create({ cwd: root, id: "session-1" }); - await session.appendMessage(createUserMessage("stored canonically")); - - await expect(search.search({ text: "custom query" })).resolves.toEqual([]); - expect(searches).toEqual([{ text: "custom query" }]); - }); -}); diff --git a/packages/storage/sqlite-node/test/test-utils.ts b/packages/storage/sqlite-node/test/test-utils.ts new file mode 100644 index 000000000..d1a5eaf8a --- /dev/null +++ b/packages/storage/sqlite-node/test/test-utils.ts @@ -0,0 +1,144 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { + AgentMessage, + BranchSummaryEntry, + CompactionEntry, + Session as CoreSession, + Entry, + MessageEntry, +} from "@earendil-works/pi-agent-core"; +import type { Usage } from "@earendil-works/pi-ai"; +import { afterEach, vi } from "vitest"; +import type { SqliteDatabaseFactory, SqliteSessionMetadata } from "../src/index.ts"; + +const tempDirs: string[] = []; + +afterEach(() => { + vi.useRealTimers(); + while (tempDirs.length > 0) rmSync(tempDirs.pop()!, { recursive: true, force: true }); +}); + +export function createTempDir(): string { + const dir = mkdtempSync(join(tmpdir(), "pi-storage-sqlite-node-")); + tempDirs.push(dir); + return dir; +} + +export function createSetupFailureSqlite(): { + sqlite: SqliteDatabaseFactory; + counts: { closes: number }; +} { + const counts = { closes: 0 }; + return { + counts, + sqlite: { + async open() { + return { + exec() { + throw new Error("setup failed"); + }, + prepare() { + throw new Error("unexpected prepare"); + }, + transaction(_fn: () => T): T { + throw new Error("unexpected transaction"); + }, + close() { + counts.closes += 1; + }, + }; + }, + }, + }; +} + +export function createUserMessage(text: string): AgentMessage { + return { + role: "user", + content: [{ type: "text", text }], + timestamp: Date.now(), + }; +} + +export function createAssistantMessage(text: string): AgentMessage { + return { + role: "assistant", + content: [{ type: "text", text }], + api: "anthropic-messages", + provider: "anthropic", + model: "claude-sonnet-4-5", + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: "stop", + timestamp: Date.now(), + }; +} + +export type SqliteTestSession = CoreSession; +export type SqliteTestMessage = MessageEntry["message"]; + +export async function appendSqliteCompaction( + session: SqliteTestSession, + summary: string, + tokensBefore: number, + details?: unknown, + usage?: Usage, + retainedTail: SqliteTestMessage[] = [], +): Promise { + const provisioned = { + type: "compaction", + id: session.idGenerator.next(), + summary, + retainedTail, + tokensBefore, + ...(details === undefined ? {} : { details }), + ...(usage === undefined ? {} : { usage }), + } satisfies Omit; + const entry = await session.appendEntry(provisioned, "main"); + return entry.id; +} + +export async function moveSqliteMainLane( + session: SqliteTestSession, + entryId: string | null, + summary?: { summary: string; details?: unknown; usage?: Usage }, +): Promise { + await session.moveLane("main", entryId); + if (!summary) return undefined; + const provisioned = { + type: "branch_summary", + id: session.idGenerator.next(), + fromId: entryId ?? "root", + summary: summary.summary, + ...(summary.details === undefined ? {} : { details: summary.details }), + ...(summary.usage === undefined ? {} : { usage: summary.usage }), + } satisfies Omit; + const entry = await session.appendEntry(provisioned, "main"); + return entry.id; +} + +export async function getSqliteBranch(session: SqliteTestSession, fromId?: string | null): Promise { + const start = fromId === undefined ? await session.getLeafId() : fromId; + if (start === null) return []; + const newestWindow = await session.findEntriesOnBranch({ start, stopAtType: "compaction" }); + return newestWindow.reverse(); +} + +export async function getSqliteEntries( + session: SqliteTestSession, + options?: { afterEntrySeq?: number; limit?: number }, +): Promise { + return session.findEntries({ + order: "oldestFirst", + limit: options?.limit, + cursor: options?.afterEntrySeq === undefined ? undefined : { afterSeq: options.afterEntrySeq }, + }); +} diff --git a/packages/storage/sqlite-node/vitest.config.ts b/packages/storage/sqlite-node/vitest.config.ts new file mode 100644 index 000000000..7125e3274 --- /dev/null +++ b/packages/storage/sqlite-node/vitest.config.ts @@ -0,0 +1,31 @@ +import { fileURLToPath } from "node:url"; +import { defineConfig } from "vitest/config"; + +const aiIndex = fileURLToPath(new URL("../../ai/src/index.ts", import.meta.url)); +const agentIndex = fileURLToPath(new URL("../../agent/src/index.ts", import.meta.url)); +const agentNode = fileURLToPath(new URL("../../agent/src/node.ts", import.meta.url)); +const agentSessionTesting = fileURLToPath(new URL("../../agent/src/harness/session/testing/index.ts", import.meta.url)); + +export default defineConfig({ + test: { + globals: true, + environment: "node", + reporters: process.env.GITHUB_ACTIONS ? ["dot", "github-actions"] : ["dot"], + coverage: { + provider: "v8", + all: true, + include: ["src/**/*.ts"], + exclude: ["src/**/*.d.ts"], + reporter: ["text", "html", "lcov"], + reportsDirectory: "coverage", + }, + }, + resolve: { + alias: [ + { find: /^@earendil-works\/pi-agent-core\/session\/testing$/, replacement: agentSessionTesting }, + { find: /^@earendil-works\/pi-agent-core\/node$/, replacement: agentNode }, + { find: /^@earendil-works\/pi-agent-core$/, replacement: agentIndex }, + { find: /^@earendil-works\/pi-ai$/, replacement: aiIndex }, + ], + }, +}); diff --git a/tsconfig.json b/tsconfig.json index a409cdf11..626ca1290 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -11,8 +11,12 @@ "@earendil-works/pi-ai/*": ["./packages/ai/src/*.ts", "./packages/ai/src/providers/*.ts"], "@earendil-works/pi-ai/dist/*": ["./packages/ai/src/*"], "@earendil-works/pi-agent-core": ["./packages/agent/src/index.ts"], + "@earendil-works/pi-agent-core/node": ["./packages/agent/src/node.ts"], + "@earendil-works/pi-agent-core/session/testing": [ + "./packages/agent/src/harness/session/testing/index.ts" + ], "@earendil-works/pi-agent-core/*": ["./packages/agent/src/*"], - "@earendil-works/pi-agent-sqlite-node": ["./packages/storage/sqlite-node/src/index.ts"], + "@earendil-works/pi-storage-sqlite-node": ["./packages/storage/sqlite-node/src/index.ts"], "@earendil-works/pi-coding-agent": ["./packages/coding-agent/src/index.ts"], "@earendil-works/pi-coding-agent/hooks": ["./packages/coding-agent/src/core/hooks/index.ts"], "@earendil-works/pi-coding-agent/*": ["./packages/coding-agent/src/*"],