diff --git a/packages/jinn/src/cli/setup.ts b/packages/jinn/src/cli/setup.ts index 2b878e1e3..0707d7f1d 100644 --- a/packages/jinn/src/cli/setup.ts +++ b/packages/jinn/src/cli/setup.ts @@ -20,7 +20,7 @@ import { CLAUDE_SKILLS_DIR, AGENTS_SKILLS_DIR, } from "../shared/paths.js"; -import { initDb } from "../sessions/registry.js"; +import { initDb } from "../shared/db.js"; import { getPackageVersion } from "../shared/version.js"; import { deriveTodoIdPrefix } from "../work-items/id.js"; import { diff --git a/packages/jinn/src/gateway/__tests__/approval-root-fallback.test.ts b/packages/jinn/src/gateway/__tests__/approval-root-fallback.test.ts index 5cfbf6c60..d72784cab 100644 --- a/packages/jinn/src/gateway/__tests__/approval-root-fallback.test.ts +++ b/packages/jinn/src/gateway/__tests__/approval-root-fallback.test.ts @@ -128,7 +128,7 @@ beforeAll(async () => { approvals = await import("../../work-items/approvals.js"); registry = await import("../../sessions/registry.js"); approvalAuthority = await import("../approval-authority.js"); - registry.initDb(); + (await import("../../shared/db.js")).initDb(); }); describe("approval root resolution without an executive employee", () => { diff --git a/packages/jinn/src/gateway/__tests__/attachments-logic.test.ts b/packages/jinn/src/gateway/__tests__/attachments-logic.test.ts index a28479ba8..8d819ffe7 100644 --- a/packages/jinn/src/gateway/__tests__/attachments-logic.test.ts +++ b/packages/jinn/src/gateway/__tests__/attachments-logic.test.ts @@ -18,7 +18,7 @@ beforeAll(async () => { paths = await import("../../shared/paths.js"); reg = await import("../../sessions/registry.js"); files = await import("../files.js"); - reg.initDb(); + (await import("../../shared/db.js")).initDb(); }); /** Simulate a first-message upload that landed in FILES_DIR before a session existed. */ diff --git a/packages/jinn/src/gateway/__tests__/browser-operator-authorization.test.ts b/packages/jinn/src/gateway/__tests__/browser-operator-authorization.test.ts index 44d2a2316..88c96c46c 100644 --- a/packages/jinn/src/gateway/__tests__/browser-operator-authorization.test.ts +++ b/packages/jinn/src/gateway/__tests__/browser-operator-authorization.test.ts @@ -174,7 +174,7 @@ async function createSessionViaHttp(headers: Record): Promise<{ beforeAll(async () => { api = await import("../api.js"); registry = await import("../../sessions/registry.js"); - registry.initDb(); + (await import("../../shared/db.js")).initDb(); config = { gateway: { host: "127.0.0.1", authDisabled: true }, engines: { default: "codex", codex: {}, claude: {} }, @@ -225,7 +225,7 @@ afterAll(async () => { await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve())); // Close the database before removing its directory: Windows refuses to unlink // a file with an open handle, so the sqlite connection has to go first. - registry.__closeDbForTest(); + (await import("../../shared/db.js")).__closeDbForTest(); removeTempDir(testHome); }); diff --git a/packages/jinn/src/gateway/__tests__/budgets.test.ts b/packages/jinn/src/gateway/__tests__/budgets.test.ts index e38b3c761..726a852c1 100644 --- a/packages/jinn/src/gateway/__tests__/budgets.test.ts +++ b/packages/jinn/src/gateway/__tests__/budgets.test.ts @@ -13,6 +13,7 @@ const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "jinn-budgets-")); process.env.JINN_HOME = tmpHome; fs.mkdirSync(path.join(tmpHome, "org"), { recursive: true }); for (const n of ["over-cap", "under-cap"]) fs.writeFileSync(path.join(tmpHome, "org", `${n}.yaml`), `name: ${n}\nengine: codex\nmodel: gpt-5.5\npersona: Budget fixture\n`); +const dbModule = await import("../../shared/db.js"); const engineRuns: string[] = []; const engineStub = { name: "stub", run: async (o: { sessionId?: string }) => { engineRuns.push(String(o.sessionId)); return { result: "ok" }; }, isAlive: () => false, kill: () => {}, killAll: () => {} }; const queueStub = { enqueue: async (_k: string, fn: () => Promise) => { await fn(); }, clearCancelled: () => {}, clearQueue: () => {}, pauseQueue: () => {}, resumeQueue: () => {}, getPendingCount: () => 0, getTransportState: (_k: string, s: string) => s }; @@ -24,12 +25,12 @@ const apiCtx = { let api: typeof import("../api.js"), reg: typeof import("../../sessions/registry.js"), budgets: typeof import("../budgets.js"); beforeAll(async () => { [api, reg, budgets] = await Promise.all([import("../api.js"), import("../../sessions/registry.js"), import("../budgets.js")]); - reg.initDb(); + dbModule.initDb(); }); /** Bank prior spend for `employee` inside the current calendar month. */ function seedSpend(employee: string, cost: number) { const now = new Date().toISOString(); - reg.initDb().prepare("INSERT INTO sessions (id, engine, source, source_ref, employee, total_cost, created_at, last_activity) VALUES (?, 'codex', 'web', 'seed', ?, ?, ?, ?)").run(`seed-${employee}`, employee, cost, now, now); + dbModule.initDb().prepare("INSERT INTO sessions (id, engine, source, source_ref, employee, total_cost, created_at, last_activity) VALUES (?, 'codex', 'web', 'seed', ?, ?, ?, ?)").run(`seed-${employee}`, employee, cost, now, now); } /** The blocked-turn surface: an ⛔ assistant message on the session. */ const blocked = (id: string) => reg.getMessages(id).some((m) => m.content.startsWith("⛔")); diff --git a/packages/jinn/src/gateway/__tests__/callback-reliability.test.ts b/packages/jinn/src/gateway/__tests__/callback-reliability.test.ts index e8dda39a6..d7e05625a 100644 --- a/packages/jinn/src/gateway/__tests__/callback-reliability.test.ts +++ b/packages/jinn/src/gateway/__tests__/callback-reliability.test.ts @@ -8,6 +8,7 @@ import type { Engine, EngineRunOpts } from "../../shared/types.js"; const home = fs.mkdtempSync(path.join(os.tmpdir(), "jinn-callback-reliability-")); process.env.JINN_HOME = home; +const dbModule = await import("../../shared/db.js"); type Api = typeof import("../api.js"); type Registry = typeof import("../../sessions/registry.js"); @@ -217,11 +218,11 @@ beforeAll(async () => { queueModule = await import("../../sessions/queue.js"); callbacks = await import("../../sessions/callbacks.js"); workItems = await import("../../work-items/store.js"); - registry.initDb(); + dbModule.initDb(); }); beforeEach(() => { - registry.initDb().exec(` + dbModule.initDb().exec(` DELETE FROM work_item_events; DELETE FROM callback_deliveries; DELETE FROM queue_items; @@ -261,15 +262,15 @@ describe("parent callback reliability", () => { expect(queue.isRunning(manager.sessionKey)).toBe(false); expect(seenPrompts).toHaveLength(1); }); - const accepted = registry.initDb().prepare(` + const accepted = dbModule.initDb().prepare(` SELECT id, status FROM callback_deliveries WHERE delivery_kind = 'manager-visibility' `).get(); callbacks.notifyManagerVisibility(manager.id, details); await new Promise((resolve) => setTimeout(resolve, 25)); - expect(registry.initDb().prepare(` + expect(dbModule.initDb().prepare(` SELECT COUNT(*) AS n FROM callback_deliveries WHERE delivery_kind = 'manager-visibility' `).get()).toEqual({ n: 1 }); - expect(registry.initDb().prepare(` + expect(dbModule.initDb().prepare(` SELECT id, status FROM callback_deliveries WHERE delivery_kind = 'manager-visibility' `).get()).toEqual(accepted); expect(registry.getMessages(manager.id).filter((message) => message.role === "notification")) @@ -338,7 +339,7 @@ describe("parent callback reliability", () => { expect(queue.isRunning(parent.sessionKey)).toBe(false); expect(seenPrompts).toHaveLength(1); }); - const rateLimitedPayload = JSON.parse((registry.initDb().prepare(` + const rateLimitedPayload = JSON.parse((dbModule.initDb().prepare(` SELECT payload FROM callback_deliveries WHERE delivery_kind = 'rate-limited' `).get() as { payload: string }).payload) as Record; expect(rateLimitedPayload).not.toHaveProperty("meta"); @@ -359,7 +360,7 @@ describe("parent callback reliability", () => { expect(queue.isRunning(parent.sessionKey)).toBe(false); expect(seenPrompts).toHaveLength(3); }); - expect(registry.initDb().prepare(` + expect(dbModule.initDb().prepare(` SELECT delivery_kind AS kind, COUNT(*) AS n FROM callback_deliveries GROUP BY delivery_kind @@ -441,7 +442,7 @@ describe("parent callback reliability", () => { expect(seenPrompts).toHaveLength(1); }); expect(registry.getMessages(parent.id).filter((message) => message.role === "notification")).toHaveLength(1); - expect(registry.initDb().prepare(` + expect(dbModule.initDb().prepare(` SELECT COUNT(*) AS n FROM callback_deliveries WHERE delivery_kind = 'rate-limit-resumed' `).get()).toEqual({ n: 1 }); expect(events.filter(({ event }) => event === "session:notification")).toHaveLength(1); @@ -473,7 +474,7 @@ describe("parent callback reliability", () => { callbacks.notifyRateLimited(active); callbacks.notifyRateLimitResumed(active); await eventually(() => { - const rows = registry.initDb().prepare(` + const rows = dbModule.initDb().prepare(` SELECT delivery_kind AS kind FROM callback_deliveries ORDER BY delivery_kind `).all(); expect(rows).toEqual([{ kind: "rate-limit-resumed" }, { kind: "rate-limited" }]); @@ -543,7 +544,7 @@ describe("parent callback reliability", () => { expect(queue.isRunning(parent.sessionKey)).toBe(false); expect(seenPrompts).toEqual([expect.stringContaining("one immutable completion")]); expect(registry.getMessages(parent.id).filter((message) => message.role === "notification")).toHaveLength(1); - expect(registry.initDb().prepare("SELECT COUNT(*) AS n FROM callback_deliveries").get()).toEqual({ n: 1 }); + expect(dbModule.initDb().prepare("SELECT COUNT(*) AS n FROM callback_deliveries").get()).toEqual({ n: 1 }); }); expect(routeFetch).toHaveBeenCalledOnce(); expect(events.filter(({ event }) => event === "session:notification")).toHaveLength(1); @@ -623,7 +624,7 @@ describe("parent callback reliability", () => { }); await eventually(() => { - const receipt = registry.initDb().prepare(` + const receipt = dbModule.initDb().prepare(` SELECT status FROM callback_deliveries WHERE delivery_kind = 'delegation-completion-nudge' `).get(); expect(receipt).toEqual({ status: "accepted" }); @@ -631,7 +632,7 @@ describe("parent callback reliability", () => { expect(routeFetch).toHaveBeenCalledOnce(); expect(registry.getMessages(child.id).filter((message) => message.role === "notification")).toHaveLength(1); expect(registry.getMessages(parent.id)).toEqual([]); - expect(registry.initDb().prepare(` + expect(dbModule.initDb().prepare(` SELECT COUNT(*) AS n FROM callback_deliveries WHERE delivery_kind = 'parent-completion' `).get()).toEqual({ n: 0 }); expect(registry.getSession(child.id)?.transportMeta).toMatchObject({ @@ -760,7 +761,7 @@ describe("parent callback reliability", () => { parentSessionId: parent.id, prompt: "finish after poison", }); - const database = registry.initDb(); + const database = dbModule.initDb(); database.pragma("ignore_check_constraints = ON"); database.prepare(` INSERT INTO callback_deliveries ( @@ -857,7 +858,7 @@ describe("parent callback reliability", () => { ]); const stored = registry.getSessionDelivery(delivery.id)!; expect(stored).toMatchObject({ status: "accepted" }); - expect(registry.initDb().prepare("SELECT COUNT(*) AS n FROM queue_items WHERE id = ?").get(stored.queueItemId)) + expect(dbModule.initDb().prepare("SELECT COUNT(*) AS n FROM queue_items WHERE id = ?").get(stored.queueItemId)) .toEqual({ n: 1 }); expect(events.filter(({ event }) => event === "session:notification")).toEqual([ expect.objectContaining({ @@ -1218,7 +1219,7 @@ describe("callback live retry sweep", () => { callbacks.notifyParentSession(child, { result: "accepted once" }); await vi.advanceTimersByTimeAsync(0); - const delivery = registry.initDb().prepare(` + const delivery = dbModule.initDb().prepare(` SELECT id FROM callback_deliveries WHERE delivery_kind = 'parent-completion' `).get() as { id: string }; await vi.advanceTimersByTimeAsync(24 * 60 * 60 * 1_000); @@ -1241,7 +1242,7 @@ describe("callback live retry sweep", () => { await vi.advanceTimersByTimeAsync(delay); await vi.runAllTicks(); } - const delivery = registry.initDb().prepare(`SELECT id FROM callback_deliveries`).get() as { id: string }; + const delivery = dbModule.initDb().prepare(`SELECT id FROM callback_deliveries`).get() as { id: string }; expect(registry.getSessionDelivery(delivery.id)).toMatchObject({ status: "dead_letter", attemptCount: callbacks.CALLBACK_DELIVERY_MAX_ATTEMPTS, diff --git a/packages/jinn/src/gateway/__tests__/codex-home-delete-cleanup.test.ts b/packages/jinn/src/gateway/__tests__/codex-home-delete-cleanup.test.ts index b4a5015eb..d04a84952 100644 --- a/packages/jinn/src/gateway/__tests__/codex-home-delete-cleanup.test.ts +++ b/packages/jinn/src/gateway/__tests__/codex-home-delete-cleanup.test.ts @@ -109,7 +109,7 @@ beforeAll(async () => { const paths = (await import("../../shared/paths.js")) as Paths; ({ ptySnapshotStore } = await import("../../engines/pty-snapshot.js")); CODEX_HOMES_DIR = paths.CODEX_HOMES_DIR; - registry.initDb(); + (await import("../../shared/db.js")).initDb(); }); describe("session delete removes the per-session Codex CODEX_HOME overlay", () => { diff --git a/packages/jinn/src/gateway/__tests__/control-plane-authority.test.ts b/packages/jinn/src/gateway/__tests__/control-plane-authority.test.ts index 0d9f3bdf6..1595b6348 100644 --- a/packages/jinn/src/gateway/__tests__/control-plane-authority.test.ts +++ b/packages/jinn/src/gateway/__tests__/control-plane-authority.test.ts @@ -172,7 +172,7 @@ beforeAll(async () => { approvals = await import("../../work-items/approvals.js"); approvalAuthority = await import("../approval-authority.js"); auth = await import("../auth.js"); - registry.initDb(); + (await import("../../shared/db.js")).initDb(); worker = registry.createSession({ engine: "codex", source: "web", sourceRef: "worker", title: "worker", employee: "platform-worker" }); peer = registry.createSession({ engine: "codex", source: "web", sourceRef: "peer", title: "peer", employee: "platform-peer" }); manager = registry.createSession({ engine: "codex", source: "web", sourceRef: "manager", title: "manager", employee: "platform-manager" }); @@ -538,7 +538,7 @@ describe("portal fallback is a virtual root, not employee authority", () => { expect(approval.approvalTarget).toBe(legacyRoot); expect(approval.approvalTargetKind).toBe("virtual"); - registry.initDb().prepare("UPDATE work_items SET approval_target_kind = NULL WHERE id = ?").run(approval.id); + (await import("../../shared/db.js")).initDb().prepare("UPDATE work_items SET approval_target_kind = NULL WHERE id = ?").run(approval.id); fs.writeFileSync( legacyFile, "name: Legacy Root\ndisplayName: Legacy Root\ndepartment: platform\nrank: employee\nreportsTo: platform-manager\nengine: codex\nmodel: gpt-5.5\npersona: Attempts to claim a legacy persisted approval target.\n", diff --git a/packages/jinn/src/gateway/__tests__/delegations-route.test.ts b/packages/jinn/src/gateway/__tests__/delegations-route.test.ts index 922d001a4..3df9392bb 100644 --- a/packages/jinn/src/gateway/__tests__/delegations-route.test.ts +++ b/packages/jinn/src/gateway/__tests__/delegations-route.test.ts @@ -45,6 +45,7 @@ vi.mock("../../work-items/store.js", async (importOriginal) => { // Isolated home for registry DB + org dir. Set BEFORE the dynamic api import. const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "jinn-delegations-route-")); process.env.JINN_HOME = tmpHome; +const dbModule = await import("../../shared/db.js"); // A real employee for the employee-path assertions (scanOrg requires name+persona). fs.mkdirSync(path.join(tmpHome, "org"), { recursive: true }); @@ -123,7 +124,7 @@ const engineStub = { run: async (opts: Record) => { // Snapshot the DB link AT TURN START — the codex finding-1 pin: the work // item ↔ session link must already be durable when the worker runs. - const row = reg + const row = dbModule .initDb() .prepare("SELECT work_item_id FROM sessions WHERE id = ?") .get(String(opts.sessionId)) as { work_item_id: string | null } | undefined; diff --git a/packages/jinn/src/gateway/__tests__/dispatch-route.test.ts b/packages/jinn/src/gateway/__tests__/dispatch-route.test.ts index 488bb81f2..fcdd6f42b 100644 --- a/packages/jinn/src/gateway/__tests__/dispatch-route.test.ts +++ b/packages/jinn/src/gateway/__tests__/dispatch-route.test.ts @@ -30,6 +30,8 @@ fs.writeFileSync( ].join("\n"), ); +const dbModule = await import("../../shared/db.js"); + type Api = typeof import("../api.js"); type Registry = typeof import("../../sessions/registry.js"); type WorkItems = typeof import("../../work-items/store.js"); @@ -162,7 +164,7 @@ beforeAll(async () => { registry = await import("../../sessions/registry.js"); workItems = await import("../../work-items/store.js"); org = await import("../org.js"); - registry.initDb(); + dbModule.initDb(); const { setJinnAttachGate } = await import("../../mcp/attachment.js"); setJinnAttachGate({ ok: true }); }); diff --git a/packages/jinn/src/gateway/__tests__/external-turns.test.ts b/packages/jinn/src/gateway/__tests__/external-turns.test.ts index 1389093a4..0d8740c9f 100644 --- a/packages/jinn/src/gateway/__tests__/external-turns.test.ts +++ b/packages/jinn/src/gateway/__tests__/external-turns.test.ts @@ -16,7 +16,7 @@ let ext: Ext; beforeAll(async () => { reg = await import("../../sessions/registry.js"); ext = await import("../external-turns.js"); - reg.initDb(); + (await import("../../shared/db.js")).initDb(); }); let seq = 0; diff --git a/packages/jinn/src/gateway/__tests__/file-cache.test.ts b/packages/jinn/src/gateway/__tests__/file-cache.test.ts index 5da53e174..132f68a21 100644 --- a/packages/jinn/src/gateway/__tests__/file-cache.test.ts +++ b/packages/jinn/src/gateway/__tests__/file-cache.test.ts @@ -18,7 +18,7 @@ beforeAll(async () => { paths = await import("../../shared/paths.js"); reg = await import("../../sessions/registry.js"); files = await import("../files.js"); - reg.initDb(); + (await import("../../shared/db.js")).initDb(); }); describe("file cache helpers", () => { diff --git a/packages/jinn/src/gateway/__tests__/file-read.test.ts b/packages/jinn/src/gateway/__tests__/file-read.test.ts index 3a1fea4af..5c60a1b5c 100644 --- a/packages/jinn/src/gateway/__tests__/file-read.test.ts +++ b/packages/jinn/src/gateway/__tests__/file-read.test.ts @@ -22,7 +22,7 @@ beforeAll(async () => { files = await import("../files.js"); registry = await import("../../sessions/registry.js"); identity = await import("../../mcp/identity.js"); - registry.initDb(); + (await import("../../shared/db.js")).initDb(); fileSession = registry.createSession({ engine: "codex", source: "web", sourceRef: "file-reader", employee: "file-reader" }); }); diff --git a/packages/jinn/src/gateway/__tests__/instance-migration-api.test.ts b/packages/jinn/src/gateway/__tests__/instance-migration-api.test.ts index fcadf544c..0d0a34700 100644 --- a/packages/jinn/src/gateway/__tests__/instance-migration-api.test.ts +++ b/packages/jinn/src/gateway/__tests__/instance-migration-api.test.ts @@ -8,6 +8,7 @@ import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest" const registryHome = fs.mkdtempSync(path.join(os.tmpdir(), "jinn-migration-api-registry-")) process.env.JINN_HOME = registryHome +const dbModule = await import("../../shared/db.js"); const home = fs.mkdtempSync(path.join(os.tmpdir(), "jinn-migration-api-home-")) const migrationsDir = path.join(home, "package-migrations") @@ -109,7 +110,7 @@ async function request(method: string, url: string, body?: unknown, authorized = beforeAll(async () => { api = await import("../api.js") registry = await import("../../sessions/registry.js") - registry.initDb() + dbModule.initDb() }) beforeEach(() => { @@ -169,8 +170,8 @@ describe("instance migration API", () => { expect(dispatched).toHaveLength(1) expect(registry.getQueueItems(`instance-migration:${pending.body.migrationKey}`)).toHaveLength(1) - registry.__closeDbForTest() - registry.initDb() + dbModule.__closeDbForTest() + dbModule.initDb() expect(registry.getQueueItems(`instance-migration:${pending.body.migrationKey}`)).toHaveLength(1) const afterRestart = await request("POST", "/api/instance-migration/open", { migrationKey: pending.body.migrationKey }) expect(afterRestart).toMatchObject({ diff --git a/packages/jinn/src/gateway/__tests__/pins-api.test.ts b/packages/jinn/src/gateway/__tests__/pins-api.test.ts index 022c48a92..d8d792ac0 100644 --- a/packages/jinn/src/gateway/__tests__/pins-api.test.ts +++ b/packages/jinn/src/gateway/__tests__/pins-api.test.ts @@ -6,6 +6,7 @@ import { Readable } from "node:stream"; import type { ServerResponse } from "node:http"; process.env.JINN_HOME = fs.mkdtempSync(path.join(os.tmpdir(), "jinn-pins-api-")); +const dbModule = await import("../../shared/db.js"); type Api = typeof import("../api.js"); type Registry = typeof import("../../sessions/registry.js"); @@ -70,7 +71,7 @@ async function request(method: string, url: string, body?: unknown) { beforeAll(async () => { registry = await import("../../sessions/registry.js"); api = await import("../api.js"); - registry.initDb(); + dbModule.initDb(); }); describe("chat pin API", () => { @@ -102,7 +103,7 @@ describe("chat pin API", () => { const newer = registry.createSession({ engine: "codex", source: "web", sourceRef: "web:api-newer" }); const archived = registry.createSession({ engine: "claude", source: "web", sourceRef: "web:api-archived" }); const unpinned = registry.createSession({ engine: "claude", source: "web", sourceRef: "web:api-unpinned" }); - const database = registry.initDb(); + const database = dbModule.initDb(); database.prepare("UPDATE sessions SET last_activity = ? WHERE id = ?").run("2026-02-01T00:00:00.000Z", older.id); database.prepare("UPDATE sessions SET last_activity = ? WHERE id = ?").run("2026-02-02T00:00:00.000Z", newer.id); registry.pinChat(older.id); @@ -131,7 +132,7 @@ describe("chat pin API", () => { }); it("filters an orphaned session key even if the database invariant was bypassed", async () => { - registry.initDb().prepare( + dbModule.initDb().prepare( "INSERT INTO chat_pins (pin_key, pinned_at) VALUES (?, ?)", ).run("missing-session", "2026-01-01T00:00:00.000Z"); diff --git a/packages/jinn/src/gateway/__tests__/privileged-read-guard.test.ts b/packages/jinn/src/gateway/__tests__/privileged-read-guard.test.ts index 57b80c409..eb51b0ef0 100644 --- a/packages/jinn/src/gateway/__tests__/privileged-read-guard.test.ts +++ b/packages/jinn/src/gateway/__tests__/privileged-read-guard.test.ts @@ -194,7 +194,7 @@ beforeAll(async () => { registry = await import("../../sessions/registry.js"); identity = await import("../../mcp/identity.js"); paths = await import("../../shared/paths.js"); - registry.initDb(); + (await import("../../shared/db.js")).initDb(); const fileDir = path.join(paths.FILES_DIR, fileId); fs.mkdirSync(fileDir, { recursive: true }); fs.writeFileSync(path.join(fileDir, "visible.txt"), "visible managed file"); diff --git a/packages/jinn/src/gateway/__tests__/rate-limit-waiting-resume.test.ts b/packages/jinn/src/gateway/__tests__/rate-limit-waiting-resume.test.ts index 8350abfb3..88ba8a146 100644 --- a/packages/jinn/src/gateway/__tests__/rate-limit-waiting-resume.test.ts +++ b/packages/jinn/src/gateway/__tests__/rate-limit-waiting-resume.test.ts @@ -19,11 +19,11 @@ let reg: Reg; beforeAll(async () => { reg = await import("../../sessions/registry.js"); api = await import("../api.js"); - reg.initDb(); + (await import("../../shared/db.js")).initDb(); }); -beforeEach(() => { - const db = reg.initDb(); +beforeEach(async () => { + const db = (await import("../../shared/db.js")).initDb(); db.exec("DELETE FROM messages; DELETE FROM queue_items; DELETE FROM sessions;"); }); diff --git a/packages/jinn/src/gateway/__tests__/session-attachment.test.ts b/packages/jinn/src/gateway/__tests__/session-attachment.test.ts index 9e33bddaf..12f3d1217 100644 --- a/packages/jinn/src/gateway/__tests__/session-attachment.test.ts +++ b/packages/jinn/src/gateway/__tests__/session-attachment.test.ts @@ -16,9 +16,9 @@ let reg: Reg; beforeAll(async () => { reg = await import("../../sessions/registry.js"); files = await import("../files.js"); - reg.initDb(); + (await import("../../shared/db.js")).initDb(); // Seed a session to attach to. - const db = reg.initDb(); + const db = (await import("../../shared/db.js")).initDb(); db.prepare( "INSERT INTO sessions (id, engine, source, source_ref, status, created_at, last_activity) VALUES ('sess-att','claude','web','web:sess-att','idle','t','t')", ).run(); diff --git a/packages/jinn/src/gateway/__tests__/session-attempt-race.test.ts b/packages/jinn/src/gateway/__tests__/session-attempt-race.test.ts index 959242ff6..1845a9911 100644 --- a/packages/jinn/src/gateway/__tests__/session-attempt-race.test.ts +++ b/packages/jinn/src/gateway/__tests__/session-attempt-race.test.ts @@ -118,11 +118,11 @@ beforeAll(async () => { store = await import("../../work-items/store.js"); reconcile = await import("../../work-items/reconcile.js"); managerModule = await import("../../sessions/manager.js"); - registry.initDb(); + (await import("../../shared/db.js")).initDb(); }); -beforeEach(() => { - const db = registry.initDb(); +beforeEach(async () => { + const db = (await import("../../shared/db.js")).initDb(); db.exec("DELETE FROM work_item_events; DELETE FROM queue_items; DELETE FROM messages; DELETE FROM sessions; DELETE FROM work_items;"); }); diff --git a/packages/jinn/src/gateway/__tests__/session-engine-switch-api.test.ts b/packages/jinn/src/gateway/__tests__/session-engine-switch-api.test.ts index 3f66fbbef..e06a4b9ab 100644 --- a/packages/jinn/src/gateway/__tests__/session-engine-switch-api.test.ts +++ b/packages/jinn/src/gateway/__tests__/session-engine-switch-api.test.ts @@ -19,11 +19,11 @@ let reg: Reg; beforeAll(async () => { reg = await import("../../sessions/registry.js"); api = await import("../api.js"); - reg.initDb(); + (await import("../../shared/db.js")).initDb(); }); -beforeEach(() => { - const db = reg.initDb(); +beforeEach(async () => { + const db = (await import("../../shared/db.js")).initDb(); db.exec("DELETE FROM messages; DELETE FROM queue_items; DELETE FROM sessions;"); }); diff --git a/packages/jinn/src/gateway/__tests__/session-spawn-parent-authority.test.ts b/packages/jinn/src/gateway/__tests__/session-spawn-parent-authority.test.ts index a04d87fe6..0d264fecd 100644 --- a/packages/jinn/src/gateway/__tests__/session-spawn-parent-authority.test.ts +++ b/packages/jinn/src/gateway/__tests__/session-spawn-parent-authority.test.ts @@ -116,7 +116,7 @@ async function spawn(body: Record, headers: Record { api = await import("../api.js"); reg = await import("../../sessions/registry.js"); - reg.initDb(); + (await import("../../shared/db.js")).initDb(); }); describe("POST /api/sessions — a session caller is always the parent", () => { diff --git a/packages/jinn/src/gateway/__tests__/skills-api.test.ts b/packages/jinn/src/gateway/__tests__/skills-api.test.ts index 2459c2f3e..4cc35d089 100644 --- a/packages/jinn/src/gateway/__tests__/skills-api.test.ts +++ b/packages/jinn/src/gateway/__tests__/skills-api.test.ts @@ -116,7 +116,7 @@ function workerHeaders(): Record { beforeAll(async () => { api = await import("../api.js"); registry = await import("../../sessions/registry.js"); - registry.initDb(); + (await import("../../shared/db.js")).initDb(); worker = registry.createSession({ engine: "codex", source: "web", diff --git a/packages/jinn/src/gateway/__tests__/status-reconciler.test.ts b/packages/jinn/src/gateway/__tests__/status-reconciler.test.ts index 5238df786..2081c2026 100644 --- a/packages/jinn/src/gateway/__tests__/status-reconciler.test.ts +++ b/packages/jinn/src/gateway/__tests__/status-reconciler.test.ts @@ -31,7 +31,7 @@ function fakeEngine(turnRunning: boolean) { beforeAll(async () => { reg = await import("../../sessions/registry.js"); rec = await import("../status-reconciler.js"); - db = reg.initDb(); + db = (await import("../../shared/db.js")).initDb(); }); beforeEach(() => { diff --git a/packages/jinn/src/gateway/__tests__/streamed-turn-settlement.test.ts b/packages/jinn/src/gateway/__tests__/streamed-turn-settlement.test.ts index 826e1d278..7169e3916 100644 --- a/packages/jinn/src/gateway/__tests__/streamed-turn-settlement.test.ts +++ b/packages/jinn/src/gateway/__tests__/streamed-turn-settlement.test.ts @@ -132,11 +132,11 @@ function normalizedRows(reload: Record) { beforeAll(async () => { api = await import("../api.js"); registry = await import("../../sessions/registry.js"); - registry.initDb(); + (await import("../../shared/db.js")).initDb(); }); -beforeEach(() => { - registry.initDb().exec("DELETE FROM queue_items; DELETE FROM messages; DELETE FROM sessions;"); +beforeEach(async () => { + (await import("../../shared/db.js")).initDb().exec("DELETE FROM queue_items; DELETE FROM messages; DELETE FROM sessions;"); }); describe("completed streamed-turn settlement", () => { diff --git a/packages/jinn/src/gateway/__tests__/work-item-approval-parity.test.ts b/packages/jinn/src/gateway/__tests__/work-item-approval-parity.test.ts index 3586ac374..77371655d 100644 --- a/packages/jinn/src/gateway/__tests__/work-item-approval-parity.test.ts +++ b/packages/jinn/src/gateway/__tests__/work-item-approval-parity.test.ts @@ -18,14 +18,13 @@ import type { ServerResponse } from "node:http"; const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "jinn-wi-parity-")); process.env.JINN_HOME = tmp; +const dbModule = await import("../../shared/db.js"); type Api = typeof import("../api.js"); -type Reg = typeof import("../../sessions/registry.js"); type Store = typeof import("../../work-items/store.js"); type Approvals = typeof import("../../work-items/approvals.js"); type Migrate = typeof import("../../work-items/migrate.js"); let api: Api; -let reg: Reg; let store: Store; let approvals: Approvals; let migrate: Migrate; @@ -92,7 +91,7 @@ interface LegacyApprovalColumns { /** Simulate a pre-slice-4 database row: write the approval COLUMNS directly * (the write path no longer does), exactly what the backfill later consumes. */ function seedLegacyColumns(id: string, legacy: LegacyApprovalColumns): void { - reg + dbModule .initDb() .prepare( `UPDATE work_items SET approval_state = ?, approval_request = ?, approval_ref = ?, approval_target = ?, @@ -201,17 +200,16 @@ const itemIds = new Map(); beforeAll(async () => { api = await import("../api.js"); - reg = await import("../../sessions/registry.js"); store = await import("../../work-items/store.js"); approvals = await import("../../work-items/approvals.js"); migrate = await import("../../work-items/migrate.js"); - reg.initDb(); + (await import("../../shared/db.js")).initDb(); for (const fixture of FIXTURES) { const item = store.createWorkItem({ title: `parity ${fixture.name}`, department: "parity-fixture" }); seedLegacyColumns(item.id, fixture.legacy); itemIds.set(fixture.name, item.id); } - migrate.backfillWorkItemApprovals(reg.initDb()); + migrate.backfillWorkItemApprovals((await import("../../shared/db.js")).initDb()); }); describe("legacy approval-field byte-parity across the dual-read window", () => { diff --git a/packages/jinn/src/gateway/__tests__/work-item-approval-route.test.ts b/packages/jinn/src/gateway/__tests__/work-item-approval-route.test.ts index 331fc9fbd..5b162151d 100644 --- a/packages/jinn/src/gateway/__tests__/work-item-approval-route.test.ts +++ b/packages/jinn/src/gateway/__tests__/work-item-approval-route.test.ts @@ -239,7 +239,7 @@ beforeAll(async () => { approvals = await import("../../work-items/approvals.js"); registry = await import("../../sessions/registry.js"); callbacks = await import("../../sessions/callbacks.js"); - registry.initDb(); + (await import("../../shared/db.js")).initDb(); globalThis.fetch = async () => { throw new Error("work-item approval route test callback transport is offline"); }; @@ -254,9 +254,9 @@ afterEach(async () => { callbacks.__resetCallbackRetrySweepForTest(); }); -afterAll(() => { +afterAll(async () => { globalThis.fetch = processFetch; - registry.__closeDbForTest(); + (await import("../../shared/db.js")).__closeDbForTest(); fs.rmSync(tmpHome, { recursive: true, force: true }); }); diff --git a/packages/jinn/src/gateway/__tests__/work-item-attachments-route.test.ts b/packages/jinn/src/gateway/__tests__/work-item-attachments-route.test.ts index b785b6295..21994f25e 100644 --- a/packages/jinn/src/gateway/__tests__/work-item-attachments-route.test.ts +++ b/packages/jinn/src/gateway/__tests__/work-item-attachments-route.test.ts @@ -167,7 +167,7 @@ beforeAll(async () => { store = await import("../../work-items/store.js"); comments = await import("../../work-items/comments.js"); attachments = await import("../../work-items/attachments.js"); - reg.initDb(); + (await import("../../shared/db.js")).initDb(); }); describe("POST /api/work-items/:id/attachments (multipart)", () => { diff --git a/packages/jinn/src/gateway/__tests__/work-item-edit-authority-route.test.ts b/packages/jinn/src/gateway/__tests__/work-item-edit-authority-route.test.ts index afb3b311f..77d06a929 100644 --- a/packages/jinn/src/gateway/__tests__/work-item-edit-authority-route.test.ts +++ b/packages/jinn/src/gateway/__tests__/work-item-edit-authority-route.test.ts @@ -115,7 +115,7 @@ beforeAll(async () => { api = await import("../api.js"); reg = await import("../../sessions/registry.js"); store = await import("../../work-items/store.js"); - reg.initDb(); + (await import("../../shared/db.js")).initDb(); }); describe("PATCH /api/work-items/:id — content is open, ownership is the operator's", () => { diff --git a/packages/jinn/src/gateway/__tests__/work-item-relations-labels-route.test.ts b/packages/jinn/src/gateway/__tests__/work-item-relations-labels-route.test.ts index fb2093581..917e0da8e 100644 --- a/packages/jinn/src/gateway/__tests__/work-item-relations-labels-route.test.ts +++ b/packages/jinn/src/gateway/__tests__/work-item-relations-labels-route.test.ts @@ -115,7 +115,7 @@ beforeAll(async () => { api = await import("../api.js"); reg = await import("../../sessions/registry.js"); store = await import("../../work-items/store.js"); - db = reg.initDb(); + db = (await import("../../shared/db.js")).initDb(); }); describe("POST/DELETE /api/work-items/:id/relations", () => { diff --git a/packages/jinn/src/gateway/__tests__/work-item-status-as-operator.test.ts b/packages/jinn/src/gateway/__tests__/work-item-status-as-operator.test.ts index 0a8575e92..a23663845 100644 --- a/packages/jinn/src/gateway/__tests__/work-item-status-as-operator.test.ts +++ b/packages/jinn/src/gateway/__tests__/work-item-status-as-operator.test.ts @@ -125,7 +125,7 @@ beforeAll(async () => { reg = await import("../../sessions/registry.js"); store = await import("../../work-items/store.js"); feed = await import("../../work-items/workflow-event-feed.js"); - reg.initDb(); + (await import("../../shared/db.js")).initDb(); }); describe("POST /api/work-items/:id/status — asOperator", () => { diff --git a/packages/jinn/src/gateway/__tests__/work-items-route.test.ts b/packages/jinn/src/gateway/__tests__/work-items-route.test.ts index 73c38efa1..a0bd83b86 100644 --- a/packages/jinn/src/gateway/__tests__/work-items-route.test.ts +++ b/packages/jinn/src/gateway/__tests__/work-items-route.test.ts @@ -15,6 +15,7 @@ import { CALLER_SESSION_CAPABILITY_HEADER, CALLER_SESSION_HEADER, TOOL_CALL_HEAD const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "jinn-wi-route-")); process.env.JINN_HOME = tmp; +const dbModule = await import("../../shared/db.js"); fs.mkdirSync(path.join(tmp, "org"), { recursive: true }); fs.writeFileSync( path.join(tmp, "org", "platform-worker.yaml"), @@ -144,7 +145,7 @@ beforeAll(async () => { api = await import("../api.js"); reg = await import("../../sessions/registry.js"); store = await import("../../work-items/store.js"); - reg.initDb(); + dbModule.initDb(); }); describe("GET /api/work-items/:id/sessions", () => { @@ -257,7 +258,7 @@ describe("GET /api/work-items and /api/search/work-items — pagination, totals, department: "route-filter-department", source: "connector", }); - const db = reg.initDb(); + const db = dbModule.initDb(); db.prepare("UPDATE work_items SET updated_at = ? WHERE id = ?").run("2033-02-10T08:00:00.000Z", match.id); db.prepare("UPDATE work_items SET updated_at = ? WHERE id = ?").run("2033-02-11T08:00:00.000Z", bodyOnly.id); db.prepare("UPDATE work_items SET updated_at = ? WHERE id = ?").run("2033-03-01T08:00:00.000Z", outside.id); @@ -1217,7 +1218,7 @@ describe("POST /api/work-items — provenance and approval routing fields", () = approvals.requestApproval(cooApproval.id, { request: "approve coo", target: "coo" }); approvals.requestApproval(workerApproval.id, { request: "approve worker", target: "platform-worker" }); - const db = reg.initDb(); + const db = dbModule.initDb(); db.prepare("UPDATE work_items SET updated_at = ? WHERE id = ?").run("2030-07-06T10:00:00.000Z", cooApproval.id); db.prepare("UPDATE work_items SET updated_at = ? WHERE id = ?").run("2030-07-06T12:00:00.000Z", cooBlocked.id); db.prepare("UPDATE work_items SET updated_at = ? WHERE id = ?").run("2030-07-06T13:00:00.000Z", workerApproval.id); diff --git a/packages/jinn/src/gateway/__tests__/workflow-parked-notice.test.ts b/packages/jinn/src/gateway/__tests__/workflow-parked-notice.test.ts index 01919633c..f35af3c9a 100644 --- a/packages/jinn/src/gateway/__tests__/workflow-parked-notice.test.ts +++ b/packages/jinn/src/gateway/__tests__/workflow-parked-notice.test.ts @@ -52,7 +52,7 @@ beforeAll(async () => { approvals = await import("../../work-items/approvals.js"); registry = await import("../../sessions/registry.js"); surface = await import("../workflow-todo-surface.js"); - db = registry.initDb(); + db = (await import("../../shared/db.js")).initDb(); }); beforeEach(() => { diff --git a/packages/jinn/src/gateway/__tests__/workflow-todo-comment-feedback.test.ts b/packages/jinn/src/gateway/__tests__/workflow-todo-comment-feedback.test.ts index e1512dde8..1eb7390e2 100644 --- a/packages/jinn/src/gateway/__tests__/workflow-todo-comment-feedback.test.ts +++ b/packages/jinn/src/gateway/__tests__/workflow-todo-comment-feedback.test.ts @@ -162,7 +162,7 @@ beforeAll(async () => { registry = await import("../../sessions/registry.js"); store = await import("../../work-items/store.js"); surface = await import("../workflow-todo-surface.js"); - database = registry.initDb(); + database = (await import("../../shared/db.js")).initDb(); }); beforeEach(() => { diff --git a/packages/jinn/src/gateway/__tests__/workflow-todo-revision.test.ts b/packages/jinn/src/gateway/__tests__/workflow-todo-revision.test.ts index 98361d0f6..412aabd88 100644 --- a/packages/jinn/src/gateway/__tests__/workflow-todo-revision.test.ts +++ b/packages/jinn/src/gateway/__tests__/workflow-todo-revision.test.ts @@ -7,13 +7,13 @@ import path from "node:path"; // keep the suite off the live DB. Set BEFORE importing the store. const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "jinn-wf-todo-revise-")); process.env.JINN_HOME = tmp; +const dbModule = await import("../../shared/db.js"); type Store = typeof import("../../work-items/store.js"); type Approvals = typeof import("../../work-items/approvals.js"); type Comments = typeof import("../../work-items/comments.js"); type Surface = typeof import("../workflow-todo-surface.js"); type Transitions = typeof import("../../work-items/transitions.js"); -type Reg = typeof import("../../sessions/registry.js"); let store: Store; let approvals: Approvals; let comments: Comments; @@ -31,8 +31,7 @@ beforeAll(async () => { comments = await import("../../work-items/comments.js"); surface = await import("../workflow-todo-surface.js"); transitions = await import("../../work-items/transitions.js"); - const reg: Reg = await import("../../sessions/registry.js"); - reg.initDb(); + dbModule.initDb(); }); let seq = 0; diff --git a/packages/jinn/src/gateway/api.ts b/packages/jinn/src/gateway/api.ts index cc0fabe5f..76010fff0 100644 --- a/packages/jinn/src/gateway/api.ts +++ b/packages/jinn/src/gateway/api.ts @@ -27,6 +27,8 @@ import { buildDelegatedActivityIndex } from "../sessions/delegated-activity.js"; import type { SessionManager } from "../sessions/manager.js"; import { buildContext, buildPlatformContextSnapshot, type BuildContextOptions } from "../sessions/context.js"; import { buildPlatformContextRefresh, fingerprintPlatformContext } from "../engines/platform-context.js"; +import { stripControlChars, hasControlBytes } from "../shared/sanitize.js"; +import { initDb } from "../shared/db.js"; import { listSessions, listPinnedSessions, @@ -44,8 +46,6 @@ import { searchSessionsFiltered, getMessageContext, getCostReport, - stripControlChars, - hasControlBytes, MESSAGE_CONTEXT_MAX_RADIUS, type MessageSearchFilter, type SearchSessionsFilter, @@ -89,7 +89,6 @@ import { claimSessionDelivery, getFile, getSessionBySessionKey, - initDb, recordChildReportedToParent, recordTurnAccounting, RESTART_ACK_META_KEY, diff --git a/packages/jinn/src/gateway/budgets.ts b/packages/jinn/src/gateway/budgets.ts index 404255087..f6bb25b79 100644 --- a/packages/jinn/src/gateway/budgets.ts +++ b/packages/jinn/src/gateway/budgets.ts @@ -1,4 +1,4 @@ -import { initDb } from '../sessions/registry.js'; +import { initDb } from '../shared/db.js'; /** True when the employee has spent at or above their calendar-month cap. * This is the only budget question any caller asks — block the turn or not. diff --git a/packages/jinn/src/gateway/external-turns.ts b/packages/jinn/src/gateway/external-turns.ts index 20e23f565..e3829e550 100644 --- a/packages/jinn/src/gateway/external-turns.ts +++ b/packages/jinn/src/gateway/external-turns.ts @@ -1,6 +1,7 @@ import fs from "node:fs"; import { logger } from "../shared/logger.js"; -import { getSession, getMessages, insertMessage, updateMessageContent, updateSession, initDb, type SessionMessage } from "../sessions/registry.js"; +import { getSession, getMessages, insertMessage, updateMessageContent, updateSession, type SessionMessage } from "../sessions/registry.js"; +import { initDb } from "../shared/db.js"; import { findTranscriptForSession } from "../engines/claude-interactive.js"; import type { HookPayload } from "./hook-registry.js"; diff --git a/packages/jinn/src/gateway/files.ts b/packages/jinn/src/gateway/files.ts index 3e6e3cbe9..32c059979 100644 --- a/packages/jinn/src/gateway/files.ts +++ b/packages/jinn/src/gateway/files.ts @@ -10,7 +10,8 @@ import Busboy from "busboy"; import { FILES_DIR, UPLOADS_DIR, JINN_HOME } from "../shared/paths.js"; import { logger } from "../shared/logger.js"; import { redactText } from "../shared/redact.js"; -import { insertFile, getFile, getSession, listFiles, deleteFile, setFilePath, insertMessage, hasControlBytes, type FileMeta, type MessageMedia } from "../sessions/registry.js"; +import { insertFile, getFile, getSession, listFiles, deleteFile, setFilePath, insertMessage, type FileMeta, type MessageMedia } from "../sessions/registry.js"; +import { hasControlBytes } from "../shared/sanitize.js"; import type { ApiContext } from "./api.js"; import { CALLER_SESSION_HEADER, TOOL_CALL_HEADER, UNIDENTIFIED_TOOL_CALL_ERROR, verifySessionCapability } from "../mcp/identity.js"; import { resolveCallerIdentity } from "./session-comm-guards.js"; diff --git a/packages/jinn/src/gateway/server.ts b/packages/jinn/src/gateway/server.ts index 338658f12..344117001 100644 --- a/packages/jinn/src/gateway/server.ts +++ b/packages/jinn/src/gateway/server.ts @@ -20,7 +20,8 @@ import { refreshPiModels, } from "../shared/models.js"; import { configureLogger, logger } from "../shared/logger.js"; -import { initDb, scheduleFtsBackfill, recoverStaleSessions, recoverStaleWorkflowAttemptSessions, recoverStaleQueueItems, clearAllPartialMessages, consumeRestartAcknowledgements, getInterruptedSessions, listSessions, updateSession, getSession, getMessages, getSessionSpend, RESTART_ACK_META_KEY } from "../sessions/registry.js"; +import { scheduleFtsBackfill, recoverStaleSessions, recoverStaleWorkflowAttemptSessions, recoverStaleQueueItems, clearAllPartialMessages, consumeRestartAcknowledgements, getInterruptedSessions, listSessions, updateSession, getSession, getMessages, getSessionSpend, RESTART_ACK_META_KEY } from "../sessions/registry.js"; +import { initDb } from "../shared/db.js"; import { SessionManager, type RouteOptions } from "../sessions/manager.js"; import { recoverSessionDeliveryStateOnStartup } from "../sessions/callbacks.js"; import { InteractiveClaudeEngine } from "../engines/claude-interactive.js"; diff --git a/packages/jinn/src/knowledge/store.ts b/packages/jinn/src/knowledge/store.ts index 251824ffb..04ddbe5cd 100644 --- a/packages/jinn/src/knowledge/store.ts +++ b/packages/jinn/src/knowledge/store.ts @@ -1,7 +1,7 @@ import fs from "node:fs"; import path from "node:path"; import { JINN_HOME } from "../shared/paths.js"; -import { stripControlChars, hasControlBytes } from "../sessions/registry.js"; +import { stripControlChars, hasControlBytes } from "../shared/sanitize.js"; /** * GRS-020b — deterministic search over the company's institutional knowledge diff --git a/packages/jinn/src/mcp/__tests__/cost-cron-tools.test.ts b/packages/jinn/src/mcp/__tests__/cost-cron-tools.test.ts index 6c895c824..da509f549 100644 --- a/packages/jinn/src/mcp/__tests__/cost-cron-tools.test.ts +++ b/packages/jinn/src/mcp/__tests__/cost-cron-tools.test.ts @@ -221,9 +221,9 @@ beforeAll(async () => { }); api = await import("../../gateway/api.js"); registry = await import("../../sessions/registry.js"); - registry.initDb(); + (await import("../../shared/db.js")).initDb(); integrationCallerId = registry.createSession({ engine: "codex", source: "web", sourceRef: "cost-cron-caller", employee: "cost-cron-caller" }).id; - const db = registry.initDb(); + const db = (await import("../../shared/db.js")).initDb(); db.prepare( `INSERT INTO sessions (id, engine, employee, source, source_ref, status, title, total_cost, total_turns, created_at, last_activity) VALUES (?, ?, ?, ?, ?, 'idle', ?, ?, ?, ?, ?)`, diff --git a/packages/jinn/src/mcp/__tests__/file-tools.test.ts b/packages/jinn/src/mcp/__tests__/file-tools.test.ts index 4fe6058f2..a2a1c34d9 100644 --- a/packages/jinn/src/mcp/__tests__/file-tools.test.ts +++ b/packages/jinn/src/mcp/__tests__/file-tools.test.ts @@ -25,7 +25,7 @@ beforeAll(async () => { files = await import("../../gateway/files.js"); registry = await import("../../sessions/registry.js"); identity = await import("../identity.js"); - registry.initDb(); + (await import("../../shared/db.js")).initDb(); fileSession = registry.createSession({ engine: "codex", source: "web", sourceRef: "file-reader", employee: "file-reader" }); }); diff --git a/packages/jinn/src/mcp/__tests__/knowledge-tools.test.ts b/packages/jinn/src/mcp/__tests__/knowledge-tools.test.ts index 04dffa6e1..b576e47be 100644 --- a/packages/jinn/src/mcp/__tests__/knowledge-tools.test.ts +++ b/packages/jinn/src/mcp/__tests__/knowledge-tools.test.ts @@ -228,7 +228,7 @@ beforeAll(async () => { fs.symlinkSync(outsideFile, path.join(home, "knowledge", "escape.md")); api = await import("../../gateway/api.js"); registry = await import("../../sessions/registry.js"); - registry.initDb(); + (await import("../../shared/db.js")).initDb(); integrationCallerId = registry.createSession({ engine: "codex", source: "web", sourceRef: "knowledge-caller", employee: "knowledge-caller" }).id; }); diff --git a/packages/jinn/src/mcp/__tests__/note-tools.test.ts b/packages/jinn/src/mcp/__tests__/note-tools.test.ts index 7ba18dec4..96608c4f4 100644 --- a/packages/jinn/src/mcp/__tests__/note-tools.test.ts +++ b/packages/jinn/src/mcp/__tests__/note-tools.test.ts @@ -248,7 +248,7 @@ beforeAll(async () => { fs.mkdirSync(path.join(integrationHome, "knowledge"), { recursive: true }); api = await import("../../gateway/api.js"); registry = await import("../../sessions/registry.js"); - registry.initDb(); + (await import("../../shared/db.js")).initDb(); callerSessionId = registry.createSession({ engine: "codex", source: "web", diff --git a/packages/jinn/src/mcp/__tests__/org-tools.test.ts b/packages/jinn/src/mcp/__tests__/org-tools.test.ts index d7af1ed20..088b8bcac 100644 --- a/packages/jinn/src/mcp/__tests__/org-tools.test.ts +++ b/packages/jinn/src/mcp/__tests__/org-tools.test.ts @@ -191,7 +191,7 @@ function apiFetch(): typeof fetch { beforeAll(async () => { api = await import("../../gateway/api.js"); registry = await import("../../sessions/registry.js"); - registry.initDb(); + (await import("../../shared/db.js")).initDb(); integrationCallerId = registry.createSession({ engine: "codex", source: "web", sourceRef: "org-caller", employee: "org-caller" }).id; // Generic synthetic org — three YAMLs under the temp ORG_DIR. const orgDir = path.join(process.env.JINN_HOME!, "org"); diff --git a/packages/jinn/src/mcp/__tests__/search-tools.test.ts b/packages/jinn/src/mcp/__tests__/search-tools.test.ts index 8bf3fb068..283caad5b 100644 --- a/packages/jinn/src/mcp/__tests__/search-tools.test.ts +++ b/packages/jinn/src/mcp/__tests__/search-tools.test.ts @@ -383,7 +383,7 @@ function seedSession(fields: { employee?: string; engine?: string; status?: stri beforeAll(async () => { api = await import("../../gateway/api.js"); registry = await import("../../sessions/registry.js"); - registry.initDb(); + (await import("../../shared/db.js")).initDb(); integrationCallerId = seedSession({ employee: "search-caller", engine: "codex", title: "Search caller" }); }); diff --git a/packages/jinn/src/mcp/__tests__/work-item-tools.test.ts b/packages/jinn/src/mcp/__tests__/work-item-tools.test.ts index 1db734e3f..27e3ce058 100644 --- a/packages/jinn/src/mcp/__tests__/work-item-tools.test.ts +++ b/packages/jinn/src/mcp/__tests__/work-item-tools.test.ts @@ -521,7 +521,7 @@ beforeAll(async () => { registry = await import("../../sessions/registry.js"); store = await import("../../work-items/store.js"); approvals = await import("../../work-items/approvals.js"); - registry.initDb(); + (await import("../../shared/db.js")).initDb(); }); describe("work-item tools — integration against the real API + store", () => { diff --git a/packages/jinn/src/mcp/knowledge-tools.ts b/packages/jinn/src/mcp/knowledge-tools.ts index 0d60e3b45..a85436b86 100644 --- a/packages/jinn/src/mcp/knowledge-tools.ts +++ b/packages/jinn/src/mcp/knowledge-tools.ts @@ -1,4 +1,5 @@ import { assertBoundCaller, gatewayGet, JinnMcpToolError, type JinnMcpTool } from "./toolkit.js"; +import { hasControlBytes } from "../shared/sanitize.js"; /** * GRS-020b — the knowledge tool group of the `jinn` MCP server: agents search @@ -27,19 +28,6 @@ export const KNOWLEDGE_QUERY_CHAR_CAP = 512; /** Tool-side relative-path cap (real paths are far shorter). */ export const KNOWLEDGE_PATH_CHAR_CAP = 300; -/** GRS-020b-fix: REJECT (never strip) control bytes on the RAW path arg. A - * trailing `%00`/NUL survives `.trim()`, and the gateway route's free-text - * cleaner would strip-then-accept it — so the tool fails first, on the raw - * value, before any normalization. Local codepoint predicate keeps this MCP - * module free of a sessions/registry (better-sqlite3) import. */ -function hasControlBytes(value: string): boolean { - for (let i = 0; i < value.length; i++) { - const c = value.charCodeAt(i); - if (c <= 0x1f || c === 0x7f) return true; - } - return false; -} - function requireString(args: Record, name: string, max: number): string { const v = args[name]; const s = typeof v === "string" ? v.trim() : ""; diff --git a/packages/jinn/src/sessions/__tests__/activity-schema-drop.test.ts b/packages/jinn/src/sessions/__tests__/activity-schema-drop.test.ts index 5ff5c2dba..9949d7787 100644 --- a/packages/jinn/src/sessions/__tests__/activity-schema-drop.test.ts +++ b/packages/jinn/src/sessions/__tests__/activity-schema-drop.test.ts @@ -48,7 +48,7 @@ function makeHome(seed?: string): string { return home; } -/** Boot the registry against `home` in a fresh module graph — SESSIONS_DB is +/** Boot the database against `home` in a fresh module graph — SESSIONS_DB is * resolved once per module instance from JINN_HOME. Returns logged warnings. */ async function bootRegistry(home: string): Promise { process.env.JINN_HOME = home; @@ -56,9 +56,9 @@ async function bootRegistry(home: string): Promise { const warnings: string[] = []; const { logger } = await import("../../shared/logger.js"); vi.spyOn(logger, "warn").mockImplementation((message: string) => { warnings.push(message); }); - const registry = await import("../registry.js"); - registry.initDb(); - registry.__closeDbForTest(); + const dbModule = await import("../../shared/db.js"); + dbModule.initDb(); + dbModule.__closeDbForTest(); return warnings; } diff --git a/packages/jinn/src/sessions/__tests__/callback-deliveries.test.ts b/packages/jinn/src/sessions/__tests__/callback-deliveries.test.ts index 1c9efa849..feead4566 100644 --- a/packages/jinn/src/sessions/__tests__/callback-deliveries.test.ts +++ b/packages/jinn/src/sessions/__tests__/callback-deliveries.test.ts @@ -6,6 +6,8 @@ import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; const home = fs.mkdtempSync(path.join(os.tmpdir(), "jinn-callback-deliveries-")); process.env.JINN_HOME = home; +const migrateModule = await import("../migrate.js"); +const dbModule = await import("../../shared/db.js"); type Registry = typeof import("../registry.js"); @@ -50,7 +52,7 @@ function createSession(id: string, parentSessionId?: string) { parentSessionId, prompt: `session ${id}`, }); - registry.initDb().prepare("UPDATE sessions SET id = ? WHERE id = ?").run(id, session.id); + dbModule.initDb().prepare("UPDATE sessions SET id = ? WHERE id = ?").run(id, session.id); return registry.getSession(id)!; } @@ -97,7 +99,7 @@ function installExactChildDeliverySchema(database: Database.Database): void { } function openRegistryOwnedExactChildDeliverySchema(): Database.Database { - registry.__closeDbForTest(); + dbModule.__closeDbForTest(); const sessionsDir = path.join(home, "sessions"); fs.rmSync(sessionsDir, { recursive: true, force: true }); fs.mkdirSync(sessionsDir, { recursive: true }); @@ -109,11 +111,11 @@ function openRegistryOwnedExactChildDeliverySchema(): Database.Database { beforeAll(async () => { registry = await import("../registry.js"); - registry.initDb(); + dbModule.initDb(); }); beforeEach(() => { - const database = registry.initDb(); + const database = dbModule.initDb(); const hasCallbackTable = database .prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'callback_deliveries'") .get(); @@ -173,7 +175,7 @@ describe("callback delivery schema migration", () => { const validBefore = seedDatabase.prepare("SELECT * FROM callback_deliveries WHERE id LIKE 'valid-%' ORDER BY id").all(); seedDatabase.close(); - const database = registry.initDb(); + const database = dbModule.initDb(); const databaseFile = (database.pragma("database_list") as Array<{ file: string }>)[0]?.file; expect(databaseFile).toBe(path.join(fs.realpathSync(home), "sessions", "registry.db")); @@ -265,7 +267,7 @@ describe("callback delivery schema migration", () => { }); }) as Database.Database["prepare"]); - expect(() => registry.migrateCallbackDeliveriesSchema(database)).toThrow("forced mid-copy failure"); + expect(() => migrateModule.migrateCallbackDeliveriesSchema(database)).toThrow("forced mid-copy failure"); prepareSpy.mockRestore(); expect(database.prepare("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'callback_deliveries'").get()).toEqual(beforeSql); expect(database.prepare("SELECT * FROM callback_deliveries ORDER BY id").all()).toEqual(beforeRows); @@ -273,7 +275,7 @@ describe("callback delivery schema migration", () => { }); it("reopens the current child-session schema as one generic session delivery without rewriting its receipt", () => { - const database = registry.initDb(); + const database = dbModule.initDb(); database.exec(` DROP TABLE callback_deliveries; CREATE TABLE callback_deliveries ( @@ -323,8 +325,8 @@ describe("callback delivery schema migration", () => { ); `); - registry.__closeDbForTest(); - registry.initDb(); + dbModule.__closeDbForTest(); + dbModule.initDb(); expect(registry.getSessionDelivery("delivery-old")).toMatchObject({ targetSessionId: "parent-a", @@ -337,14 +339,14 @@ describe("callback delivery schema migration", () => { status: "pending", payload: { message: "existing payload", displayMessage: "Existing payload" }, }); - expect(registry.initDb().prepare("SELECT COUNT(*) AS n FROM callback_deliveries").get()).toEqual({ n: 1 }); + expect(dbModule.initDb().prepare("SELECT COUNT(*) AS n FROM callback_deliveries").get()).toEqual({ n: 1 }); }); it("is idempotent and installs the durable composite uniqueness contract", () => { const database = new Database(":memory:"); - registry.migrateCallbackDeliveriesSchema(database); - registry.migrateCallbackDeliveriesSchema(database); + migrateModule.migrateCallbackDeliveriesSchema(database); + migrateModule.migrateCallbackDeliveriesSchema(database); const columns = database.prepare("PRAGMA table_info(callback_deliveries)").all() as Array<{ name: string }>; expect(columns.map((column) => column.name)).toEqual(expect.arrayContaining([ @@ -395,7 +397,7 @@ describe("callback delivery schema migration", () => { const database = new Database(":memory:"); database.exec("CREATE TABLE callback_deliveries (id TEXT PRIMARY KEY)"); - expect(() => registry.migrateCallbackDeliveriesSchema(database)).toThrow(/incompatible callback_deliveries schema/i); + expect(() => migrateModule.migrateCallbackDeliveriesSchema(database)).toThrow(/incompatible callback_deliveries schema/i); const columns = database.prepare("PRAGMA table_info(callback_deliveries)").all() as Array<{ name: string }>; expect(columns.map((column) => column.name)).toEqual(["id"]); @@ -429,8 +431,8 @@ describe("callback delivery schema migration", () => { '{broken', 'pending', NULL, NULL, '2026-01-01T00:00:01.000Z', NULL); `); - registry.migrateCallbackDeliveriesSchema(database); - registry.migrateCallbackDeliveriesSchema(database); + migrateModule.migrateCallbackDeliveriesSchema(database); + migrateModule.migrateCallbackDeliveriesSchema(database); expect(database.prepare(` SELECT target_session_id AS targetSessionId, source_kind AS sourceKind, @@ -487,10 +489,10 @@ describe("callback delivery schema migration", () => { insert.run(`ws-${hex}`, `${edge}parent-${hex}${edge}`, `child-${hex}`, `attempt-${hex}`, new Date().toISOString()); } - registry.migrateCallbackDeliveriesSchema(database); + migrateModule.migrateCallbackDeliveriesSchema(database); database.close(); database = new Database(dbPath); - registry.migrateCallbackDeliveriesSchema(database); + migrateModule.migrateCallbackDeliveriesSchema(database); const rows = database.prepare(` SELECT id, target_session_id AS targetSessionId FROM callback_deliveries ORDER BY id @@ -534,7 +536,7 @@ describe("callback delivery schema migration", () => { 'pending', -2, '2026-01-01T00:00:00.000Z'); `); - registry.migrateCallbackDeliveriesSchema(database); + migrateModule.migrateCallbackDeliveriesSchema(database); expect(() => database.prepare(` INSERT INTO callback_deliveries ( @@ -635,7 +637,7 @@ describe("callback delivery identity", () => { }); it("uses the complete Unicode White_Space set for claims and SQLite constraints", () => { - const database = registry.initDb(); + const database = dbModule.initDb(); for (const codePoint of UNICODE_WHITE_SPACE) { const edge = String.fromCodePoint(codePoint); const hex = codePoint.toString(16); @@ -693,7 +695,7 @@ describe("callback delivery identity", () => { ["padded source attempt", "source_attempt", " token ", 1], ["zero source version", "source_version", "attempt-2", 0], ])("rejects direct SQL identities that violate canonical constraints: %s", (_label, column, value, version) => { - const database = registry.initDb(); + const database = dbModule.initDb(); const row = callbackInput({ targetSessionId: column === "target_session_id" ? value : "parent-sql", sourceId: column === "source_id" ? value : "child-sql", @@ -836,7 +838,7 @@ describe("callback delivery retry lifecycle", () => { }); it("quarantines a poison pending row and continues returning later valid receipts", () => { - const database = registry.initDb(); + const database = dbModule.initDb(); database.pragma("ignore_check_constraints = ON"); database.prepare(` INSERT INTO callback_deliveries ( @@ -865,7 +867,7 @@ describe("callback delivery retry lifecycle", () => { }); it("quarantines mixed identity and lifecycle poison per row and continues after reopen", () => { - const database = registry.initDb(); + const database = dbModule.initDb(); const baseValues = { payload: JSON.stringify({ message: "poison", displayMessage: "poison" }), createdAt: new Date().toISOString(), @@ -912,7 +914,7 @@ describe("callback delivery retry lifecycle", () => { lastError: expect.stringMatching(/(?:callback|session) delivery/i), }))); - registry.__closeDbForTest(); + dbModule.__closeDbForTest(); expect(registry.listPendingSessionDeliveries()).toEqual([ expect.objectContaining({ id: valid.id }), ]); @@ -1050,7 +1052,7 @@ describe("callback delivery acceptance", () => { describe("session terminal versions", () => { it("upgrades a migrated tokenless terminal row from version zero when its first callback is claimed", () => { const child = createSession("child-1"); - registry.initDb().prepare(` + dbModule.initDb().prepare(` UPDATE sessions SET status = 'idle', attempt_outcome = 'succeeded', attempt_token = NULL, attempt_terminal_version = 0 WHERE id = ? diff --git a/packages/jinn/src/sessions/__tests__/delegation-completion-contract-atomic.test.ts b/packages/jinn/src/sessions/__tests__/delegation-completion-contract-atomic.test.ts index 15dcfacd5..7bfd92d1a 100644 --- a/packages/jinn/src/sessions/__tests__/delegation-completion-contract-atomic.test.ts +++ b/packages/jinn/src/sessions/__tests__/delegation-completion-contract-atomic.test.ts @@ -18,7 +18,7 @@ beforeAll(async () => { registry = await import("../registry.js"); workItems = await import("../../work-items/store.js"); contract = await import("../delegation-completion-contract.js"); - registry.initDb(); + (await import("../../shared/db.js")).initDb(); }); describe("delegation completion contract atomic guard", () => { diff --git a/packages/jinn/src/sessions/__tests__/engine-sessions.test.ts b/packages/jinn/src/sessions/__tests__/engine-sessions.test.ts index cdc2eb29c..da2c1df84 100644 --- a/packages/jinn/src/sessions/__tests__/engine-sessions.test.ts +++ b/packages/jinn/src/sessions/__tests__/engine-sessions.test.ts @@ -8,11 +8,12 @@ import Database from "better-sqlite3"; // is resolved at module load). const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "jinn-engine-sessions-")); process.env.JINN_HOME = tmp; +const migrateModule = await import("../migrate.js"); const reg = await import("../registry.js"); describe("engine session refs", () => { - beforeEach(() => { - const db = reg.initDb(); + beforeEach(async () => { + const db = (await import("../../shared/db.js")).initDb(); db.exec("DELETE FROM messages; DELETE FROM queue_items; DELETE FROM sessions;"); }); @@ -34,7 +35,7 @@ describe("engine session refs", () => { ) `); - reg.migrateSessionsSchema(db); + migrateModule.migrateSessionsSchema(db); const cols = db.prepare("PRAGMA table_info(sessions)").all() as Array<{ name: string }>; expect(cols.map((col) => col.name)).toContain("engine_sessions"); diff --git a/packages/jinn/src/sessions/__tests__/fixtures/callback-open-worker.mjs b/packages/jinn/src/sessions/__tests__/fixtures/callback-open-worker.mjs index 9e2f889e6..3f41d6c68 100644 --- a/packages/jinn/src/sessions/__tests__/fixtures/callback-open-worker.mjs +++ b/packages/jinn/src/sessions/__tests__/fixtures/callback-open-worker.mjs @@ -35,7 +35,7 @@ try { payload, }); process.stdout.write(JSON.stringify({ commonId: common.delivery.id, distinctId: distinct.delivery.id })); - registry.__closeDbForTest(); + (await import(new URL("../shared/db.js", pathToFileURL(registryPath).href).href)).__closeDbForTest(); } catch (error) { process.stderr.write(error instanceof Error ? `${error.stack ?? error.message}\n` : `${String(error)}\n`); process.exitCode = 1; diff --git a/packages/jinn/src/sessions/__tests__/get-partial-messages.test.ts b/packages/jinn/src/sessions/__tests__/get-partial-messages.test.ts index 23310472b..61d8a0b01 100644 --- a/packages/jinn/src/sessions/__tests__/get-partial-messages.test.ts +++ b/packages/jinn/src/sessions/__tests__/get-partial-messages.test.ts @@ -6,12 +6,13 @@ import path from "node:path"; // Throwaway DB before importing the registry (SESSIONS_DB resolves from JINN_HOME). const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "jinn-getpartial-")); process.env.JINN_HOME = tmp; +const dbModule = await import("../../shared/db.js"); type Reg = typeof import("../registry.js"); let reg: Reg; function newSession(id: string): void { - reg.initDb().prepare( + dbModule.initDb().prepare( "INSERT INTO sessions (id, engine, source, source_ref, status, created_at, last_activity) VALUES (?, 'claude','web',?, 'running','t','t')", ).run(id, `web:${id}`); } @@ -47,7 +48,7 @@ describe("getPartialMessages (bounded turn-settle read)", () => { }); it("reads via an index seek, never a full messages table scan", () => { - const db = reg.initDb(); + const db = dbModule.initDb(); const plan = db .prepare( "EXPLAIN QUERY PLAN SELECT rowid, id, role, content, timestamp, media, partial, seq, tool_call, blocks, meta FROM messages WHERE session_id = ? AND partial = 1 ORDER BY timestamp ASC, COALESCE(seq, 0) ASC, rowid ASC", diff --git a/packages/jinn/src/sessions/__tests__/messages-media.test.ts b/packages/jinn/src/sessions/__tests__/messages-media.test.ts index 81e1eb001..377f792ea 100644 --- a/packages/jinn/src/sessions/__tests__/messages-media.test.ts +++ b/packages/jinn/src/sessions/__tests__/messages-media.test.ts @@ -8,6 +8,8 @@ import Database from "better-sqlite3"; // resolved from JINN_HOME at module load). const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "jinn-media-")); process.env.JINN_HOME = tmp; +const migrateModule = await import("../migrate.js"); +const dbModule = await import("../../shared/db.js"); type Reg = typeof import("../registry.js"); let reg: Reg; @@ -18,14 +20,14 @@ beforeAll(async () => { describe("messages.media column", () => { it("adds a nullable media column on init", () => { - const db = reg.initDb(); + const db = dbModule.initDb(); const cols = db.prepare("PRAGMA table_info(messages)").all() as Array<{ name: string }>; expect(cols.map((c) => c.name)).toContain("media"); expect(cols.map((c) => c.name)).toContain("meta"); }); it("round-trips media as parsed JSON, defaulting to undefined", () => { - const db = reg.initDb(); + const db = dbModule.initDb(); // a plain message has no media db.prepare( "INSERT INTO sessions (id, engine, source, source_ref, status, created_at, last_activity) VALUES ('s1','claude','web','web:s1','idle','t','t')", @@ -62,7 +64,7 @@ describe("messages.media column", () => { "INSERT INTO messages (id, session_id, role, content, timestamp) VALUES ('m1','s','user','old',1)", ).run(); - reg.migrateMessagesSchema(legacy); + migrateModule.migrateMessagesSchema(legacy); const cols = legacy.prepare("PRAGMA table_info(messages)").all() as Array<{ name: string }>; expect(cols.map((c) => c.name)).toContain("media"); @@ -78,7 +80,7 @@ describe("messages.media column", () => { }); it("duplicateSession copies message media", () => { - const db = reg.initDb(); + const db = dbModule.initDb(); db.prepare( "INSERT INTO sessions (id, engine, engine_session_id, source, source_ref, status, created_at, last_activity) VALUES ('src','claude','eng-1','web','web:src','idle','t','t')", ).run(); diff --git a/packages/jinn/src/sessions/__tests__/messages-pagination.test.ts b/packages/jinn/src/sessions/__tests__/messages-pagination.test.ts index e2e82e76f..1d43c5745 100644 --- a/packages/jinn/src/sessions/__tests__/messages-pagination.test.ts +++ b/packages/jinn/src/sessions/__tests__/messages-pagination.test.ts @@ -6,19 +6,20 @@ import path from "node:path"; // Point the DB at a throwaway dir BEFORE importing the registry. const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "jinn-msg-page-")); process.env.JINN_HOME = tmp; +const dbModule = await import("../../shared/db.js"); type Reg = typeof import("../registry.js"); let reg: Reg; function insertSession(id: string) { - const db = reg.initDb(); + const db = dbModule.initDb(); db.prepare( "INSERT INTO sessions (id, engine, source, source_ref, status, created_at, last_activity) VALUES (?, 'claude', 'web', ?, 'idle', 't', 't')", ).run(id, `web:${id}`); } function insertMessage(id: string, content: string, timestamp: number, seq: number | null = null) { - const db = reg.initDb(); + const db = dbModule.initDb(); db.prepare( "INSERT INTO messages (id, session_id, role, content, timestamp, seq) VALUES (?, 's-page', 'assistant', ?, ?, ?)", ).run(id, content, timestamp, seq); @@ -26,7 +27,7 @@ function insertMessage(id: string, content: string, timestamp: number, seq: numb beforeAll(async () => { reg = await import("../registry.js"); - reg.initDb(); + dbModule.initDb(); insertSession("s-page"); insertMessage("m1", "one", 1000); insertMessage("m2", "two", 2000); diff --git a/packages/jinn/src/sessions/__tests__/messages-partial.test.ts b/packages/jinn/src/sessions/__tests__/messages-partial.test.ts index 7eabf8eca..9734a5aef 100644 --- a/packages/jinn/src/sessions/__tests__/messages-partial.test.ts +++ b/packages/jinn/src/sessions/__tests__/messages-partial.test.ts @@ -7,6 +7,8 @@ import Database from "better-sqlite3"; // Throwaway DB before importing the registry (SESSIONS_DB resolves from JINN_HOME). const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "jinn-partial-")); process.env.JINN_HOME = tmp; +const migrateModule = await import("../migrate.js"); +const dbModule = await import("../../shared/db.js"); type Reg = typeof import("../registry.js"); let reg: Reg; @@ -16,7 +18,7 @@ let reg: Reg; let foldPartialText: typeof import("../../gateway/api.js").foldPartialText; function newSession(id: string): void { - reg.initDb().prepare( + dbModule.initDb().prepare( "INSERT INTO sessions (id, engine, source, source_ref, status, created_at, last_activity) VALUES (?, 'claude','web',?, 'running','t','t')", ).run(id, `web:${id}`); } @@ -28,7 +30,7 @@ beforeAll(async () => { describe("messages partial (mid-turn streaming) blocks", () => { it("adds nullable partial/seq/tool_call/tool_id/blocks/meta columns on init", () => { - const db = reg.initDb(); + const db = dbModule.initDb(); const cols = (db.prepare("PRAGMA table_info(messages)").all() as Array<{ name: string }>).map((c) => c.name); expect(cols).toContain("partial"); expect(cols).toContain("seq"); @@ -271,11 +273,11 @@ describe("messages partial (mid-turn streaming) blocks", () => { payload: { action: "trigger-approval-decided" }, }); - const beforeReplay = reg.initDb() + const beforeReplay = dbModule.initDb() .prepare("SELECT content, blocks FROM messages WHERE session_id = ?") .get("workflow-activity-order"); reg.applyBlockEnvelope("workflow-activity-order", approvalDecided); - expect(reg.initDb().prepare("SELECT content, blocks FROM messages WHERE session_id = ?").get("workflow-activity-order")) + expect(dbModule.initDb().prepare("SELECT content, blocks FROM messages WHERE session_id = ?").get("workflow-activity-order")) .toEqual(beforeReplay); }); @@ -419,7 +421,7 @@ describe("messages partial (mid-turn streaming) blocks", () => { it("drops obsolete stored block types when reading messages", () => { newSession("block-legacy-types"); - reg.initDb().prepare( + dbModule.initDb().prepare( "INSERT INTO messages (id, session_id, role, content, timestamp, blocks) VALUES ('legacy-blocks', ?, 'assistant', 'Mixed blocks', ?, ?)", ).run("block-legacy-types", Date.now(), JSON.stringify([ { @@ -442,7 +444,7 @@ describe("messages partial (mid-turn streaming) blocks", () => { it("removing a block from a mixed row preserves the row text", () => { newSession("block-mixed"); - reg.initDb().prepare( + dbModule.initDb().prepare( "INSERT INTO messages (id, session_id, role, content, timestamp, blocks) VALUES ('mixed', ?, 'assistant', 'Keep this answer text', ?, ?)", ).run("block-mixed", Date.now(), JSON.stringify([{ id: "plan", @@ -465,7 +467,7 @@ describe("messages partial (mid-turn streaming) blocks", () => { it("patching a block on a mixed row preserves the row text", () => { newSession("block-mixed-patch"); - reg.initDb().prepare( + dbModule.initDb().prepare( "INSERT INTO messages (id, session_id, role, content, timestamp, blocks) VALUES ('mixed-patch', ?, 'assistant', 'Keep this answer text', ?, ?)", ).run("block-mixed-patch", Date.now(), JSON.stringify([{ id: "plan", @@ -522,7 +524,7 @@ describe("messages partial (mid-turn streaming) blocks", () => { ); legacy.prepare("INSERT INTO messages (id, session_id, role, content, timestamp) VALUES ('m1','s','user','old',1)").run(); - reg.migrateMessagesSchema(legacy); + migrateModule.migrateMessagesSchema(legacy); const cols = (legacy.prepare("PRAGMA table_info(messages)").all() as Array<{ name: string }>).map((c) => c.name); expect(cols).toContain("partial"); diff --git a/packages/jinn/src/sessions/__tests__/pins.test.ts b/packages/jinn/src/sessions/__tests__/pins.test.ts index 0d8e3bbf4..8d858acfd 100644 --- a/packages/jinn/src/sessions/__tests__/pins.test.ts +++ b/packages/jinn/src/sessions/__tests__/pins.test.ts @@ -5,13 +5,14 @@ import path from "node:path"; const home = fs.mkdtempSync(path.join(os.tmpdir(), "jinn-pins-")); process.env.JINN_HOME = home; +const dbModule = await import("../../shared/db.js"); type Registry = typeof import("../registry.js"); let registry: Registry; beforeAll(async () => { registry = await import("../registry.js"); - registry.initDb(); + dbModule.initDb(); }); describe("chat pins", () => { @@ -56,7 +57,7 @@ describe("chat pins", () => { source: "web", sourceRef: "web:unpinned", }); - const database = registry.initDb(); + const database = dbModule.initDb(); database.prepare("UPDATE sessions SET last_activity = ? WHERE id = ?").run("2026-01-01T00:00:00.000Z", older.id); database.prepare("UPDATE sessions SET last_activity = ? WHERE id = ?").run("2026-01-02T00:00:00.000Z", newer.id); database.prepare("UPDATE sessions SET last_activity = ? WHERE id = ?").run("2026-01-03T00:00:00.000Z", archived.id); @@ -89,7 +90,7 @@ describe("chat pins", () => { expect(registry.deleteSession(single.id)).toBe(true); expect(registry.deleteSessions([bulk.id])).toBe(1); - const database = registry.initDb(); + const database = dbModule.initDb(); const rows = database .prepare("SELECT pin_key FROM chat_pins WHERE pin_key IN (?, ?)") .all(single.id, bulk.id); @@ -100,11 +101,11 @@ describe("chat pins", () => { }); it("creates chat_pins when opening a database that predates the table", () => { - const database = registry.initDb(); + const database = dbModule.initDb(); database.exec("DROP TABLE chat_pins"); - registry.__closeDbForTest(); + dbModule.__closeDbForTest(); - const reopened = registry.initDb(); + const reopened = dbModule.initDb(); const table = reopened .prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'chat_pins'") .get(); diff --git a/packages/jinn/src/sessions/__tests__/platform-context-dispatch.test.ts b/packages/jinn/src/sessions/__tests__/platform-context-dispatch.test.ts index 18a706e10..75cdc588f 100644 --- a/packages/jinn/src/sessions/__tests__/platform-context-dispatch.test.ts +++ b/packages/jinn/src/sessions/__tests__/platform-context-dispatch.test.ts @@ -162,11 +162,11 @@ beforeAll(async () => { registry = await import("../registry.js"); managerModule = await import("../manager.js"); api = await import("../../gateway/api.js"); - registry.initDb(); + (await import("../../shared/db.js")).initDb(); }); -beforeEach(() => { - registry.initDb().exec("DELETE FROM messages; DELETE FROM queue_items; DELETE FROM sessions;"); +beforeEach(async () => { + (await import("../../shared/db.js")).initDb().exec("DELETE FROM messages; DELETE FROM queue_items; DELETE FROM sessions;"); }); describe("SessionManager platform context dispatch", () => { diff --git a/packages/jinn/src/sessions/__tests__/prompt-excerpt.test.ts b/packages/jinn/src/sessions/__tests__/prompt-excerpt.test.ts index 8aaf23cf8..b583dffc0 100644 --- a/packages/jinn/src/sessions/__tests__/prompt-excerpt.test.ts +++ b/packages/jinn/src/sessions/__tests__/prompt-excerpt.test.ts @@ -8,13 +8,14 @@ import Database from "better-sqlite3"; // resolved from JINN_HOME at module load). const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "jinn-prompt-excerpt-")); process.env.JINN_HOME = tmp; +const migrateModule = await import("../migrate.js"); type Reg = typeof import("../registry.js"); let reg: Reg; beforeAll(async () => { reg = await import("../registry.js"); - reg.initDb(); + (await import("../../shared/db.js")).initDb(); }); describe("promptExcerptOf", () => { @@ -89,7 +90,7 @@ describe("migrateSessionsSchema prompt_excerpt migration", () => { expect(hasCol()).toBe(false); - reg.migrateSessionsSchema(db); + migrateModule.migrateSessionsSchema(db); expect(hasCol()).toBe(true); expect( (db.prepare("SELECT prompt_excerpt FROM sessions WHERE id = ?").get("old-1") as { @@ -98,7 +99,7 @@ describe("migrateSessionsSchema prompt_excerpt migration", () => { ).toBeNull(); // Re-running must not throw and must not duplicate the column. - expect(() => reg.migrateSessionsSchema(db)).not.toThrow(); + expect(() => migrateModule.migrateSessionsSchema(db)).not.toThrow(); const cols = (db.prepare("PRAGMA table_info(sessions)").all() as Array<{ name: string }>).filter( (c) => c.name === "prompt_excerpt", ); diff --git a/packages/jinn/src/sessions/__tests__/registry-delete-queue-items.test.ts b/packages/jinn/src/sessions/__tests__/registry-delete-queue-items.test.ts index 25d83cb1a..841789690 100644 --- a/packages/jinn/src/sessions/__tests__/registry-delete-queue-items.test.ts +++ b/packages/jinn/src/sessions/__tests__/registry-delete-queue-items.test.ts @@ -7,6 +7,7 @@ import path from "node:path"; // resolved from JINN_HOME at module load). const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "jinn-delq-")); process.env.JINN_HOME = tmp; +const dbModule = await import("../../shared/db.js"); type Reg = typeof import("../registry.js"); let reg: Reg; @@ -22,11 +23,11 @@ beforeAll(async () => { workItems = await import("../../work-items/store.js"); managerModule = await import("../manager.js"); ptySnapshots = await import("../../engines/pty-snapshot.js"); - reg.initDb(); + dbModule.initDb(); }); function queueRowCount(sessionId: string): number { - const db = reg.initDb(); + const db = dbModule.initDb(); const row = db .prepare("SELECT COUNT(*) as count FROM queue_items WHERE session_id = ?") .get(sessionId) as { count: number }; @@ -34,7 +35,7 @@ function queueRowCount(sessionId: string): number { } function queueStatus(itemId: string): string | null { - const db = reg.initDb(); + const db = dbModule.initDb(); const row = db .prepare("SELECT status FROM queue_items WHERE id = ?") .get(itemId) as { status: string } | undefined; diff --git a/packages/jinn/src/sessions/__tests__/registry-hot-path-indexes.test.ts b/packages/jinn/src/sessions/__tests__/registry-hot-path-indexes.test.ts index d1be1e8eb..a92497091 100644 --- a/packages/jinn/src/sessions/__tests__/registry-hot-path-indexes.test.ts +++ b/packages/jinn/src/sessions/__tests__/registry-hot-path-indexes.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, beforeAll } from "vitest"; +import { describe, it, expect } from "vitest"; import os from "node:os"; import fs from "node:fs"; import path from "node:path"; @@ -6,16 +6,11 @@ import path from "node:path"; // Throwaway DB before importing the registry (SESSIONS_DB resolves from JINN_HOME). const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "jinn-hotidx-")); process.env.JINN_HOME = tmp; +const dbModule = await import("../../shared/db.js"); -type Reg = typeof import("../registry.js"); -let reg: Reg; - -beforeAll(async () => { - reg = await import("../registry.js"); -}); function queryPlan(sql: string): string { - const db = reg.initDb(); + const db = dbModule.initDb(); const rows = db.prepare(`EXPLAIN QUERY PLAN ${sql}`).all() as Array<{ detail: string }>; return rows.map((r) => r.detail).join("\n"); } diff --git a/packages/jinn/src/sessions/__tests__/registry-narrow-reads.test.ts b/packages/jinn/src/sessions/__tests__/registry-narrow-reads.test.ts index 3d0675a19..486c6b7d7 100644 --- a/packages/jinn/src/sessions/__tests__/registry-narrow-reads.test.ts +++ b/packages/jinn/src/sessions/__tests__/registry-narrow-reads.test.ts @@ -6,6 +6,7 @@ import path from "node:path"; // Throwaway DB before importing the registry (SESSIONS_DB resolves from JINN_HOME). const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "jinn-narrow-")); process.env.JINN_HOME = tmp; +const dbModule = await import("../../shared/db.js"); type Reg = typeof import("../registry.js"); let reg: Reg; @@ -15,7 +16,7 @@ beforeAll(async () => { }); function seed(id: string, status: string, lastActivity: string): void { - reg.initDb().prepare( + dbModule.initDb().prepare( "INSERT INTO sessions (id, engine, source, source_ref, status, created_at, last_activity) VALUES (?, 'claude','web',?, ?, 't', ?)", ).run(id, `web:${id}`, status, lastActivity); } @@ -27,7 +28,7 @@ describe("narrow read primitives for polled endpoints", () => { seed("c3", "error", "2026-07-03T00:00:00Z"); expect(reg.countSessions()).toBe(3); - const plan = reg + const plan = dbModule .initDb() .prepare("EXPLAIN QUERY PLAN SELECT COUNT(*) AS n FROM sessions") .all() as Array<{ detail: string }>; @@ -40,7 +41,7 @@ describe("narrow read primitives for polled endpoints", () => { expect(recent.map((s) => s.id)).toEqual(["c3", "c2"]); // LIMIT is respected in SQL, not sliced in JS. - const plan = reg + const plan = dbModule .initDb() .prepare("EXPLAIN QUERY PLAN SELECT * FROM sessions ORDER BY last_activity DESC LIMIT 2") .all() as Array<{ detail: string }>; diff --git a/packages/jinn/src/sessions/__tests__/registry-pagination.test.ts b/packages/jinn/src/sessions/__tests__/registry-pagination.test.ts index 41f211e07..6210f2720 100644 --- a/packages/jinn/src/sessions/__tests__/registry-pagination.test.ts +++ b/packages/jinn/src/sessions/__tests__/registry-pagination.test.ts @@ -31,7 +31,7 @@ function insert( beforeAll(async () => { reg = await import("../registry.js"); - const db = reg.initDb(); + const db = (await import("../../shared/db.js")).initDb(); // Alice: 12 chats, Bob: 3, direct: 6, cron: 20. let t = 0; const ts = () => `2026-01-01T00:00:${String(t++).padStart(2, "0")}.000Z`; @@ -118,8 +118,8 @@ describe("getSessionGroupCounts", () => { // never spawns a phantom group that renders with the portal's own title. // Kept LAST so its inserts don't perturb the counts asserted above. describe("portal-slug sessions fold into the direct group", () => { - beforeAll(() => { - const db = reg.initDb(); + beforeAll(async () => { + const db = (await import("../../shared/db.js")).initDb(); let t = 0; const ts = () => `2026-02-01T00:00:${String(t++).padStart(2, "0")}.000Z`; // 2 lowercase + 1 mixed-case portal-slug rows = 3 phantom-prone sessions. diff --git a/packages/jinn/src/sessions/__tests__/registry-prompt-excerpt.test.ts b/packages/jinn/src/sessions/__tests__/registry-prompt-excerpt.test.ts index 78193afbc..ec9b6000b 100644 --- a/packages/jinn/src/sessions/__tests__/registry-prompt-excerpt.test.ts +++ b/packages/jinn/src/sessions/__tests__/registry-prompt-excerpt.test.ts @@ -13,7 +13,7 @@ let reg: Reg; beforeAll(async () => { reg = await import("../registry.js"); - reg.initDb(); + (await import("../../shared/db.js")).initDb(); }); const base = { engine: "claude", source: "web", sourceRef: "web:pe" } as const; diff --git a/packages/jinn/src/sessions/__tests__/registry-search-messages.test.ts b/packages/jinn/src/sessions/__tests__/registry-search-messages.test.ts index 1016c1955..71c28ae53 100644 --- a/packages/jinn/src/sessions/__tests__/registry-search-messages.test.ts +++ b/packages/jinn/src/sessions/__tests__/registry-search-messages.test.ts @@ -8,13 +8,15 @@ import Database from "better-sqlite3"; // resolved from JINN_HOME at module load). const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "jinn-fts-")); process.env.JINN_HOME = tmp; +const migrateModule = await import("../migrate.js"); +const dbModule = await import("../../shared/db.js"); type Reg = typeof import("../registry.js"); let reg: Reg; let seq = 0; function mkSession(reg: Reg, id: string): void { - const db = reg.initDb(); + const db = dbModule.initDb(); db.prepare( "INSERT INTO sessions (id, engine, source, source_ref, status, created_at, last_activity) VALUES (?, 'claude', 'web', ?, 'idle', 't', 't')", ).run(id, `web:${id}`); @@ -22,7 +24,7 @@ function mkSession(reg: Reg, id: string): void { // Insert a message with an explicit timestamp so newest-first ordering is // deterministic (insertMessage uses Date.now(), which collides within a ms). function mkMessage(reg: Reg, sessionId: string, role: string, content: string, ts: number): void { - const db = reg.initDb(); + const db = dbModule.initDb(); db.prepare( "INSERT INTO messages (id, session_id, role, content, timestamp) VALUES (?, ?, ?, ?, ?)", ).run(`m${seq++}`, sessionId, role, content, ts); @@ -30,7 +32,7 @@ function mkMessage(reg: Reg, sessionId: string, role: string, content: string, t beforeAll(async () => { reg = await import("../registry.js"); - reg.initDb(); + dbModule.initDb(); }); describe("searchMessages (FTS5)", () => { @@ -154,7 +156,7 @@ describe("FTS backfill of pre-existing rows", () => { ins.run("c", "leg", "notification", "narwhal notification", 3); ins.run("d", "leg", "tool", "narwhal tool", 4); - reg.migrateFtsSchema(db); + migrateModule.migrateFtsSchema(db); reg.backfillFtsSync(db); const hits = matchRows(db, "narwhal"); @@ -168,7 +170,7 @@ describe("FTS backfill of pre-existing rows", () => { const ins = db.prepare("INSERT INTO messages (id, session_id, role, content, timestamp) VALUES (?, ?, ?, ?, ?)"); for (let i = 0; i < 25; i++) ins.run(`r${i}`, "leg", i % 2 ? "assistant" : "user", `gerbil row ${i}`, i); - reg.migrateFtsSchema(db); + migrateModule.migrateFtsSchema(db); // chunkSize=4 forces multiple resumable chunks reg.backfillFtsSync(db, 4); expect(matchRows(db, "gerbil").length).toBe(25); @@ -187,7 +189,7 @@ describe("FTS backfill of pre-existing rows", () => { const db = legacyDb(); db.prepare("INSERT INTO messages (id, session_id, role, content, timestamp) VALUES (?, ?, ?, ?, ?)") .run("x", "leg", "assistant", "doomed axolotl note", 1); - reg.migrateFtsSchema(db); + migrateModule.migrateFtsSchema(db); reg.backfillFtsSync(db); expect(matchRows(db, "axolotl").length).toBe(1); @@ -203,7 +205,7 @@ describe("FTS backfill of pre-existing rows", () => { ins.run("async-update", "leg", "assistant", "old async hedgehog note", 1); ins.run("async-delete", "leg", "assistant", "deleted async hedgehog note", 2); - reg.migrateFtsSchema(db); + migrateModule.migrateFtsSchema(db); // The gateway may serve writes before a yielded backfill reaches these legacy // rowids. Trigger maintenance must therefore tolerate both mutations without @@ -244,7 +246,7 @@ describe("FTS backfill of pre-existing rows", () => { db.prepare("INSERT INTO messages (id, session_id, role, content, timestamp) VALUES (?, ?, ?, ?, ?)") .run("pre", "leg", "assistant", "preexisting okapi", 1); - reg.migrateFtsSchema(db); // snapshots fts_backfill_max at the single pre-existing row + migrateModule.migrateFtsSchema(db); // snapshots fts_backfill_max at the single pre-existing row // a row inserted after migration is indexed by the AI trigger… db.prepare("INSERT INTO messages (id, session_id, role, content, timestamp) VALUES (?, ?, ?, ?, ?)") @@ -289,7 +291,7 @@ describe("production finalization path", () => { // process once the flag is set. describe("FTS degrade — fail-safe", () => { it("disableFtsForProcess drops infrastructure and searchMessages returns []", () => { - const database = reg.initDb(); + const database = dbModule.initDb(); // Simulate what initDb() does when backfillFtsSync throws mid-drain. reg.disableFtsForProcess(database, new Error("simulated disk error during backfill")); @@ -303,7 +305,7 @@ describe("FTS degrade — fail-safe", () => { it("deleteSession does not throw in degraded mode (AD trigger absent)", () => { // Directly insert a session + assistant message (bypassing the trigger-dropped // state — the INSERT into messages won't fire FTS AI trigger since it's gone). - const database = reg.initDb(); + const database = dbModule.initDb(); database .prepare( "INSERT INTO sessions (id, engine, source, source_ref, status, created_at, last_activity) VALUES ('s-dg-del','claude','web','web:s-dg-del','idle','t','t')", @@ -322,7 +324,7 @@ describe("FTS degrade — fail-safe", () => { }); it("updatePartialMessage does not throw in degraded mode (AU trigger absent)", () => { - const database = reg.initDb(); + const database = dbModule.initDb(); database .prepare( "INSERT INTO sessions (id, engine, source, source_ref, status, created_at, last_activity) VALUES ('s-dg-upd','claude','web','web:s-dg-upd','running','t','t')", @@ -350,7 +352,7 @@ describe("FTS degrade — fail-safe", () => { ins.run("r2", "leg-recovery", "assistant", "recovery mongoose answer found", 2); // First "boot": create FTS + backfill. - reg.migrateFtsSchema(db); + migrateModule.migrateFtsSchema(db); reg.backfillFtsSync(db); const hitsBefore = db .prepare( @@ -370,7 +372,7 @@ describe("FTS degrade — fail-safe", () => { db.prepare("DELETE FROM meta WHERE key IN ('fts_backfill_done','fts_backfill_rowid','fts_backfill_max')").run(); // "Next boot" recovery: migrateFtsSchema sees no watermark → recreates everything. - reg.migrateFtsSchema(db); + migrateModule.migrateFtsSchema(db); reg.backfillFtsSync(db); const hitsAfter = db diff --git a/packages/jinn/src/sessions/__tests__/registry-search-reference.test.ts b/packages/jinn/src/sessions/__tests__/registry-search-reference.test.ts index eff25cc1d..333e26130 100644 --- a/packages/jinn/src/sessions/__tests__/registry-search-reference.test.ts +++ b/packages/jinn/src/sessions/__tests__/registry-search-reference.test.ts @@ -19,6 +19,7 @@ import path from "node:path"; // Point the DB at a throwaway dir BEFORE importing the registry. const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "jinn-ref-search-")); process.env.JINN_HOME = tmp; +const dbModule = await import("../../shared/db.js"); type Reg = typeof import("../registry.js"); let reg: Reg; @@ -28,7 +29,7 @@ function mkSession( id: string, fields: { employee?: string; engine?: string; status?: string; source?: string; title?: string; promptExcerpt?: string; parent?: string; lastActivity?: string; createdAt?: string } = {}, ): void { - const db = reg.initDb(); + const db = dbModule.initDb(); db.prepare( `INSERT INTO sessions (id, engine, employee, source, source_ref, status, title, prompt_excerpt, parent_session_id, created_at, last_activity) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, @@ -48,7 +49,7 @@ function mkSession( } function mkMessage(sessionId: string, role: string, content: string, ts: number): string { const id = `m${seq++}`; - reg.initDb() + dbModule.initDb() .prepare("INSERT INTO messages (id, session_id, role, content, timestamp) VALUES (?, ?, ?, ?, ?)") .run(id, sessionId, role, content, ts); return id; @@ -56,7 +57,7 @@ function mkMessage(sessionId: string, role: string, content: string, ts: number) beforeAll(async () => { reg = await import("../registry.js"); - reg.initDb(); + dbModule.initDb(); }); describe("searchMessages — filters + messageId anchor (GRS-020a)", () => { @@ -303,7 +304,7 @@ describe("getMessageContext (GRS-020a)", () => { it("keeps getMessages ordering under timestamp ties (NULL seq vs numbered seq) — the bounded-window rewrite (finding 6)", () => { mkSession("ctx-tie"); - const db = reg.initDb(); + const db = dbModule.initDb(); const ins = db.prepare("INSERT INTO messages (id, session_id, role, content, timestamp, seq) VALUES (?, ?, ?, ?, ?, ?)"); // Same timestamp: NULL seq sorts before numbered seqs (getMessages: timestamp ASC, seq ASC). ins.run("tie-null", "ctx-tie", "assistant", "tie null", 9500, null); diff --git a/packages/jinn/src/sessions/__tests__/registry-turn-accounting.test.ts b/packages/jinn/src/sessions/__tests__/registry-turn-accounting.test.ts index 4d013f8b9..76ccd798f 100644 --- a/packages/jinn/src/sessions/__tests__/registry-turn-accounting.test.ts +++ b/packages/jinn/src/sessions/__tests__/registry-turn-accounting.test.ts @@ -6,6 +6,7 @@ import path from "node:path"; // Throwaway DB before importing the registry (SESSIONS_DB resolves from JINN_HOME). const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "jinn-turn-accounting-")); process.env.JINN_HOME = tmp; +const dbModule = await import("../../shared/db.js"); type Reg = typeof import("../registry.js"); let reg: Reg; @@ -15,13 +16,13 @@ beforeAll(async () => { }); function seed(id: string, source: string, engine = "claude"): void { - reg.initDb().prepare( + dbModule.initDb().prepare( "INSERT INTO sessions (id, engine, source, source_ref, status, created_at, last_activity) VALUES (?, ?, ?, ?, 'idle', '2026-07-25T00:00:00Z', '2026-07-25T00:00:00Z')", ).run(id, engine, source, `${source}:${id}`); } function totals(id: string): { cost: number; turns: number } { - const row = reg.initDb() + const row = dbModule.initDb() .prepare("SELECT total_cost AS cost, total_turns AS turns FROM sessions WHERE id = ?") .get(id) as { cost: number; turns: number }; return row; diff --git a/packages/jinn/src/sessions/__tests__/restart-acknowledgement.test.ts b/packages/jinn/src/sessions/__tests__/restart-acknowledgement.test.ts index 59734b196..7ac90a2e3 100644 --- a/packages/jinn/src/sessions/__tests__/restart-acknowledgement.test.ts +++ b/packages/jinn/src/sessions/__tests__/restart-acknowledgement.test.ts @@ -11,7 +11,7 @@ let registry: Registry; beforeAll(async () => { registry = await import("../registry.js"); - registry.initDb(); + (await import("../../shared/db.js")).initDb(); }); describe("consumeRestartAcknowledgements", () => { diff --git a/packages/jinn/src/sessions/__tests__/sso-user-capture.test.ts b/packages/jinn/src/sessions/__tests__/sso-user-capture.test.ts index ee6c3a0cf..ea91088b3 100644 --- a/packages/jinn/src/sessions/__tests__/sso-user-capture.test.ts +++ b/packages/jinn/src/sessions/__tests__/sso-user-capture.test.ts @@ -8,6 +8,7 @@ import Database from "better-sqlite3"; // resolved from JINN_HOME at module load). const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "jinn-sso-")); process.env.JINN_HOME = tmp; +const migrateModule = await import("../migrate.js"); type Reg = typeof import("../registry.js"); let reg: Reg; @@ -15,7 +16,7 @@ let resolveUserHeader: typeof import("../../gateway/api.js")["resolveUserHeader" beforeAll(async () => { reg = await import("../registry.js"); - reg.initDb(); + (await import("../../shared/db.js")).initDb(); ({ resolveUserHeader } = await import("../../gateway/api.js")); }); @@ -97,14 +98,14 @@ describe("migrateSessionsSchema user_id migration", () => { expect(hasUserId()).toBe(false); - reg.migrateSessionsSchema(db); + migrateModule.migrateSessionsSchema(db); expect(hasUserId()).toBe(true); expect( (db.prepare("SELECT user_id FROM sessions WHERE id = ?").get("old-1") as { user_id: string | null }).user_id, ).toBeNull(); // Re-running must not throw, must not duplicate the column, must not change data. - expect(() => reg.migrateSessionsSchema(db)).not.toThrow(); + expect(() => migrateModule.migrateSessionsSchema(db)).not.toThrow(); const userIdCols = (db.prepare("PRAGMA table_info(sessions)").all() as Array<{ name: string }>).filter( (c) => c.name === "user_id", ); diff --git a/packages/jinn/src/sessions/__tests__/update-effort.test.ts b/packages/jinn/src/sessions/__tests__/update-effort.test.ts index f868c8073..0b103c092 100644 --- a/packages/jinn/src/sessions/__tests__/update-effort.test.ts +++ b/packages/jinn/src/sessions/__tests__/update-effort.test.ts @@ -10,8 +10,8 @@ process.env.JINN_HOME = tmp; const reg = await import("../registry.js"); describe("updateSession persists model + effort_level (mid-chat switch backing store)", () => { - it("round-trips a model + effortLevel change", () => { - reg.initDb(); + it("round-trips a model + effortLevel change", async () => { + (await import("../../shared/db.js")).initDb(); const s = reg.createSession({ engine: "claude", source: "web", diff --git a/packages/jinn/src/sessions/__tests__/workflow-attempt-recovery.test.ts b/packages/jinn/src/sessions/__tests__/workflow-attempt-recovery.test.ts index aff220c22..53fec7f7e 100644 --- a/packages/jinn/src/sessions/__tests__/workflow-attempt-recovery.test.ts +++ b/packages/jinn/src/sessions/__tests__/workflow-attempt-recovery.test.ts @@ -7,6 +7,7 @@ import type { SessionManager } from "../manager.js"; const home = fs.mkdtempSync(path.join(os.tmpdir(), "jinn-workflow-attempt-recovery-")); process.env.JINN_HOME = home; +const dbModule = await import("../../shared/db.js"); type Registry = typeof import("../registry.js"); type ExecutorModule = typeof import("../../workflows/session-executor.js"); @@ -41,15 +42,15 @@ function createPhaseSession(runId: string) { beforeAll(async () => { registry = await import("../registry.js"); executorModule = await import("../../workflows/session-executor.js"); - registry.initDb(); + dbModule.initDb(); }); beforeEach(() => { - registry.initDb().exec("DELETE FROM messages; DELETE FROM queue_items; DELETE FROM sessions;"); + dbModule.initDb().exec("DELETE FROM messages; DELETE FROM queue_items; DELETE FROM sessions;"); }); afterAll(() => { - registry.__closeDbForTest(); + dbModule.__closeDbForTest(); fs.rmSync(home, { recursive: true, force: true }); }); diff --git a/packages/jinn/src/sessions/__tests__/workflow-attempt-turns.test.ts b/packages/jinn/src/sessions/__tests__/workflow-attempt-turns.test.ts index 6c9bc5142..350cb7acf 100644 --- a/packages/jinn/src/sessions/__tests__/workflow-attempt-turns.test.ts +++ b/packages/jinn/src/sessions/__tests__/workflow-attempt-turns.test.ts @@ -87,11 +87,11 @@ function managerWith(runs: EngineRunOpts[]) { beforeAll(async () => { registry = await import("../registry.js"); managerModule = await import("../manager.js"); - registry.initDb(); + (await import("../../shared/db.js")).initDb(); }); -beforeEach(() => { - registry.initDb().exec("DELETE FROM messages; DELETE FROM queue_items; DELETE FROM sessions;"); +beforeEach(async () => { + (await import("../../shared/db.js")).initDb().exec("DELETE FROM messages; DELETE FROM queue_items; DELETE FROM sessions;"); }); describe("workflow attempt per-turn completion", () => { diff --git a/packages/jinn/src/sessions/__tests__/workflow-provenance.test.ts b/packages/jinn/src/sessions/__tests__/workflow-provenance.test.ts index 3469780ca..3b12bf8bf 100644 --- a/packages/jinn/src/sessions/__tests__/workflow-provenance.test.ts +++ b/packages/jinn/src/sessions/__tests__/workflow-provenance.test.ts @@ -12,13 +12,13 @@ let registry: Registry; beforeAll(async () => { registry = await import('../registry.js'); - registry.initDb(); + (await import('../../shared/db.js')).initDb(); }); -afterAll(() => { +afterAll(async () => { // Close the database before removing its directory: Windows refuses to unlink // a file with an open handle, so the sqlite connection has to go first. - registry.__closeDbForTest(); + (await import('../../shared/db.js')).__closeDbForTest(); removeTempDir(home); }); diff --git a/packages/jinn/src/sessions/migrate.ts b/packages/jinn/src/sessions/migrate.ts new file mode 100644 index 000000000..f472ea133 --- /dev/null +++ b/packages/jinn/src/sessions/migrate.ts @@ -0,0 +1,966 @@ +// Sessions-owned storage schema: the DDL for every table this module writes, the +// idempotent upgrade migrations that keep an existing home in step, and the +// callback_deliveries row model those migrations validate against. `shared/db.ts` +// sequences these; `registry.ts` runs the queries. +import { randomUUID } from 'node:crypto'; +import Database from 'better-sqlite3'; +import type { SessionDelivery, SessionDeliveryIdentity, SessionDeliveryPayload } from '../shared/types.js'; + +export const CREATE_TABLE = ` +CREATE TABLE IF NOT EXISTS sessions ( + id TEXT PRIMARY KEY, + engine TEXT NOT NULL, + engine_session_id TEXT, + engine_sessions TEXT, + source TEXT NOT NULL, + source_ref TEXT NOT NULL, + connector TEXT, + session_key TEXT, + reply_context TEXT, + message_id TEXT, + transport_meta TEXT, + employee TEXT, + model TEXT, + title TEXT, + prompt_excerpt TEXT, + parent_session_id TEXT, + workflow_kind TEXT, + workflow_id TEXT, + workflow_name TEXT, + workflow_run_id TEXT, + workflow_trigger_source TEXT, + workflow_phase_node_id TEXT, + workflow_phase_name TEXT, + workflow_phase_index INTEGER, + workflow_phase_round INTEGER, + workflow_phase_attempt INTEGER, + user_id TEXT, + status TEXT DEFAULT 'idle', + attempt_outcome TEXT, + attempt_token TEXT, + attempt_terminal_version INTEGER NOT NULL DEFAULT 0, + attempt_turn INTEGER NOT NULL DEFAULT 0, + attempt_interruption_cause TEXT, + attempt_interruption_turn INTEGER, + archived_at TEXT, + created_at TEXT NOT NULL, + last_activity TEXT NOT NULL, + last_error TEXT +)`; + +export const CREATE_MESSAGES_TABLE = ` +CREATE TABLE IF NOT EXISTS messages ( + id TEXT PRIMARY KEY, + session_id TEXT NOT NULL, + role TEXT NOT NULL, + content TEXT NOT NULL, + timestamp INTEGER NOT NULL +)`; + +export const CREATE_MESSAGES_INDEX = ` +CREATE INDEX IF NOT EXISTS idx_messages_session ON messages (session_id, timestamp) +`; + +export const CREATE_MESSAGES_ORDER_INDEX = ` +CREATE INDEX IF NOT EXISTS idx_messages_session_order ON messages (session_id, timestamp, seq) +`; + +export const CREATE_SESSION_KEY_INDEX = ` +CREATE INDEX IF NOT EXISTS idx_sessions_session_key ON sessions (session_key, last_activity) +`; + +/** Caller-supplied delegation idempotency keys map to one durable session. The + * key stored in session_key is a scoped hash, so the unique index is both + * restart-safe and safe to add to existing databases. */ +export const CREATE_DELEGATION_IDEMPOTENCY_INDEX = ` +CREATE UNIQUE INDEX IF NOT EXISTS uq_sessions_delegation_idempotency + ON sessions (session_key) WHERE session_key LIKE 'delegation-idempotency:%' +`; + +// Backs `ORDER BY last_activity DESC` in the session list (was a full scan + sort). +export const CREATE_LAST_ACTIVITY_INDEX = ` +CREATE INDEX IF NOT EXISTS idx_sessions_last_activity ON sessions (last_activity DESC) +`; + +// Backs the children lookup (was a full-table deserialization + JS filter). +export const CREATE_PARENT_INDEX = ` +CREATE INDEX IF NOT EXISTS idx_sessions_parent ON sessions (parent_session_id) +`; + +// Backs provenance filters and workflow-run grouping lookups without parsing the +// deterministic sourceRef. Partial because ordinary chats never carry a run id. +export const CREATE_WORKFLOW_RUN_INDEX = ` +CREATE INDEX IF NOT EXISTS idx_sessions_workflow_run ON sessions (workflow_run_id) + WHERE workflow_run_id IS NOT NULL +`; + +// Backs the highly-selective status filter (running ~6 of 2.5k rows) used on +// every boot (recoverStaleSessions / getInterruptedSessions) and every +// status-reconciler tick (listSessions({status:'running'})) — all of which were +// SCANning the full sessions table. Composite with last_activity DESC so the +// status-filtered list read also gets its ORDER BY from the index. +export const CREATE_STATUS_INDEX = ` +CREATE INDEX IF NOT EXISTS idx_sessions_status ON sessions (status, last_activity DESC) +`; + +// Backs the `WHERE partial = 1` hot path — the boot sweep (clearAllPartialMessages) +// and every turn-settle (deletePartialMessages / finalizePartialMessages / +// getPartialMessages), which were full-SCANning the (largest) messages table to +// touch a handful of live mid-turn rows. Partial index: only the tiny set of +// currently-partial rows is indexed, so it stays cheap regardless of history size. +export const CREATE_MESSAGES_PARTIAL_INDEX = ` +DROP INDEX IF EXISTS idx_messages_partial; +CREATE INDEX IF NOT EXISTS idx_messages_partial_order + ON messages (session_id, timestamp, COALESCE(seq, 0)) WHERE partial = 1 +`; + +export const CREATE_FILES_TABLE = ` +CREATE TABLE IF NOT EXISTS files ( + id TEXT PRIMARY KEY, + filename TEXT NOT NULL, + size INTEGER NOT NULL, + mimetype TEXT, + path TEXT, + created_at TEXT NOT NULL +) +`; + +// Generic key/value store for one-off migration progress flags (e.g. the FTS +// backfill watermark). Keep entries tiny — this is not a config table. +export const CREATE_META_TABLE = ` +CREATE TABLE IF NOT EXISTS meta ( + key TEXT PRIMARY KEY, + value TEXT +) +`; + +export const CREATE_CHAT_PINS_TABLE = ` +CREATE TABLE IF NOT EXISTS chat_pins ( + pin_key TEXT PRIMARY KEY, + pinned_at TEXT NOT NULL +) +`; + +function callbackDeliveriesTableSql(tableName = 'callback_deliveries'): string { + return ` +CREATE TABLE ${tableName} ( + id TEXT PRIMARY KEY, + target_session_id TEXT NOT NULL CHECK (length(target_session_id) > 0 AND target_session_id = jinn_callback_identity(target_session_id)), + source_kind TEXT NOT NULL CHECK (source_kind IN ('session', 'workflow-run')), + source_id TEXT NOT NULL CHECK (length(source_id) > 0 AND source_id = jinn_callback_identity(source_id)), + source_attempt TEXT NOT NULL CHECK (length(source_attempt) > 0 AND source_attempt = jinn_callback_identity(source_attempt)), + source_outcome TEXT NOT NULL CHECK (length(source_outcome) > 0 AND source_outcome = jinn_callback_identity(source_outcome)), + source_version INTEGER NOT NULL CHECK (source_version >= 1), + delivery_kind TEXT NOT NULL CHECK (length(delivery_kind) > 0 AND delivery_kind = jinn_callback_identity(delivery_kind)), + payload TEXT NOT NULL CHECK ( + json_valid(payload) + AND json_type(payload) = 'object' + AND json_type(payload, '$.message') IS 'text' + AND json_type(payload, '$.displayMessage') IS 'text' + ), + status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'accepted', 'dead_letter')), + message_id TEXT, + queue_item_id TEXT, + attempt_count INTEGER NOT NULL DEFAULT 0 CHECK (attempt_count >= 0), + next_attempt_at INTEGER, + last_attempt_at INTEGER, + last_error TEXT, + dead_lettered_at INTEGER, + created_at TEXT NOT NULL, + accepted_at TEXT +) +`; +} + +const CREATE_CALLBACK_DELIVERIES_TABLE = callbackDeliveriesTableSql(); + +const CALLBACK_DELIVERY_REQUIRED_COLUMNS = [ + 'id', + 'target_session_id', + 'source_kind', + 'source_id', + 'source_attempt', + 'source_outcome', + 'source_version', + 'delivery_kind', + 'payload', + 'status', + 'message_id', + 'queue_item_id', + 'attempt_count', + 'next_attempt_at', + 'last_attempt_at', + 'last_error', + 'dead_lettered_at', + 'created_at', + 'accepted_at', +] as const; + +export const CREATE_QUEUE_ITEMS_TABLE = ` + CREATE TABLE IF NOT EXISTS queue_items ( + id TEXT PRIMARY KEY, + session_id TEXT NOT NULL, + session_key TEXT NOT NULL, + prompt TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', + internal INTEGER NOT NULL DEFAULT 0, + position INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL, + started_at TEXT, + completed_at TEXT + ); + CREATE INDEX IF NOT EXISTS idx_queue_session + ON queue_items (session_key, status, position); + `; + +// Backs listSessionsByWorkItem (the GRS-002 read-back path) and any future +// per-item session lookup. Partial: only sessions actually linked to an item. +export const CREATE_WORK_ITEM_SESSION_INDEX = ` +CREATE INDEX IF NOT EXISTS idx_sessions_work_item ON sessions (work_item_id) WHERE work_item_id IS NOT NULL +`; + +// Full-text search over message bodies. External-content FTS5 table (the index +// lives here; `content` is read back from `messages` via rowid for snippets), so +// it stays in lockstep with `messages` through the AI/AD/AU triggers below. Only +// user/assistant rows are indexed — notification/tool rows are deliberately +// excluded (they're machine chatter, not conversation). Pre-existing rows are +// seeded by a yielded backfill after listen(). While that backfill is in flight, +// the AD/AU triggers only issue an FTS delete for rowids known to be indexed: +// already-drained legacy rows or post-watermark rows owned by the AI trigger. +// This keeps legacy updates/deletes safe without blocking gateway boot. +const CREATE_FTS = ` +CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts USING fts5(content, content='messages', content_rowid='rowid', tokenize='unicode61'); +CREATE TRIGGER IF NOT EXISTS messages_fts_ai AFTER INSERT ON messages +WHEN new.role IN ('user','assistant') AND ( + COALESCE((SELECT value = '1' FROM meta WHERE key = 'fts_backfill_done'), 0) + OR new.rowid <= COALESCE(CAST((SELECT value FROM meta WHERE key = 'fts_backfill_rowid') AS INTEGER), 0) + OR new.rowid > COALESCE(CAST((SELECT value FROM meta WHERE key = 'fts_backfill_max') AS INTEGER), 0) +) BEGIN + INSERT INTO messages_fts(rowid, content) VALUES (new.rowid, new.content); +END; +CREATE TRIGGER IF NOT EXISTS messages_fts_ad AFTER DELETE ON messages +WHEN old.role IN ('user','assistant') AND ( + COALESCE((SELECT value = '1' FROM meta WHERE key = 'fts_backfill_done'), 0) + OR old.rowid <= COALESCE(CAST((SELECT value FROM meta WHERE key = 'fts_backfill_rowid') AS INTEGER), 0) + OR old.rowid > COALESCE(CAST((SELECT value FROM meta WHERE key = 'fts_backfill_max') AS INTEGER), 0) +) BEGIN + INSERT INTO messages_fts(messages_fts, rowid, content) VALUES('delete', old.rowid, old.content); +END; +CREATE TRIGGER IF NOT EXISTS messages_fts_au AFTER UPDATE ON messages BEGIN + INSERT INTO messages_fts(messages_fts, rowid, content) + SELECT 'delete', old.rowid, old.content + WHERE old.role IN ('user','assistant') AND ( + COALESCE((SELECT value = '1' FROM meta WHERE key = 'fts_backfill_done'), 0) + OR old.rowid <= COALESCE(CAST((SELECT value FROM meta WHERE key = 'fts_backfill_rowid') AS INTEGER), 0) + OR old.rowid > COALESCE(CAST((SELECT value FROM meta WHERE key = 'fts_backfill_max') AS INTEGER), 0) + ); + INSERT INTO messages_fts(rowid, content) + SELECT new.rowid, new.content + WHERE new.role IN ('user','assistant') AND ( + COALESCE((SELECT value = '1' FROM meta WHERE key = 'fts_backfill_done'), 0) + OR new.rowid <= COALESCE(CAST((SELECT value FROM meta WHERE key = 'fts_backfill_rowid') AS INTEGER), 0) + OR new.rowid > COALESCE(CAST((SELECT value FROM meta WHERE key = 'fts_backfill_max') AS INTEGER), 0) + ); +END; +`; + +/** + * Additive, nullable migration: add the `media` column to an existing messages + * table. Safe to run repeatedly and on legacy DBs created before media support. + */ +export function migrateMessagesSchema(database: Database.Database): void { + const cols = database.prepare('PRAGMA table_info(messages)').all() as Array<{ name: string }>; + const colNames = new Set(cols.map((c) => c.name)); + if (!colNames.has('media')) { + database.exec('ALTER TABLE messages ADD COLUMN media TEXT'); + } + // Mid-turn streaming: `partial=1` rows are the live blocks (text segments + tool + // calls) persisted DURING a turn so a refresh restores in-progress output. They + // are deleted at turn end and replaced by the single consolidated final message + // (same end-state as before). `seq` orders blocks within a turn (timestamp ms + // collides across blocks); `tool_call` carries the tool name so a reloaded tool + // block renders as a tool card, matching the live stream. All additive/nullable. + if (!colNames.has('partial')) { + database.exec('ALTER TABLE messages ADD COLUMN partial INTEGER'); + } + if (!colNames.has('seq')) { + database.exec('ALTER TABLE messages ADD COLUMN seq INTEGER'); + } + if (!colNames.has('tool_call')) { + database.exec('ALTER TABLE messages ADD COLUMN tool_call TEXT'); + } + if (!colNames.has('tool_id')) { + database.exec('ALTER TABLE messages ADD COLUMN tool_id TEXT'); + } + if (!colNames.has('blocks')) { + database.exec('ALTER TABLE messages ADD COLUMN blocks TEXT'); + } + if (!colNames.has('meta')) { + database.exec('ALTER TABLE messages ADD COLUMN meta TEXT'); + } +} + +/** Additive migration for restart-safe system work. Internal queue rows use the + * same durable ordering/replay machinery as user messages, but stay out of the + * operator-facing queue panel and its cancel/clear controls. */ +export function migrateQueueItemsSchema(database: Database.Database): void { + const columns = database.prepare('PRAGMA table_info(queue_items)').all() as Array<{ name: string }>; + if (!columns.some((column) => column.name === 'internal')) { + database.exec('ALTER TABLE queue_items ADD COLUMN internal INTEGER NOT NULL DEFAULT 0'); + } +} + +export function migrateSessionsSchema(database: Database.Database): void { + const cols = database.prepare('PRAGMA table_info(sessions)').all() as Array<{ name: string }>; + const colNames = new Set(cols.map((c) => c.name)); + const missingColumns: Array<[string, string, string?]> = [ + ['title', 'TEXT'], + ['parent_session_id', 'TEXT'], + ['workflow_kind', 'TEXT'], + ['workflow_id', 'TEXT'], + ['workflow_name', 'TEXT'], + ['workflow_run_id', 'TEXT'], + ['workflow_trigger_source', 'TEXT'], + ['workflow_phase_node_id', 'TEXT'], + ['workflow_phase_name', 'TEXT'], + ['workflow_phase_index', 'INTEGER'], + ['workflow_phase_round', 'INTEGER'], + ['workflow_phase_attempt', 'INTEGER'], + ['connector', 'TEXT'], + ['session_key', 'TEXT'], + ['reply_context', 'TEXT'], + ['message_id', 'TEXT'], + ['transport_meta', 'TEXT'], + ['engine_sessions', 'TEXT'], + ['total_cost', 'REAL', '0'], + ['total_turns', 'INTEGER', '0'], + ['effort_level', 'TEXT'], + ['last_context_tokens', 'INTEGER'], + ['user_id', 'TEXT'], + // No backfill: pre-existing sessions stay NULL (no excerpt); only new sessions populate it. + ['prompt_excerpt', 'TEXT'], + // Work-item link (GRS-002). Nullable; NULL = unchanged legacy behavior. The + // partial index idx_sessions_work_item is created in initDb. + ['work_item_id', 'TEXT'], + // Explicit latest-attempt receipt. NULL means no successful/failed terminal + // engine result has been recorded; `idle` by itself is not completion proof. + ['attempt_outcome', 'TEXT'], + // Per-dispatch generation used for compare-and-set terminal writes. + ['attempt_token', 'TEXT'], + ['attempt_terminal_version', 'INTEGER NOT NULL', '0'], + ['attempt_turn', 'INTEGER NOT NULL', '0'], + ['attempt_interruption_cause', 'TEXT'], + ['attempt_interruption_turn', 'INTEGER'], + // Archive is reversible: retain the durable chat and only hide it from + // normal list queries. NULL keeps all pre-existing sessions visible. + ['archived_at', 'TEXT'], + ]; + + for (const [name, type, defaultVal] of missingColumns) { + if (!colNames.has(name)) { + const defaultClause = defaultVal !== undefined ? ` DEFAULT ${defaultVal}` : ''; + database.exec(`ALTER TABLE sessions ADD COLUMN ${name} ${type}${defaultClause}`); + } + } + + const refreshedCols = database.prepare('PRAGMA table_info(sessions)').all() as Array<{ name: string }>; + const refreshedNames = new Set(refreshedCols.map((c) => c.name)); + if (refreshedNames.has('session_key')) { + database.exec(`UPDATE sessions SET session_key = COALESCE(session_key, source_ref) WHERE session_key IS NULL OR session_key = ''`); + } + if (refreshedNames.has('connector')) { + database.exec(`UPDATE sessions SET connector = COALESCE(connector, source) WHERE connector IS NULL OR connector = ''`); + } +} + +export function getMeta(database: Database.Database, key: string): string | null { + const row = database.prepare('SELECT value FROM meta WHERE key = ?').get(key) as { value: string } | undefined; + return row ? row.value : null; +} + +export function setMeta(database: Database.Database, key: string, value: string): void { + database + .prepare('INSERT INTO meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value') + .run(key, value); +} + +/** + * Create the FTS5 search index + sync triggers, and record the backfill watermark. + * + * The triggers keep the index current for every message written from now on. Rows + * that already existed before this table did are NOT seen by the triggers, so they + * are seeded separately by the chunked backfill (`scheduleFtsBackfill`). To stop + * the backfill from double-indexing rows the triggers also handle, we snapshot the + * current MAX(rowid) here — synchronously, before any new insert can race in — and + * the backfill only ever touches `rowid <= fts_backfill_max`. Anything above that + * watermark is a brand-new row and belongs to the triggers. + * + * Idempotent: safe to run on every boot. On a DB where the backfill already + * completed it is a no-op. + */ +export function migrateFtsSchema(database: Database.Database): void { + database.exec(CREATE_META_TABLE); + // Trigger definitions changed when the boot drain became asynchronous. Rebuild + // them idempotently so upgraded databases get the guarded AD/AU behavior too; + // CREATE TRIGGER IF NOT EXISTS alone would preserve the unsafe legacy bodies. + database.exec(` + DROP TRIGGER IF EXISTS messages_fts_ai; + DROP TRIGGER IF EXISTS messages_fts_ad; + DROP TRIGGER IF EXISTS messages_fts_au; + `); + database.exec(CREATE_FTS); + // First time we see this DB and the backfill hasn't run: pin the watermark. + if (getMeta(database, 'fts_backfill_done') !== '1' && getMeta(database, 'fts_backfill_max') === null) { + const row = database.prepare('SELECT MAX(rowid) AS m FROM messages').get() as { m: number | null }; + setMeta(database, 'fts_backfill_max', String(row.m ?? 0)); + setMeta(database, 'fts_backfill_rowid', '0'); + } +} + +export interface SessionDeliveryRow { + id: string; + targetSessionId: string; + sourceKind: SessionDeliveryIdentity['sourceKind']; + sourceId: string; + sourceAttempt: string; + sourceOutcome: string; + sourceVersion: number; + deliveryKind: string; + payload: string; + status: SessionDelivery['status']; + messageId: string | null; + queueItemId: string | null; + attemptCount: number; + nextAttemptAt: number | null; + lastAttemptAt: number | null; + lastError: string | null; + deadLetteredAt: number | null; + createdAt: string; + acceptedAt: string | null; +} + +function hasSessionDeliveryConstraints(sql: string): boolean { + const normalized = sql.replace(/\s+/g, ' ').toLowerCase(); + const canonicalColumns = [ + 'target_session_id', + 'source_id', + 'source_attempt', + 'source_outcome', + 'delivery_kind', + ]; + return canonicalColumns.every((column) => + normalized.includes(`length(${column}) > 0 and ${column} = jinn_callback_identity(${column})`), + ) + && normalized.includes("source_kind in ('session', 'workflow-run')") + && normalized.includes('source_version >= 1') + && normalized.includes('json_valid(payload)') + && normalized.includes("json_type(payload) = 'object'") + && normalized.includes("json_type(payload, '$.message') is 'text'") + && normalized.includes("json_type(payload, '$.displaymessage') is 'text'") + && normalized.includes("status in ('pending', 'accepted', 'dead_letter')") + && normalized.includes('attempt_count >= 0'); +} + +/** Install the callback outbox atomically. A malformed pre-existing table is + * never silently indexed: validation throws inside the transaction so any DDL + * from this migration is rolled back as one unit. */ +export function migrateCallbackDeliveriesSchema(database: Database.Database): void { + database.pragma('busy_timeout = 10000'); + database.function('jinn_callback_identity', { deterministic: true }, canonicalCallbackIdentityText); + const migrate = database.transaction(() => { + const existing = database.prepare(` + SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'callback_deliveries' + `).get() as { sql: string } | undefined; + if (!existing) { + database.exec(CREATE_CALLBACK_DELIVERIES_TABLE); + } else { + const columns = database.prepare('PRAGMA table_info(callback_deliveries)').all() as Array<{ name: string }>; + const names = new Set(columns.map((column) => column.name)); + const legacyIdentity = [ + 'parent_session_id', + 'child_session_id', + 'attempt_token', + 'terminal_outcome', + 'terminal_version', + 'callback_kind', + ]; + const lifecycleRequired = [ + 'id', + 'payload', + 'status', + 'message_id', + 'queue_item_id', + 'created_at', + 'accepted_at', + ]; + const missingLifecycle = lifecycleRequired.filter((column) => !names.has(column)); + const hasLegacyIdentity = legacyIdentity.every((column) => names.has(column)); + const hasGenericIdentity = CALLBACK_DELIVERY_REQUIRED_COLUMNS.every((column) => names.has(column)); + if (missingLifecycle.length > 0 || (!hasLegacyIdentity && !hasGenericIdentity)) { + throw new Error(`Incompatible callback_deliveries schema: missing ${missingLifecycle.join(', ') || 'delivery identity columns'}`); + } + if (hasLegacyIdentity || !hasSessionDeliveryConstraints(existing.sql)) { + rebuildCallbackDeliveriesTable(database, names, hasLegacyIdentity ? 'legacy-session' : 'generic'); + } + } + const columns = database.prepare('PRAGMA table_info(callback_deliveries)').all() as Array<{ name: string }>; + const names = new Set(columns.map((column) => column.name)); + const missing = CALLBACK_DELIVERY_REQUIRED_COLUMNS.filter((column) => !names.has(column)); + if (missing.length > 0) { + throw new Error(`Incompatible callback_deliveries schema: missing ${missing.join(', ')}`); + } + ensureCallbackDeliveryIndexes(database); + const identityColumns = database.prepare('PRAGMA index_info(uq_callback_delivery_identity)').all() as Array<{ name: string }>; + const expectedIdentity = [ + 'target_session_id', + 'source_kind', + 'source_id', + 'source_attempt', + 'source_outcome', + 'source_version', + 'delivery_kind', + ]; + if (identityColumns.map((column) => column.name).join('|') !== expectedIdentity.join('|')) { + throw new Error('Incompatible callback delivery identity index'); + } + const indexList = database.prepare('PRAGMA index_list(callback_deliveries)').all() as Array<{ name: string; unique: number }>; + if (indexList.find((index) => index.name === 'uq_callback_delivery_identity')?.unique !== 1) { + throw new Error('Incompatible callback delivery identity uniqueness'); + } + const pendingColumns = database.prepare('PRAGMA index_info(idx_callback_deliveries_pending)').all() as Array<{ name: string }>; + const pendingSql = (database.prepare(` + SELECT sql FROM sqlite_master WHERE type = 'index' AND name = 'idx_callback_deliveries_pending' + `).get() as { sql: string } | undefined)?.sql.replace(/\s+/g, ' ').toLowerCase() ?? ''; + if ( + pendingColumns.map((column) => column.name).join('|') !== 'status|next_attempt_at|created_at' + || !pendingSql.includes("where status = 'pending'") + ) { + throw new Error('Incompatible callback delivery pending index'); + } + const installedSql = (database.prepare(` + SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'callback_deliveries' + `).get() as { sql: string }).sql; + if (!hasSessionDeliveryConstraints(installedSql)) { + throw new Error('Incompatible callback_deliveries constraints'); + } + }); + runImmediateMigrationWithRetry(migrate); +} + +function ensureCallbackDeliveryIndexes(database: Database.Database): void { + const expectedIdentity = [ + 'target_session_id', + 'source_kind', + 'source_id', + 'source_attempt', + 'source_outcome', + 'source_version', + 'delivery_kind', + ]; + const indexes = database.prepare('PRAGMA index_list(callback_deliveries)').all() as Array<{ name: string; unique: number }>; + const identity = indexes.find((index) => index.name === 'uq_callback_delivery_identity'); + const identityColumns = identity + ? database.prepare('PRAGMA index_info(uq_callback_delivery_identity)').all() as Array<{ name: string }> + : []; + if ( + identity + && (identity.unique !== 1 || identityColumns.map((column) => column.name).join('|') !== expectedIdentity.join('|')) + ) { + database.exec('DROP INDEX uq_callback_delivery_identity'); + } + database.exec(` + CREATE UNIQUE INDEX IF NOT EXISTS uq_callback_delivery_identity + ON callback_deliveries ( + target_session_id, + source_kind, + source_id, + source_attempt, + source_outcome, + source_version, + delivery_kind + ) + `); + + const refreshedIndexes = database.prepare('PRAGMA index_list(callback_deliveries)').all() as Array<{ name: string; unique: number }>; + const pending = refreshedIndexes.find((index) => index.name === 'idx_callback_deliveries_pending'); + const pendingColumns = pending + ? database.prepare('PRAGMA index_info(idx_callback_deliveries_pending)').all() as Array<{ name: string }> + : []; + const pendingSql = pending + ? (database.prepare(` + SELECT sql FROM sqlite_master WHERE type = 'index' AND name = 'idx_callback_deliveries_pending' + `).get() as { sql: string } | undefined)?.sql.replace(/\s+/g, ' ').toLowerCase() ?? '' + : ''; + if ( + pending + && ( + pending.unique !== 0 + || pendingColumns.map((column) => column.name).join('|') !== 'status|next_attempt_at|created_at' + || !pendingSql.includes("where status = 'pending'") + ) + ) { + database.exec('DROP INDEX idx_callback_deliveries_pending'); + } + database.exec(` + CREATE INDEX IF NOT EXISTS idx_callback_deliveries_pending + ON callback_deliveries (status, next_attempt_at, created_at) + WHERE status = 'pending' + `); +} + +export function canonicalCallbackIdentityText(value: unknown): string { + return typeof value === 'string' + ? value.normalize('NFC').replace(/^\p{White_Space}+|\p{White_Space}+$/gu, '') + : ''; +} + +function rebuildCallbackDeliveriesTable( + database: Database.Database, + columns: Set, + shape: 'legacy-session' | 'generic', +): void { + const rows = database.prepare('SELECT * FROM callback_deliveries ORDER BY created_at ASC, id ASC').all() as Array>; + database.exec('DROP TABLE IF EXISTS callback_deliveries_v2'); + database.exec(callbackDeliveriesTableSql('callback_deliveries_v2')); + const insert = database.prepare(` + INSERT INTO callback_deliveries_v2 ( + id, target_session_id, source_kind, source_id, source_attempt, source_outcome, + source_version, delivery_kind, payload, status, message_id, queue_item_id, + attempt_count, next_attempt_at, last_attempt_at, last_error, dead_lettered_at, + created_at, accepted_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `); + for (const row of rows) { + const id = typeof row.id === 'string' ? row.id : String(row.id ?? randomUUID()); + const targetSessionId = canonicalCallbackIdentityText( + shape === 'legacy-session' ? row.parent_session_id : row.target_session_id, + ); + const sourceKind = shape === 'legacy-session' ? 'session' : canonicalCallbackIdentityText(row.source_kind); + const sourceId = canonicalCallbackIdentityText(shape === 'legacy-session' ? row.child_session_id : row.source_id); + const sourceAttempt = canonicalCallbackIdentityText(shape === 'legacy-session' ? row.attempt_token : row.source_attempt); + const sourceOutcome = canonicalCallbackIdentityText(shape === 'legacy-session' ? row.terminal_outcome : row.source_outcome); + const deliveryKind = canonicalCallbackIdentityText(shape === 'legacy-session' ? row.callback_kind : row.delivery_kind); + const sourceVersion = Number(shape === 'legacy-session' ? row.terminal_version : row.source_version); + const candidate: SessionDeliveryRow = { + id, + targetSessionId, + sourceKind: sourceKind as SessionDeliveryIdentity['sourceKind'], + sourceId, + sourceAttempt, + sourceOutcome, + sourceVersion, + deliveryKind, + payload: typeof row.payload === 'string' ? row.payload : '', + status: row.status as SessionDelivery['status'], + messageId: (row.message_id ?? null) as string | null, + queueItemId: (row.queue_item_id ?? null) as string | null, + attemptCount: columns.has('attempt_count') ? Number(row.attempt_count ?? 0) : 0, + nextAttemptAt: (columns.has('next_attempt_at') ? row.next_attempt_at ?? null : null) as number | null, + lastAttemptAt: (columns.has('last_attempt_at') ? row.last_attempt_at ?? null : null) as number | null, + lastError: (columns.has('last_error') ? row.last_error ?? null : null) as string | null, + deadLetteredAt: (columns.has('dead_lettered_at') ? row.dead_lettered_at ?? null : null) as number | null, + createdAt: row.created_at as string, + acceptedAt: (row.accepted_at ?? null) as string | null, + }; + let persisted = candidate; + try { + sessionDeliveryFromRow(candidate); + } catch (error) { + persisted = quarantinedMigrationDelivery(candidate, error instanceof Error ? error.message : String(error)); + } + let values = sessionDeliveryInsertValues(persisted); + try { + insert.run(...values); + } catch (error) { + if (!(error instanceof Error) || !/unique constraint/i.test(error.message)) throw error; + persisted = quarantinedMigrationDelivery(candidate, 'duplicate canonical session delivery identity during migration'); + values = sessionDeliveryInsertValues(persisted); + insert.run(...values); + } + } + database.exec(` + DROP TABLE callback_deliveries; + ALTER TABLE callback_deliveries_v2 RENAME TO callback_deliveries; + `); +} + +function sessionDeliveryInsertValues(row: SessionDeliveryRow): unknown[] { + return [ + row.id, + row.targetSessionId, + row.sourceKind, + row.sourceId, + row.sourceAttempt, + row.sourceOutcome, + row.sourceVersion, + row.deliveryKind, + row.payload, + row.status, + row.messageId, + row.queueItemId, + row.attemptCount, + row.nextAttemptAt, + row.lastAttemptAt, + row.lastError, + row.deadLetteredAt, + row.createdAt, + row.acceptedAt, + ]; +} + +function quarantinedMigrationDelivery(row: SessionDeliveryRow, diagnostic: string): SessionDeliveryRow { + const safeId = canonicalCallbackIdentityText(row.id) || randomUUID(); + return { + id: row.id, + targetSessionId: `quarantined-target:${safeId}`, + sourceKind: 'session', + sourceId: `quarantined-source:${safeId}`, + sourceAttempt: `quarantined-attempt:${safeId}`, + sourceOutcome: 'quarantined', + sourceVersion: 1, + deliveryKind: 'quarantined', + payload: JSON.stringify({ message: '', displayMessage: '' }), + status: 'dead_letter', + messageId: null, + queueItemId: null, + attemptCount: 0, + nextAttemptAt: null, + lastAttemptAt: null, + lastError: `migration quarantine: ${diagnostic}`, + deadLetteredAt: Date.now(), + createdAt: typeof row.createdAt === 'string' && Number.isFinite(Date.parse(row.createdAt)) + ? row.createdAt + : new Date().toISOString(), + acceptedAt: null, + }; +} + +export function sessionDeliveryFromRow(row: SessionDeliveryRow): SessionDelivery { + if (row.deliveryKind === 'quarantined' || row.sourceOutcome === 'quarantined') { + throw new Error(`Session delivery ${row.id} is quarantined${row.lastError ? `: ${row.lastError}` : ''}`); + } + const canonicalIdentity = canonicalSessionDeliveryIdentity(row); + validateSessionDeliveryIdentity(canonicalIdentity); + for (const field of [ + 'targetSessionId', + 'sourceId', + 'sourceAttempt', + 'sourceOutcome', + 'deliveryKind', + ] as const) { + if (row[field] !== canonicalIdentity[field]) { + throw new Error(`Callback delivery ${row.id} has noncanonical ${field}`); + } + } + if (!Number.isInteger(row.sourceVersion) || row.sourceVersion < 1) { + throw new Error(`Session delivery ${row.id} has an invalid source version`); + } + if (row.sourceKind !== 'session' && row.sourceKind !== 'workflow-run') { + throw new Error(`Session delivery ${row.id} has an invalid source kind`); + } + if (!['pending', 'accepted', 'dead_letter'].includes(row.status)) { + throw new Error(`Callback delivery ${row.id} has an invalid lifecycle status`); + } + if (!Number.isInteger(row.attemptCount) || row.attemptCount < 0) { + throw new Error(`Callback delivery ${row.id} has an invalid attempt count`); + } + for (const [field, value] of Object.entries({ + nextAttemptAt: row.nextAttemptAt, + lastAttemptAt: row.lastAttemptAt, + deadLetteredAt: row.deadLetteredAt, + })) { + if (value !== null && (!Number.isInteger(value) || value < 0)) { + throw new Error(`Callback delivery ${row.id} has an invalid ${field}`); + } + } + if (typeof row.createdAt !== 'string' || !row.createdAt || !Number.isFinite(Date.parse(row.createdAt))) { + throw new Error(`Callback delivery ${row.id} has an invalid createdAt`); + } + for (const [field, value] of Object.entries({ + messageId: row.messageId, + queueItemId: row.queueItemId, + acceptedAt: row.acceptedAt, + lastError: row.lastError, + })) { + if (value !== null && (typeof value !== 'string' || value.length === 0)) { + throw new Error(`Callback delivery ${row.id} has an invalid ${field}`); + } + } + if (row.acceptedAt !== null && !Number.isFinite(Date.parse(row.acceptedAt))) { + throw new Error(`Callback delivery ${row.id} has an invalid acceptedAt`); + } + const createdAtMs = Date.parse(row.createdAt); + const acceptedAtMs = row.acceptedAt === null ? null : Date.parse(row.acceptedAt); + if (acceptedAtMs !== null && acceptedAtMs < createdAtMs) { + throw new Error(`Callback delivery ${row.id} has acceptedAt before createdAt`); + } + if (row.deadLetteredAt !== null && row.deadLetteredAt < createdAtMs) { + throw new Error(`Callback delivery ${row.id} has deadLetteredAt before createdAt`); + } + if (row.lastError !== null && row.lastError.trim() === '') { + throw new Error(`Callback delivery ${row.id} has an empty lastError`); + } + if (row.attemptCount === 0 && (row.nextAttemptAt !== null || row.lastAttemptAt !== null || row.lastError !== null)) { + throw new Error(`Callback delivery ${row.id} has attempt state without an attempt`); + } + if (row.attemptCount > 0 && row.lastAttemptAt === null) { + throw new Error(`Callback delivery ${row.id} has an attempt without lastAttemptAt`); + } + if (row.status === 'pending' && row.attemptCount > 0 && row.nextAttemptAt === null) { + throw new Error(`Callback delivery ${row.id} has a pending attempt without nextAttemptAt`); + } + if (row.lastAttemptAt !== null && row.lastAttemptAt < createdAtMs) { + throw new Error(`Callback delivery ${row.id} has lastAttemptAt before createdAt`); + } + if (row.nextAttemptAt !== null && row.lastAttemptAt === null) { + throw new Error(`Callback delivery ${row.id} has nextAttemptAt without lastAttemptAt`); + } + if (row.nextAttemptAt !== null && row.lastAttemptAt !== null && row.nextAttemptAt < row.lastAttemptAt) { + throw new Error(`Callback delivery ${row.id} has nextAttemptAt before lastAttemptAt`); + } + if (row.status === 'accepted') { + if ( + !row.messageId + || !row.queueItemId + || !row.acceptedAt + || row.nextAttemptAt !== null + || row.lastError !== null + || row.deadLetteredAt !== null + ) { + throw new Error(`Callback delivery ${row.id} has an invalid accepted lifecycle`); + } + if (acceptedAtMs !== null && row.lastAttemptAt !== null && acceptedAtMs < row.lastAttemptAt) { + throw new Error(`Callback delivery ${row.id} has acceptedAt before lastAttemptAt`); + } + } else if (row.messageId !== null || row.queueItemId !== null || row.acceptedAt !== null) { + throw new Error(`Callback delivery ${row.id} has callback acceptance state before acceptance`); + } + if (row.status === 'dead_letter') { + if (row.deadLetteredAt === null || row.nextAttemptAt !== null || !row.lastError) { + throw new Error(`Callback delivery ${row.id} has an invalid dead-letter lifecycle`); + } + if (row.lastAttemptAt !== null && row.deadLetteredAt < row.lastAttemptAt) { + throw new Error(`Callback delivery ${row.id} has deadLetteredAt before lastAttemptAt`); + } + } + if (row.status === 'pending' && row.deadLetteredAt !== null) { + throw new Error(`Callback delivery ${row.id} has dead-letter state while pending`); + } + if (row.status === 'pending' && row.lastError !== null && row.nextAttemptAt === null) { + throw new Error(`Callback delivery ${row.id} has retry error without nextAttemptAt`); + } + let payload: SessionDeliveryPayload; + try { + payload = JSON.parse(row.payload) as SessionDeliveryPayload; + } catch { + throw new Error(`Callback delivery ${row.id} has invalid payload JSON`); + } + if ( + !payload + || typeof payload !== 'object' + || typeof payload.message !== 'string' + || typeof payload.displayMessage !== 'string' + ) { + throw new Error(`Callback delivery ${row.id} has an invalid payload`); + } + return { ...row, payload }; +} + +export function canonicalSessionDeliveryIdentity(identity: SessionDeliveryIdentity): SessionDeliveryIdentity { + return { + targetSessionId: canonicalCallbackIdentityText(identity.targetSessionId), + sourceKind: identity.sourceKind, + sourceId: canonicalCallbackIdentityText(identity.sourceId), + sourceAttempt: canonicalCallbackIdentityText(identity.sourceAttempt), + sourceOutcome: canonicalCallbackIdentityText(identity.sourceOutcome), + sourceVersion: identity.sourceVersion, + deliveryKind: canonicalCallbackIdentityText(identity.deliveryKind), + }; +} + +export function validateSessionDeliveryIdentity(identity: SessionDeliveryIdentity): void { + for (const [name, value] of Object.entries({ + targetSessionId: identity.targetSessionId, + sourceId: identity.sourceId, + sourceAttempt: identity.sourceAttempt, + sourceOutcome: identity.sourceOutcome, + deliveryKind: identity.deliveryKind, + })) { + if (typeof value !== 'string' || !canonicalCallbackIdentityText(value)) throw new Error(`${name} is required for session delivery`); + } + if (identity.sourceKind !== 'session' && identity.sourceKind !== 'workflow-run') { + throw new Error('sourceKind is invalid for session delivery'); + } + if (!Number.isInteger(identity.sourceVersion) || identity.sourceVersion < 1) { + throw new Error('sourceVersion must be a positive integer for session delivery'); + } +} + +export function runImmediateMigrationWithRetry(migration: Database.Transaction<() => T>): T { + return runSqliteBusyRetry(() => migration.immediate()); +} + +/** Error classes worth waiting out when several processes open one database. + * + * SQLITE_BUSY is the obvious one. SQLITE_READONLY belongs here too, and only + * Windows shows why: `journal_mode = WAL` has to take a brief exclusive lock to + * rewrite the header, and when a peer holds the file at that instant SQLite + * reports "attempt to write a readonly database" rather than BUSY. Sixteen + * processes racing to initialize produced it roughly one run in five. + * + * Retrying a database that is genuinely read-only — bad permissions, a + * read-only mount — costs the same bounded wait and then throws the identical + * error, so nothing is masked by including it. */ +function isTransientSqliteError(code: string): boolean { + return code.startsWith('SQLITE_BUSY') || code.startsWith('SQLITE_READONLY'); +} + +/** How long to keep retrying transient contention before giving up. + * + * A time budget rather than an attempt count, because the thing being waited + * out is a window of contention whose length has nothing to do with how many + * times we have asked. The previous fixed ladder spent ~1.76s on Windows and + * then threw; sixteen processes initializing one database on a CI runner held + * the lock for longer than that, so the ladder ran out mid-race. + * + * Matched to the `busy_timeout` already set on the connection: SQLite waits ten + * seconds for a BUSY lock, so waiting a comparable span for the same class of + * contention is consistent rather than arbitrary. Only ever reached on an error + * path — a database that is genuinely read-only pays this once at boot and then + * fails with the same message it would have before. + * + * This raises a ceiling; it does not remove one. Instrumented at six times CI's + * concurrency the loop still exhausts the full budget and throws, because no + * bounded wait can be sufficient for unbounded contention. Serializing + * initialization across processes is the fix that would not have a ceiling, and + * it is a larger change than this one. */ +const SQLITE_RETRY_BUDGET_MS = process.platform === 'win32' ? 15_000 : 5_000; + +export function runSqliteBusyRetry(operation: () => T): T { + // performance.now(), not Date.now(): this runs at process start and the sleep + // below blocks the thread outright, so a backward wall-clock step during the + // wait (w32time resyncing at boot, an NTP correction, a VM snapshot restore) + // would extend a synchronous block by the size of the step, unbounded and + // unlogged — the gateway would simply appear hung. A forward step would + // silently truncate the budget instead. performance.now() is monotonic from + // process start and immune to both. + const deadline = performance.now() + SQLITE_RETRY_BUDGET_MS; + let delayMs = 10; + for (;;) { + try { + return operation(); + } catch (error) { + const code = error && typeof error === 'object' && 'code' in error + ? String((error as { code?: unknown }).code) + : ''; + const remainingMs = deadline - performance.now(); + if (!isTransientSqliteError(code) || remainingMs <= 0) throw error; + // Jittered exponential backoff. Without the jitter, peers that collided + // once back off by the same amount and collide again on every subsequent + // attempt, which is how a ladder that looks generous still exhausts itself. + const jittered = delayMs * (0.5 + Math.random()); + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, Math.max(1, Math.min(jittered, remainingMs))); + delayMs = Math.min(delayMs * 2, 500); + } + } +} diff --git a/packages/jinn/src/sessions/registry.test.ts b/packages/jinn/src/sessions/registry.test.ts index d3fd4d7f0..498a9ae1f 100644 --- a/packages/jinn/src/sessions/registry.test.ts +++ b/packages/jinn/src/sessions/registry.test.ts @@ -1,6 +1,6 @@ import { expect, test } from "vitest"; import Database from "better-sqlite3"; -import { migrateQueueItemsSchema, migrateSessionsSchema } from "./registry.js"; +import { migrateQueueItemsSchema, migrateSessionsSchema } from "./migrate.js"; test("migrateSessionsSchema upgrades an old sessions table before session_key usage", () => { const db = new Database(":memory:"); diff --git a/packages/jinn/src/sessions/registry.ts b/packages/jinn/src/sessions/registry.ts index f874cad66..fa088e44f 100644 --- a/packages/jinn/src/sessions/registry.ts +++ b/packages/jinn/src/sessions/registry.ts @@ -1,279 +1,18 @@ -import path from 'node:path'; import { randomUUID } from 'node:crypto'; -import { mkdirSync, existsSync, statSync, statfsSync, copyFileSync, writeFileSync, readFileSync, readdirSync, rmSync } from 'node:fs'; import Database from 'better-sqlite3'; import { v4 as uuidv4 } from 'uuid'; -import { SESSIONS_DB } from '../shared/paths.js'; -import { getPackageVersion } from '../shared/version.js'; import { logger } from '../shared/logger.js'; -import { - migrateWorkItemsSchema, - preflightWorkItemsDatabase, - UNSUPPORTED_PRERELEASE_TODO_DATA, - WORK_ITEMS_BACKUP_SUFFIX, -} from '../work-items/migrate.js'; -import type { WorkItemSchemaPreflight } from '../work-items/migrate.js'; +import { initDb } from '../shared/db.js'; +import { stripControlChars, hasControlBytes } from '../shared/sanitize.js'; +import { getMeta, setMeta, canonicalCallbackIdentityText, canonicalSessionDeliveryIdentity, sessionDeliveryFromRow, validateSessionDeliveryIdentity, type SessionDeliveryRow } from './migrate.js'; import { parseTodoId } from '../work-items/id.js'; import type { ChatBlock, ChatBlockEnvelope, EngineSessionRef, EngineSessionRefs, JsonObject, ReplyContext, Session, SessionAttemptOutcome, SessionDelivery, SessionDeliveryIdentity, SessionDeliveryPayload, WorkflowAttemptInterruptionCause, WorkflowSessionProvenance } from '../shared/types.js'; import { blockFallbackText, mergeBlock, validateBlockEnvelope } from '../shared/blocks.js'; import { ptySnapshotStore } from '../engines/pty-snapshot.js'; -let db: Database.Database | undefined; - export const RESTART_ACK_META_KEY = "restartAcknowledgedAt"; export const GATEWAY_RESTARTED_MESSAGE = "Gateway restarted successfully."; -const CREATE_TABLE = ` -CREATE TABLE IF NOT EXISTS sessions ( - id TEXT PRIMARY KEY, - engine TEXT NOT NULL, - engine_session_id TEXT, - engine_sessions TEXT, - source TEXT NOT NULL, - source_ref TEXT NOT NULL, - connector TEXT, - session_key TEXT, - reply_context TEXT, - message_id TEXT, - transport_meta TEXT, - employee TEXT, - model TEXT, - title TEXT, - prompt_excerpt TEXT, - parent_session_id TEXT, - workflow_kind TEXT, - workflow_id TEXT, - workflow_name TEXT, - workflow_run_id TEXT, - workflow_trigger_source TEXT, - workflow_phase_node_id TEXT, - workflow_phase_name TEXT, - workflow_phase_index INTEGER, - workflow_phase_round INTEGER, - workflow_phase_attempt INTEGER, - user_id TEXT, - status TEXT DEFAULT 'idle', - attempt_outcome TEXT, - attempt_token TEXT, - attempt_terminal_version INTEGER NOT NULL DEFAULT 0, - attempt_turn INTEGER NOT NULL DEFAULT 0, - attempt_interruption_cause TEXT, - attempt_interruption_turn INTEGER, - archived_at TEXT, - created_at TEXT NOT NULL, - last_activity TEXT NOT NULL, - last_error TEXT -)`; - -const CREATE_MESSAGES_TABLE = ` -CREATE TABLE IF NOT EXISTS messages ( - id TEXT PRIMARY KEY, - session_id TEXT NOT NULL, - role TEXT NOT NULL, - content TEXT NOT NULL, - timestamp INTEGER NOT NULL -)`; - -const CREATE_MESSAGES_INDEX = ` -CREATE INDEX IF NOT EXISTS idx_messages_session ON messages (session_id, timestamp) -`; - -const CREATE_MESSAGES_ORDER_INDEX = ` -CREATE INDEX IF NOT EXISTS idx_messages_session_order ON messages (session_id, timestamp, seq) -`; - -const CREATE_SESSION_KEY_INDEX = ` -CREATE INDEX IF NOT EXISTS idx_sessions_session_key ON sessions (session_key, last_activity) -`; - -/** Caller-supplied delegation idempotency keys map to one durable session. The - * key stored in session_key is a scoped hash, so the unique index is both - * restart-safe and safe to add to existing databases. */ -const CREATE_DELEGATION_IDEMPOTENCY_INDEX = ` -CREATE UNIQUE INDEX IF NOT EXISTS uq_sessions_delegation_idempotency - ON sessions (session_key) WHERE session_key LIKE 'delegation-idempotency:%' -`; - -// Backs `ORDER BY last_activity DESC` in the session list (was a full scan + sort). -const CREATE_LAST_ACTIVITY_INDEX = ` -CREATE INDEX IF NOT EXISTS idx_sessions_last_activity ON sessions (last_activity DESC) -`; - -// Backs the children lookup (was a full-table deserialization + JS filter). -const CREATE_PARENT_INDEX = ` -CREATE INDEX IF NOT EXISTS idx_sessions_parent ON sessions (parent_session_id) -`; - -// Backs provenance filters and workflow-run grouping lookups without parsing the -// deterministic sourceRef. Partial because ordinary chats never carry a run id. -const CREATE_WORKFLOW_RUN_INDEX = ` -CREATE INDEX IF NOT EXISTS idx_sessions_workflow_run ON sessions (workflow_run_id) - WHERE workflow_run_id IS NOT NULL -`; - -// Backs the highly-selective status filter (running ~6 of 2.5k rows) used on -// every boot (recoverStaleSessions / getInterruptedSessions) and every -// status-reconciler tick (listSessions({status:'running'})) — all of which were -// SCANning the full sessions table. Composite with last_activity DESC so the -// status-filtered list read also gets its ORDER BY from the index. -const CREATE_STATUS_INDEX = ` -CREATE INDEX IF NOT EXISTS idx_sessions_status ON sessions (status, last_activity DESC) -`; - -// Backs the `WHERE partial = 1` hot path — the boot sweep (clearAllPartialMessages) -// and every turn-settle (deletePartialMessages / finalizePartialMessages / -// getPartialMessages), which were full-SCANning the (largest) messages table to -// touch a handful of live mid-turn rows. Partial index: only the tiny set of -// currently-partial rows is indexed, so it stays cheap regardless of history size. -const CREATE_MESSAGES_PARTIAL_INDEX = ` -DROP INDEX IF EXISTS idx_messages_partial; -CREATE INDEX IF NOT EXISTS idx_messages_partial_order - ON messages (session_id, timestamp, COALESCE(seq, 0)) WHERE partial = 1 -`; - -const CREATE_FILES_TABLE = ` -CREATE TABLE IF NOT EXISTS files ( - id TEXT PRIMARY KEY, - filename TEXT NOT NULL, - size INTEGER NOT NULL, - mimetype TEXT, - path TEXT, - created_at TEXT NOT NULL -) -`; - -// Generic key/value store for one-off migration progress flags (e.g. the FTS -// backfill watermark). Keep entries tiny — this is not a config table. -const CREATE_META_TABLE = ` -CREATE TABLE IF NOT EXISTS meta ( - key TEXT PRIMARY KEY, - value TEXT -) -`; - -const CREATE_CHAT_PINS_TABLE = ` -CREATE TABLE IF NOT EXISTS chat_pins ( - pin_key TEXT PRIMARY KEY, - pinned_at TEXT NOT NULL -) -`; - -function callbackDeliveriesTableSql(tableName = 'callback_deliveries'): string { - return ` -CREATE TABLE ${tableName} ( - id TEXT PRIMARY KEY, - target_session_id TEXT NOT NULL CHECK (length(target_session_id) > 0 AND target_session_id = jinn_callback_identity(target_session_id)), - source_kind TEXT NOT NULL CHECK (source_kind IN ('session', 'workflow-run')), - source_id TEXT NOT NULL CHECK (length(source_id) > 0 AND source_id = jinn_callback_identity(source_id)), - source_attempt TEXT NOT NULL CHECK (length(source_attempt) > 0 AND source_attempt = jinn_callback_identity(source_attempt)), - source_outcome TEXT NOT NULL CHECK (length(source_outcome) > 0 AND source_outcome = jinn_callback_identity(source_outcome)), - source_version INTEGER NOT NULL CHECK (source_version >= 1), - delivery_kind TEXT NOT NULL CHECK (length(delivery_kind) > 0 AND delivery_kind = jinn_callback_identity(delivery_kind)), - payload TEXT NOT NULL CHECK ( - json_valid(payload) - AND json_type(payload) = 'object' - AND json_type(payload, '$.message') IS 'text' - AND json_type(payload, '$.displayMessage') IS 'text' - ), - status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'accepted', 'dead_letter')), - message_id TEXT, - queue_item_id TEXT, - attempt_count INTEGER NOT NULL DEFAULT 0 CHECK (attempt_count >= 0), - next_attempt_at INTEGER, - last_attempt_at INTEGER, - last_error TEXT, - dead_lettered_at INTEGER, - created_at TEXT NOT NULL, - accepted_at TEXT -) -`; -} - -const CREATE_CALLBACK_DELIVERIES_TABLE = callbackDeliveriesTableSql(); - -const CALLBACK_DELIVERY_REQUIRED_COLUMNS = [ - 'id', - 'target_session_id', - 'source_kind', - 'source_id', - 'source_attempt', - 'source_outcome', - 'source_version', - 'delivery_kind', - 'payload', - 'status', - 'message_id', - 'queue_item_id', - 'attempt_count', - 'next_attempt_at', - 'last_attempt_at', - 'last_error', - 'dead_lettered_at', - 'created_at', - 'accepted_at', -] as const; - -// Work-item primitive (GRS-002, elevated to the Todos model by GRS-021a). The -// durable unit of intended work; sessions are execution attempts against it -// (see sessions.work_item_id below). The DDL lives in `work-items/migrate.ts` -// (single source of truth shared with the vocabulary rebuild); CHECK constraints -// enforce the valid status/priority/source sets at the DB layer and the partial -// UNIQUE index gives machine-minted items idempotency on (source, source_ref). -// Created inside initDb's sequence to avoid an init-order race. The store module -// (`work-items/store.ts`) + guarded `work-items/transitions.ts` are the only -// write paths. - -// Backs listSessionsByWorkItem (the GRS-002 read-back path) and any future -// per-item session lookup. Partial: only sessions actually linked to an item. -const CREATE_WORK_ITEM_SESSION_INDEX = ` -CREATE INDEX IF NOT EXISTS idx_sessions_work_item ON sessions (work_item_id) WHERE work_item_id IS NOT NULL -`; - -// Full-text search over message bodies. External-content FTS5 table (the index -// lives here; `content` is read back from `messages` via rowid for snippets), so -// it stays in lockstep with `messages` through the AI/AD/AU triggers below. Only -// user/assistant rows are indexed — notification/tool rows are deliberately -// excluded (they're machine chatter, not conversation). Pre-existing rows are -// seeded by a yielded backfill after listen(). While that backfill is in flight, -// the AD/AU triggers only issue an FTS delete for rowids known to be indexed: -// already-drained legacy rows or post-watermark rows owned by the AI trigger. -// This keeps legacy updates/deletes safe without blocking gateway boot. -const CREATE_FTS = ` -CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts USING fts5(content, content='messages', content_rowid='rowid', tokenize='unicode61'); -CREATE TRIGGER IF NOT EXISTS messages_fts_ai AFTER INSERT ON messages -WHEN new.role IN ('user','assistant') AND ( - COALESCE((SELECT value = '1' FROM meta WHERE key = 'fts_backfill_done'), 0) - OR new.rowid <= COALESCE(CAST((SELECT value FROM meta WHERE key = 'fts_backfill_rowid') AS INTEGER), 0) - OR new.rowid > COALESCE(CAST((SELECT value FROM meta WHERE key = 'fts_backfill_max') AS INTEGER), 0) -) BEGIN - INSERT INTO messages_fts(rowid, content) VALUES (new.rowid, new.content); -END; -CREATE TRIGGER IF NOT EXISTS messages_fts_ad AFTER DELETE ON messages -WHEN old.role IN ('user','assistant') AND ( - COALESCE((SELECT value = '1' FROM meta WHERE key = 'fts_backfill_done'), 0) - OR old.rowid <= COALESCE(CAST((SELECT value FROM meta WHERE key = 'fts_backfill_rowid') AS INTEGER), 0) - OR old.rowid > COALESCE(CAST((SELECT value FROM meta WHERE key = 'fts_backfill_max') AS INTEGER), 0) -) BEGIN - INSERT INTO messages_fts(messages_fts, rowid, content) VALUES('delete', old.rowid, old.content); -END; -CREATE TRIGGER IF NOT EXISTS messages_fts_au AFTER UPDATE ON messages BEGIN - INSERT INTO messages_fts(messages_fts, rowid, content) - SELECT 'delete', old.rowid, old.content - WHERE old.role IN ('user','assistant') AND ( - COALESCE((SELECT value = '1' FROM meta WHERE key = 'fts_backfill_done'), 0) - OR old.rowid <= COALESCE(CAST((SELECT value FROM meta WHERE key = 'fts_backfill_rowid') AS INTEGER), 0) - OR old.rowid > COALESCE(CAST((SELECT value FROM meta WHERE key = 'fts_backfill_max') AS INTEGER), 0) - ); - INSERT INTO messages_fts(rowid, content) - SELECT new.rowid, new.content - WHERE new.role IN ('user','assistant') AND ( - COALESCE((SELECT value = '1' FROM meta WHERE key = 'fts_backfill_done'), 0) - OR new.rowid <= COALESCE(CAST((SELECT value FROM meta WHERE key = 'fts_backfill_rowid') AS INTEGER), 0) - OR new.rowid > COALESCE(CAST((SELECT value FROM meta WHERE key = 'fts_backfill_max') AS INTEGER), 0) - ); -END; -`; - function parseJsonObject(value: unknown, label?: string): JsonObject | null { if (typeof value !== 'string' || !value.trim()) return null; try { @@ -413,681 +152,6 @@ function rowToSession(row: Record): Session { }; } -// --- Upgrade safety around the session database ----------------------------- -// Migrations are transactional (atomic rollback on failure), but two failure -// modes still deserve belt-and-suspenders: (1) running out of disk mid-migration -// — the classic corruption trigger — and (2) an operator wanting to undo an -// upgrade. So before an upgrade migration we refuse to proceed on a near-full -// disk and snapshot the existing DB, logging where it went. -const MIN_FREE_BYTES_FOR_DB = 200 * 1024 * 1024; // 200 MB headroom for a migration -const DB_VERSION_SIDECAR = `${SESSIONS_DB}.version`; -const DB_BACKUP_DIR = path.join(path.dirname(SESSIONS_DB), 'backups'); -const KEEP_PREMIGRATION_BACKUPS = 3; - -function preflightSessionDiskSpace(): void { - let free: number; - try { - const dir = existsSync(path.dirname(SESSIONS_DB)) - ? path.dirname(SESSIONS_DB) - : path.dirname(path.dirname(SESSIONS_DB)); - const fs = statfsSync(dir); - free = fs.bavail * fs.bsize; - } catch { - return; // can't stat the filesystem — don't block boot on that alone - } - if (free < MIN_FREE_BYTES_FOR_DB) { - const mb = Math.round(free / (1024 * 1024)); - throw new Error( - `Refusing to open the session database: only ${mb} MB free on the disk holding ${SESSIONS_DB}. ` + - `Free up space before starting — running out of disk during a schema migration can corrupt the database.`, - ); - } -} - -function prunePremigrationBackups(): void { - try { - const bases = readdirSync(DB_BACKUP_DIR) - .filter((n) => n.startsWith('registry.db.pre-') && !n.endsWith('-wal') && !n.endsWith('-shm')) - .sort(); // ISO-timestamped names sort chronologically - for (const name of bases.slice(0, Math.max(0, bases.length - KEEP_PREMIGRATION_BACKUPS))) { - for (const suffix of ['', '-wal', '-shm']) rmSync(path.join(DB_BACKUP_DIR, name + suffix), { force: true }); - } - } catch { - /* best-effort pruning */ - } -} - -function maybeBackupBeforeMigration(): void { - if (!existsSync(SESSIONS_DB) || statSync(SESSIONS_DB).size === 0) return; // fresh install — nothing to snapshot - const current = getPackageVersion(); - let last = ''; - try { - last = readFileSync(DB_VERSION_SIDECAR, 'utf8').trim(); - } catch { - last = ''; - } - if (last === current) return; // same version already booted — no upgrade migration expected - try { - mkdirSync(DB_BACKUP_DIR, { recursive: true }); - // Idempotent per version: if a backup for this target version already exists - // (a prior boot, or a racing concurrent first-boot process just made one), - // don't snapshot again. - if (readdirSync(DB_BACKUP_DIR).some((n) => n.startsWith(`registry.db.pre-${current}-`))) return; - const stamp = new Date().toISOString().replace(/[:.]/g, '-'); - const from = last || 'preversioned'; - const dest = path.join(DB_BACKUP_DIR, `registry.db.pre-${current}-from-${from}-${stamp}`); - // Copy the whole consistent set so a checkpoint-pending WAL rides with its base file. - for (const suffix of ['', '-wal', '-shm']) { - if (existsSync(SESSIONS_DB + suffix)) copyFileSync(SESSIONS_DB + suffix, dest + suffix); - } - logger.info(`Pre-migration session DB backup created: ${dest} (upgrade ${from} → ${current})`); - prunePremigrationBackups(); - } catch (err) { - // A backup failure must not block boot, but it must be loud. - logger.warn(`Could not create pre-migration session DB backup: ${err instanceof Error ? err.message : err}`); - } -} - -function recordDbVersion(): void { - try { - writeFileSync(DB_VERSION_SIDECAR, getPackageVersion(), 'utf8'); - } catch { - /* best-effort — sidecar only gates the backup, never correctness */ - } -} - -/** - * Read-only Todo preflight that tolerates a peer's concurrent first-boot migration. - * - * The preflight is deliberately read-only and runs before any lock, so several - * gateway processes discovering the same fresh/upgraded home at once can have one - * of them mid-migration while another probes. During that window the probe can - * momentarily observe an inconsistent schema shape (e.g. an uncheckpointed WAL a - * read-only connection can't fully resolve) and classify it as an unsupported - * prerelease refusal even though the database is perfectly valid. - * - * That early refusal is NOT authoritative: {@link migrateWorkItemsSchema} re-runs - * the SAME classification under `BEGIN IMMEDIATE` on the write connection — which - * has full, consistent visibility — and refuses genuinely-unsupported data there, - * rolling back without persisting any write. So on that specific refusal we retry - * the read-only probe within a bounded budget: genuinely-unsupported data is stable - * and keeps refusing (so we still refuse fast, before any write), while a racing - * migration commits a valid schema within the window and a retry then succeeds. - * Corruption/disk-space errors are not this refusal and propagate immediately. - */ -function preflightWorkItemsToleratingConcurrentInit( - filename: string, -): WorkItemSchemaPreflight { - const deadline = Date.now() + 8000; - for (;;) { - try { - return preflightWorkItemsDatabase(filename); - } catch (error) { - const isPrereleaseRefusal = - error instanceof Error && error.message === UNSUPPORTED_PRERELEASE_TODO_DATA; - if (!isPrereleaseRefusal || Date.now() >= deadline) throw error; - // Synchronous sleep (initDb is sync) before re-reading a fresh snapshot. - Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 25); - } - } -} - -/** Tables of the removed Activity ledger, dropped in dependency-free order. */ -const ACTIVITY_LEDGER_TABLES = [ - 'activity_event_search', - 'activity_story_versions', - 'activity_stories', - 'activity_events', - 'activity_ledger_meta', -] as const; - -/** - * Drop the Activity ledger left behind on homes that booted a version which - * created it. No shipped code path ever appended to it, so those tables are - * empty, and fresh homes never create them — this is a no-op there. SQLite - * removes a table's indexes and triggers along with the table, so naming the - * tables is enough. Idempotent; runs inside the boot migration transaction. - * - * If a home somehow does hold events, keep everything and say so loudly: - * silently deleting operator data is never the right answer to a surprise. - */ -function dropActivityLedgerSchema(database: Database.Database): void { - const lookup = database.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?").pluck(); - const present = ACTIVITY_LEDGER_TABLES.filter((table) => lookup.get(table) !== undefined); - if (present.length === 0) return; - if (present.includes('activity_events')) { - const rows = database.prepare('SELECT COUNT(*) FROM activity_events').pluck().get() as number; - if (rows > 0) { - logger.warn( - `Refusing to drop the removed Activity ledger: activity_events holds ${rows} row(s). ` + - `Leaving ${present.join(', ')} in place — drop them by hand once those rows are exported.`, - ); - return; - } - } - for (const table of present) database.exec(`DROP TABLE ${table}`); -} - -export function initDb(): Database.Database { - if (db) return db; - // Fail fast on a near-full disk before any write — running out of space during - // a migration is the classic corruption trigger. - preflightSessionDiskSpace(); - // Todo classification is deliberately the first database operation. It opens - // an existing file read-only and refuses unsupported prerelease data before - // WAL mode, migrations, or any other schema write can occur. - const todoPreflight = preflightWorkItemsToleratingConcurrentInit(SESSIONS_DB); - // A v1 ledger is about to be rebuilt in place — keep a one-time pristine file - // copy (plus WAL/SHM sidecars) beside it so the operator can always roll back. - if (todoPreflight === 'v1') { - const backup = `${SESSIONS_DB}${WORK_ITEMS_BACKUP_SUFFIX}`; - if (!existsSync(backup)) { - copyFileSync(SESSIONS_DB, backup); - for (const suffix of ['-wal', '-shm'] as const) { - if (existsSync(`${SESSIONS_DB}${suffix}`)) copyFileSync(`${SESSIONS_DB}${suffix}`, `${backup}${suffix}`); - } - } - } - mkdirSync(path.dirname(SESSIONS_DB), { recursive: true }); - // Snapshot the existing DB before an upgrade migration mutates it (version-gated, - // so this runs once per upgrade, never on a steady-state boot). - maybeBackupBeforeMigration(); - const database = new Database(SESSIONS_DB); - db = database; - // Register the busy handler before WAL/DDL. Several gateway processes may - // discover the same fresh or upgraded home concurrently; initialization is - // serialized by SQLite instead of surfacing a transient SQLITE_BUSY. - database.pragma('busy_timeout = 10000'); - runSqliteBusyRetry(() => database.pragma('journal_mode = WAL')); - const initialize = database.transaction(() => { - database.exec(CREATE_TABLE); - database.exec(CREATE_MESSAGES_TABLE); - database.exec(CREATE_MESSAGES_INDEX); - database.exec(CREATE_META_TABLE); - migrateMessagesSchema(database); - database.exec(CREATE_MESSAGES_ORDER_INDEX); - // Partial-message index needs the `partial` column, added by migrateMessagesSchema above. - database.exec(CREATE_MESSAGES_PARTIAL_INDEX); - migrateFtsSchema(database); - // Pre-existing rows are intentionally NOT drained here. startGateway schedules - // the chunked backfill after server.listen(), and searchMessages also schedules - // it as a lazy fallback for non-gateway callers. The guarded AD/AU triggers above - // keep writes safe while that one-time backfill is incomplete. - migrateSessionsSchema(database); - database.exec(CREATE_SESSION_KEY_INDEX); - database.exec(CREATE_DELEGATION_IDEMPOTENCY_INDEX); - database.exec(CREATE_LAST_ACTIVITY_INDEX); - database.exec(CREATE_PARENT_INDEX); - database.exec(CREATE_WORKFLOW_RUN_INDEX); - database.exec(CREATE_STATUS_INDEX); - // The next public release is the first Todo release: create the clean model - // directly, or replace only a read-only-preflighted empty prerelease shape. - migrateWorkItemsSchema(database, todoPreflight); - database.exec(CREATE_WORK_ITEM_SESSION_INDEX); - dropActivityLedgerSchema(database); - database.exec(` - CREATE TABLE IF NOT EXISTS queue_items ( - id TEXT PRIMARY KEY, - session_id TEXT NOT NULL, - session_key TEXT NOT NULL, - prompt TEXT NOT NULL, - status TEXT NOT NULL DEFAULT 'pending', - internal INTEGER NOT NULL DEFAULT 0, - position INTEGER NOT NULL DEFAULT 0, - created_at TEXT NOT NULL, - started_at TEXT, - completed_at TEXT - ); - CREATE INDEX IF NOT EXISTS idx_queue_session - ON queue_items (session_key, status, position); - `); - migrateQueueItemsSchema(database); - migrateCallbackDeliveriesSchema(database); - database.exec(CREATE_FILES_TABLE); - database.exec(CREATE_CHAT_PINS_TABLE); - }); - try { - runImmediateMigrationWithRetry(initialize); - // Migration succeeded — stamp the version so the next boot at the same version - // skips the pre-migration backup. - recordDbVersion(); - return database; - } catch (error) { - database.close(); - db = undefined; - throw error; - } -} - -/** Test-only restart seam: close the process singleton so the next initDb() - * reopens the same sanitized home and reruns migrations. */ -export function __closeDbForTest(): void { - db?.close(); - db = undefined; -} - -/** - * Additive, nullable migration: add the `media` column to an existing messages - * table. Safe to run repeatedly and on legacy DBs created before media support. - */ -export function migrateMessagesSchema(database: Database.Database): void { - const cols = database.prepare('PRAGMA table_info(messages)').all() as Array<{ name: string }>; - const colNames = new Set(cols.map((c) => c.name)); - if (!colNames.has('media')) { - database.exec('ALTER TABLE messages ADD COLUMN media TEXT'); - } - // Mid-turn streaming: `partial=1` rows are the live blocks (text segments + tool - // calls) persisted DURING a turn so a refresh restores in-progress output. They - // are deleted at turn end and replaced by the single consolidated final message - // (same end-state as before). `seq` orders blocks within a turn (timestamp ms - // collides across blocks); `tool_call` carries the tool name so a reloaded tool - // block renders as a tool card, matching the live stream. All additive/nullable. - if (!colNames.has('partial')) { - database.exec('ALTER TABLE messages ADD COLUMN partial INTEGER'); - } - if (!colNames.has('seq')) { - database.exec('ALTER TABLE messages ADD COLUMN seq INTEGER'); - } - if (!colNames.has('tool_call')) { - database.exec('ALTER TABLE messages ADD COLUMN tool_call TEXT'); - } - if (!colNames.has('tool_id')) { - database.exec('ALTER TABLE messages ADD COLUMN tool_id TEXT'); - } - if (!colNames.has('blocks')) { - database.exec('ALTER TABLE messages ADD COLUMN blocks TEXT'); - } - if (!colNames.has('meta')) { - database.exec('ALTER TABLE messages ADD COLUMN meta TEXT'); - } -} - -/** Additive migration for restart-safe system work. Internal queue rows use the - * same durable ordering/replay machinery as user messages, but stay out of the - * operator-facing queue panel and its cancel/clear controls. */ -export function migrateQueueItemsSchema(database: Database.Database): void { - const columns = database.prepare('PRAGMA table_info(queue_items)').all() as Array<{ name: string }>; - if (!columns.some((column) => column.name === 'internal')) { - database.exec('ALTER TABLE queue_items ADD COLUMN internal INTEGER NOT NULL DEFAULT 0'); - } -} - -function hasSessionDeliveryConstraints(sql: string): boolean { - const normalized = sql.replace(/\s+/g, ' ').toLowerCase(); - const canonicalColumns = [ - 'target_session_id', - 'source_id', - 'source_attempt', - 'source_outcome', - 'delivery_kind', - ]; - return canonicalColumns.every((column) => - normalized.includes(`length(${column}) > 0 and ${column} = jinn_callback_identity(${column})`), - ) - && normalized.includes("source_kind in ('session', 'workflow-run')") - && normalized.includes('source_version >= 1') - && normalized.includes('json_valid(payload)') - && normalized.includes("json_type(payload) = 'object'") - && normalized.includes("json_type(payload, '$.message') is 'text'") - && normalized.includes("json_type(payload, '$.displaymessage') is 'text'") - && normalized.includes("status in ('pending', 'accepted', 'dead_letter')") - && normalized.includes('attempt_count >= 0'); -} - -/** Install the callback outbox atomically. A malformed pre-existing table is - * never silently indexed: validation throws inside the transaction so any DDL - * from this migration is rolled back as one unit. */ -export function migrateCallbackDeliveriesSchema(database: Database.Database): void { - database.pragma('busy_timeout = 10000'); - database.function('jinn_callback_identity', { deterministic: true }, canonicalCallbackIdentityText); - const migrate = database.transaction(() => { - const existing = database.prepare(` - SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'callback_deliveries' - `).get() as { sql: string } | undefined; - if (!existing) { - database.exec(CREATE_CALLBACK_DELIVERIES_TABLE); - } else { - const columns = database.prepare('PRAGMA table_info(callback_deliveries)').all() as Array<{ name: string }>; - const names = new Set(columns.map((column) => column.name)); - const legacyIdentity = [ - 'parent_session_id', - 'child_session_id', - 'attempt_token', - 'terminal_outcome', - 'terminal_version', - 'callback_kind', - ]; - const lifecycleRequired = [ - 'id', - 'payload', - 'status', - 'message_id', - 'queue_item_id', - 'created_at', - 'accepted_at', - ]; - const missingLifecycle = lifecycleRequired.filter((column) => !names.has(column)); - const hasLegacyIdentity = legacyIdentity.every((column) => names.has(column)); - const hasGenericIdentity = CALLBACK_DELIVERY_REQUIRED_COLUMNS.every((column) => names.has(column)); - if (missingLifecycle.length > 0 || (!hasLegacyIdentity && !hasGenericIdentity)) { - throw new Error(`Incompatible callback_deliveries schema: missing ${missingLifecycle.join(', ') || 'delivery identity columns'}`); - } - if (hasLegacyIdentity || !hasSessionDeliveryConstraints(existing.sql)) { - rebuildCallbackDeliveriesTable(database, names, hasLegacyIdentity ? 'legacy-session' : 'generic'); - } - } - const columns = database.prepare('PRAGMA table_info(callback_deliveries)').all() as Array<{ name: string }>; - const names = new Set(columns.map((column) => column.name)); - const missing = CALLBACK_DELIVERY_REQUIRED_COLUMNS.filter((column) => !names.has(column)); - if (missing.length > 0) { - throw new Error(`Incompatible callback_deliveries schema: missing ${missing.join(', ')}`); - } - ensureCallbackDeliveryIndexes(database); - const identityColumns = database.prepare('PRAGMA index_info(uq_callback_delivery_identity)').all() as Array<{ name: string }>; - const expectedIdentity = [ - 'target_session_id', - 'source_kind', - 'source_id', - 'source_attempt', - 'source_outcome', - 'source_version', - 'delivery_kind', - ]; - if (identityColumns.map((column) => column.name).join('|') !== expectedIdentity.join('|')) { - throw new Error('Incompatible callback delivery identity index'); - } - const indexList = database.prepare('PRAGMA index_list(callback_deliveries)').all() as Array<{ name: string; unique: number }>; - if (indexList.find((index) => index.name === 'uq_callback_delivery_identity')?.unique !== 1) { - throw new Error('Incompatible callback delivery identity uniqueness'); - } - const pendingColumns = database.prepare('PRAGMA index_info(idx_callback_deliveries_pending)').all() as Array<{ name: string }>; - const pendingSql = (database.prepare(` - SELECT sql FROM sqlite_master WHERE type = 'index' AND name = 'idx_callback_deliveries_pending' - `).get() as { sql: string } | undefined)?.sql.replace(/\s+/g, ' ').toLowerCase() ?? ''; - if ( - pendingColumns.map((column) => column.name).join('|') !== 'status|next_attempt_at|created_at' - || !pendingSql.includes("where status = 'pending'") - ) { - throw new Error('Incompatible callback delivery pending index'); - } - const installedSql = (database.prepare(` - SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'callback_deliveries' - `).get() as { sql: string }).sql; - if (!hasSessionDeliveryConstraints(installedSql)) { - throw new Error('Incompatible callback_deliveries constraints'); - } - }); - runImmediateMigrationWithRetry(migrate); -} - -function ensureCallbackDeliveryIndexes(database: Database.Database): void { - const expectedIdentity = [ - 'target_session_id', - 'source_kind', - 'source_id', - 'source_attempt', - 'source_outcome', - 'source_version', - 'delivery_kind', - ]; - const indexes = database.prepare('PRAGMA index_list(callback_deliveries)').all() as Array<{ name: string; unique: number }>; - const identity = indexes.find((index) => index.name === 'uq_callback_delivery_identity'); - const identityColumns = identity - ? database.prepare('PRAGMA index_info(uq_callback_delivery_identity)').all() as Array<{ name: string }> - : []; - if ( - identity - && (identity.unique !== 1 || identityColumns.map((column) => column.name).join('|') !== expectedIdentity.join('|')) - ) { - database.exec('DROP INDEX uq_callback_delivery_identity'); - } - database.exec(` - CREATE UNIQUE INDEX IF NOT EXISTS uq_callback_delivery_identity - ON callback_deliveries ( - target_session_id, - source_kind, - source_id, - source_attempt, - source_outcome, - source_version, - delivery_kind - ) - `); - - const refreshedIndexes = database.prepare('PRAGMA index_list(callback_deliveries)').all() as Array<{ name: string; unique: number }>; - const pending = refreshedIndexes.find((index) => index.name === 'idx_callback_deliveries_pending'); - const pendingColumns = pending - ? database.prepare('PRAGMA index_info(idx_callback_deliveries_pending)').all() as Array<{ name: string }> - : []; - const pendingSql = pending - ? (database.prepare(` - SELECT sql FROM sqlite_master WHERE type = 'index' AND name = 'idx_callback_deliveries_pending' - `).get() as { sql: string } | undefined)?.sql.replace(/\s+/g, ' ').toLowerCase() ?? '' - : ''; - if ( - pending - && ( - pending.unique !== 0 - || pendingColumns.map((column) => column.name).join('|') !== 'status|next_attempt_at|created_at' - || !pendingSql.includes("where status = 'pending'") - ) - ) { - database.exec('DROP INDEX idx_callback_deliveries_pending'); - } - database.exec(` - CREATE INDEX IF NOT EXISTS idx_callback_deliveries_pending - ON callback_deliveries (status, next_attempt_at, created_at) - WHERE status = 'pending' - `); -} - -function runImmediateMigrationWithRetry(migration: Database.Transaction<() => T>): T { - return runSqliteBusyRetry(() => migration.immediate()); -} - -/** Error classes worth waiting out when several processes open one database. - * - * SQLITE_BUSY is the obvious one. SQLITE_READONLY belongs here too, and only - * Windows shows why: `journal_mode = WAL` has to take a brief exclusive lock to - * rewrite the header, and when a peer holds the file at that instant SQLite - * reports "attempt to write a readonly database" rather than BUSY. Sixteen - * processes racing to initialize produced it roughly one run in five. - * - * Retrying a database that is genuinely read-only — bad permissions, a - * read-only mount — costs the same bounded wait and then throws the identical - * error, so nothing is masked by including it. */ -function isTransientSqliteError(code: string): boolean { - return code.startsWith('SQLITE_BUSY') || code.startsWith('SQLITE_READONLY'); -} - -/** How long to keep retrying transient contention before giving up. - * - * A time budget rather than an attempt count, because the thing being waited - * out is a window of contention whose length has nothing to do with how many - * times we have asked. The previous fixed ladder spent ~1.76s on Windows and - * then threw; sixteen processes initializing one database on a CI runner held - * the lock for longer than that, so the ladder ran out mid-race. - * - * Matched to the `busy_timeout` already set on the connection: SQLite waits ten - * seconds for a BUSY lock, so waiting a comparable span for the same class of - * contention is consistent rather than arbitrary. Only ever reached on an error - * path — a database that is genuinely read-only pays this once at boot and then - * fails with the same message it would have before. - * - * This raises a ceiling; it does not remove one. Instrumented at six times CI's - * concurrency the loop still exhausts the full budget and throws, because no - * bounded wait can be sufficient for unbounded contention. Serializing - * initialization across processes is the fix that would not have a ceiling, and - * it is a larger change than this one. */ -const SQLITE_RETRY_BUDGET_MS = process.platform === 'win32' ? 15_000 : 5_000; - -function runSqliteBusyRetry(operation: () => T): T { - // performance.now(), not Date.now(): this runs at process start and the sleep - // below blocks the thread outright, so a backward wall-clock step during the - // wait (w32time resyncing at boot, an NTP correction, a VM snapshot restore) - // would extend a synchronous block by the size of the step, unbounded and - // unlogged — the gateway would simply appear hung. A forward step would - // silently truncate the budget instead. performance.now() is monotonic from - // process start and immune to both. - const deadline = performance.now() + SQLITE_RETRY_BUDGET_MS; - let delayMs = 10; - for (;;) { - try { - return operation(); - } catch (error) { - const code = error && typeof error === 'object' && 'code' in error - ? String((error as { code?: unknown }).code) - : ''; - const remainingMs = deadline - performance.now(); - if (!isTransientSqliteError(code) || remainingMs <= 0) throw error; - // Jittered exponential backoff. Without the jitter, peers that collided - // once back off by the same amount and collide again on every subsequent - // attempt, which is how a ladder that looks generous still exhausts itself. - const jittered = delayMs * (0.5 + Math.random()); - Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, Math.max(1, Math.min(jittered, remainingMs))); - delayMs = Math.min(delayMs * 2, 500); - } - } -} - -function canonicalCallbackIdentityText(value: unknown): string { - return typeof value === 'string' - ? value.normalize('NFC').replace(/^\p{White_Space}+|\p{White_Space}+$/gu, '') - : ''; -} - -function rebuildCallbackDeliveriesTable( - database: Database.Database, - columns: Set, - shape: 'legacy-session' | 'generic', -): void { - const rows = database.prepare('SELECT * FROM callback_deliveries ORDER BY created_at ASC, id ASC').all() as Array>; - database.exec('DROP TABLE IF EXISTS callback_deliveries_v2'); - database.exec(callbackDeliveriesTableSql('callback_deliveries_v2')); - const insert = database.prepare(` - INSERT INTO callback_deliveries_v2 ( - id, target_session_id, source_kind, source_id, source_attempt, source_outcome, - source_version, delivery_kind, payload, status, message_id, queue_item_id, - attempt_count, next_attempt_at, last_attempt_at, last_error, dead_lettered_at, - created_at, accepted_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `); - for (const row of rows) { - const id = typeof row.id === 'string' ? row.id : String(row.id ?? randomUUID()); - const targetSessionId = canonicalCallbackIdentityText( - shape === 'legacy-session' ? row.parent_session_id : row.target_session_id, - ); - const sourceKind = shape === 'legacy-session' ? 'session' : canonicalCallbackIdentityText(row.source_kind); - const sourceId = canonicalCallbackIdentityText(shape === 'legacy-session' ? row.child_session_id : row.source_id); - const sourceAttempt = canonicalCallbackIdentityText(shape === 'legacy-session' ? row.attempt_token : row.source_attempt); - const sourceOutcome = canonicalCallbackIdentityText(shape === 'legacy-session' ? row.terminal_outcome : row.source_outcome); - const deliveryKind = canonicalCallbackIdentityText(shape === 'legacy-session' ? row.callback_kind : row.delivery_kind); - const sourceVersion = Number(shape === 'legacy-session' ? row.terminal_version : row.source_version); - const candidate: SessionDeliveryRow = { - id, - targetSessionId, - sourceKind: sourceKind as SessionDeliveryIdentity['sourceKind'], - sourceId, - sourceAttempt, - sourceOutcome, - sourceVersion, - deliveryKind, - payload: typeof row.payload === 'string' ? row.payload : '', - status: row.status as SessionDelivery['status'], - messageId: (row.message_id ?? null) as string | null, - queueItemId: (row.queue_item_id ?? null) as string | null, - attemptCount: columns.has('attempt_count') ? Number(row.attempt_count ?? 0) : 0, - nextAttemptAt: (columns.has('next_attempt_at') ? row.next_attempt_at ?? null : null) as number | null, - lastAttemptAt: (columns.has('last_attempt_at') ? row.last_attempt_at ?? null : null) as number | null, - lastError: (columns.has('last_error') ? row.last_error ?? null : null) as string | null, - deadLetteredAt: (columns.has('dead_lettered_at') ? row.dead_lettered_at ?? null : null) as number | null, - createdAt: row.created_at as string, - acceptedAt: (row.accepted_at ?? null) as string | null, - }; - let persisted = candidate; - try { - sessionDeliveryFromRow(candidate); - } catch (error) { - persisted = quarantinedMigrationDelivery(candidate, error instanceof Error ? error.message : String(error)); - } - let values = sessionDeliveryInsertValues(persisted); - try { - insert.run(...values); - } catch (error) { - if (!(error instanceof Error) || !/unique constraint/i.test(error.message)) throw error; - persisted = quarantinedMigrationDelivery(candidate, 'duplicate canonical session delivery identity during migration'); - values = sessionDeliveryInsertValues(persisted); - insert.run(...values); - } - } - database.exec(` - DROP TABLE callback_deliveries; - ALTER TABLE callback_deliveries_v2 RENAME TO callback_deliveries; - `); -} - -function sessionDeliveryInsertValues(row: SessionDeliveryRow): unknown[] { - return [ - row.id, - row.targetSessionId, - row.sourceKind, - row.sourceId, - row.sourceAttempt, - row.sourceOutcome, - row.sourceVersion, - row.deliveryKind, - row.payload, - row.status, - row.messageId, - row.queueItemId, - row.attemptCount, - row.nextAttemptAt, - row.lastAttemptAt, - row.lastError, - row.deadLetteredAt, - row.createdAt, - row.acceptedAt, - ]; -} - -function quarantinedMigrationDelivery(row: SessionDeliveryRow, diagnostic: string): SessionDeliveryRow { - const safeId = canonicalCallbackIdentityText(row.id) || randomUUID(); - return { - id: row.id, - targetSessionId: `quarantined-target:${safeId}`, - sourceKind: 'session', - sourceId: `quarantined-source:${safeId}`, - sourceAttempt: `quarantined-attempt:${safeId}`, - sourceOutcome: 'quarantined', - sourceVersion: 1, - deliveryKind: 'quarantined', - payload: JSON.stringify({ message: '', displayMessage: '' }), - status: 'dead_letter', - messageId: null, - queueItemId: null, - attemptCount: 0, - nextAttemptAt: null, - lastAttemptAt: null, - lastError: `migration quarantine: ${diagnostic}`, - deadLetteredAt: Date.now(), - createdAt: typeof row.createdAt === 'string' && Number.isFinite(Date.parse(row.createdAt)) - ? row.createdAt - : new Date().toISOString(), - acceptedAt: null, - }; -} - -function getMeta(database: Database.Database, key: string): string | null { - const row = database.prepare('SELECT value FROM meta WHERE key = ?').get(key) as { value: string } | undefined; - return row ? row.value : null; -} - -function setMeta(database: Database.Database, key: string, value: string): void { - database - .prepare('INSERT INTO meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value') - .run(key, value); -} - /** Read a value from the generic key/value meta store (one-off progress flags / * watermarks). Returns null when the key was never written. */ export function getMetaValue(key: string): string | null { @@ -1099,39 +163,6 @@ export function setMetaValue(key: string, value: string): void { setMeta(initDb(), key, value); } -/** - * Create the FTS5 search index + sync triggers, and record the backfill watermark. - * - * The triggers keep the index current for every message written from now on. Rows - * that already existed before this table did are NOT seen by the triggers, so they - * are seeded separately by the chunked backfill (`scheduleFtsBackfill`). To stop - * the backfill from double-indexing rows the triggers also handle, we snapshot the - * current MAX(rowid) here — synchronously, before any new insert can race in — and - * the backfill only ever touches `rowid <= fts_backfill_max`. Anything above that - * watermark is a brand-new row and belongs to the triggers. - * - * Idempotent: safe to run on every boot. On a DB where the backfill already - * completed it is a no-op. - */ -export function migrateFtsSchema(database: Database.Database): void { - database.exec(CREATE_META_TABLE); - // Trigger definitions changed when the boot drain became asynchronous. Rebuild - // them idempotently so upgraded databases get the guarded AD/AU behavior too; - // CREATE TRIGGER IF NOT EXISTS alone would preserve the unsafe legacy bodies. - database.exec(` - DROP TRIGGER IF EXISTS messages_fts_ai; - DROP TRIGGER IF EXISTS messages_fts_ad; - DROP TRIGGER IF EXISTS messages_fts_au; - `); - database.exec(CREATE_FTS); - // First time we see this DB and the backfill hasn't run: pin the watermark. - if (getMeta(database, 'fts_backfill_done') !== '1' && getMeta(database, 'fts_backfill_max') === null) { - const row = database.prepare('SELECT MAX(rowid) AS m FROM messages').get() as { m: number | null }; - setMeta(database, 'fts_backfill_max', String(row.m ?? 0)); - setMeta(database, 'fts_backfill_rowid', '0'); - } -} - const FTS_BACKFILL_CHUNK = 1000; /** @@ -1272,26 +303,6 @@ export interface MessageSearchResult { engine: string | null; } -/** Replace NUL and other non-printing control bytes with spaces (GRS-020a-fix - * finding 2). Shared by the FTS sanitizer and the search routes so hostile - * encoded input (%00 etc.) yields a normal result everywhere, never a 500. */ -export function stripControlChars(value: string): string { - // eslint-disable-next-line no-control-regex - return value.replace(/[\u0000-\u001f\u007f]/g, ' '); -} - -/** True if the string carries a NUL or other non-printing control byte. The - * REJECT-don't-strip gate for security-critical PATH params (GRS-020b-fix): - * {@link stripControlChars} would silently REPAIR a `%00`-tampered path into a - * valid one, so the knowledge read surface rejects on the raw param instead. */ -export function hasControlBytes(value: string): boolean { - for (let i = 0; i < value.length; i++) { - const c = value.charCodeAt(i); - if (c <= 0x1f || c === 0x7f) return true; - } - return false; -} - /** Deterministic AND-composed narrowing for searchMessages (GRS-020a). All * values become bound SQL parameters — never spliced into the statement. */ export interface MessageSearchFilter { @@ -1403,69 +414,6 @@ export function searchMessages(query: string, limit = 50, filter?: MessageSearch } } -export function migrateSessionsSchema(database: Database.Database): void { - const cols = database.prepare('PRAGMA table_info(sessions)').all() as Array<{ name: string }>; - const colNames = new Set(cols.map((c) => c.name)); - const missingColumns: Array<[string, string, string?]> = [ - ['title', 'TEXT'], - ['parent_session_id', 'TEXT'], - ['workflow_kind', 'TEXT'], - ['workflow_id', 'TEXT'], - ['workflow_name', 'TEXT'], - ['workflow_run_id', 'TEXT'], - ['workflow_trigger_source', 'TEXT'], - ['workflow_phase_node_id', 'TEXT'], - ['workflow_phase_name', 'TEXT'], - ['workflow_phase_index', 'INTEGER'], - ['workflow_phase_round', 'INTEGER'], - ['workflow_phase_attempt', 'INTEGER'], - ['connector', 'TEXT'], - ['session_key', 'TEXT'], - ['reply_context', 'TEXT'], - ['message_id', 'TEXT'], - ['transport_meta', 'TEXT'], - ['engine_sessions', 'TEXT'], - ['total_cost', 'REAL', '0'], - ['total_turns', 'INTEGER', '0'], - ['effort_level', 'TEXT'], - ['last_context_tokens', 'INTEGER'], - ['user_id', 'TEXT'], - // No backfill: pre-existing sessions stay NULL (no excerpt); only new sessions populate it. - ['prompt_excerpt', 'TEXT'], - // Work-item link (GRS-002). Nullable; NULL = unchanged legacy behavior. The - // partial index idx_sessions_work_item is created in initDb. - ['work_item_id', 'TEXT'], - // Explicit latest-attempt receipt. NULL means no successful/failed terminal - // engine result has been recorded; `idle` by itself is not completion proof. - ['attempt_outcome', 'TEXT'], - // Per-dispatch generation used for compare-and-set terminal writes. - ['attempt_token', 'TEXT'], - ['attempt_terminal_version', 'INTEGER NOT NULL', '0'], - ['attempt_turn', 'INTEGER NOT NULL', '0'], - ['attempt_interruption_cause', 'TEXT'], - ['attempt_interruption_turn', 'INTEGER'], - // Archive is reversible: retain the durable chat and only hide it from - // normal list queries. NULL keeps all pre-existing sessions visible. - ['archived_at', 'TEXT'], - ]; - - for (const [name, type, defaultVal] of missingColumns) { - if (!colNames.has(name)) { - const defaultClause = defaultVal !== undefined ? ` DEFAULT ${defaultVal}` : ''; - database.exec(`ALTER TABLE sessions ADD COLUMN ${name} ${type}${defaultClause}`); - } - } - - const refreshedCols = database.prepare('PRAGMA table_info(sessions)').all() as Array<{ name: string }>; - const refreshedNames = new Set(refreshedCols.map((c) => c.name)); - if (refreshedNames.has('session_key')) { - database.exec(`UPDATE sessions SET session_key = COALESCE(session_key, source_ref) WHERE session_key IS NULL OR session_key = ''`); - } - if (refreshedNames.has('connector')) { - database.exec(`UPDATE sessions SET connector = COALESCE(connector, source) WHERE connector IS NULL OR connector = ''`); - } -} - export interface CreateSessionOpts { engine: string; source: string; @@ -2467,7 +1415,7 @@ export function listChildSessions(parentSessionId: string): Session[] { /** * Execution attempts (sessions) linked to a work item — backed by - * idx_sessions_work_item. The read-back half of the GRS-002 work-item slice + * idx_sessions_work_item. The read-back half of the work-item slice * (cron mints+links an item; this reads its sessions). Newest first. */ export function listSessionsByWorkItem(workItemId: string): Session[] { @@ -3315,28 +2263,6 @@ export function clearAllPartialMessages(): number { return db.prepare('DELETE FROM messages WHERE partial = 1').run().changes; } -interface SessionDeliveryRow { - id: string; - targetSessionId: string; - sourceKind: SessionDeliveryIdentity['sourceKind']; - sourceId: string; - sourceAttempt: string; - sourceOutcome: string; - sourceVersion: number; - deliveryKind: string; - payload: string; - status: SessionDelivery['status']; - messageId: string | null; - queueItemId: string | null; - attemptCount: number; - nextAttemptAt: number | null; - lastAttemptAt: number | null; - lastError: string | null; - deadLetteredAt: number | null; - createdAt: string; - acceptedAt: string | null; -} - const CALLBACK_DELIVERY_SELECT = ` SELECT id, @@ -3361,167 +2287,6 @@ const CALLBACK_DELIVERY_SELECT = ` FROM callback_deliveries `; -function sessionDeliveryFromRow(row: SessionDeliveryRow): SessionDelivery { - if (row.deliveryKind === 'quarantined' || row.sourceOutcome === 'quarantined') { - throw new Error(`Session delivery ${row.id} is quarantined${row.lastError ? `: ${row.lastError}` : ''}`); - } - const canonicalIdentity = canonicalSessionDeliveryIdentity(row); - validateSessionDeliveryIdentity(canonicalIdentity); - for (const field of [ - 'targetSessionId', - 'sourceId', - 'sourceAttempt', - 'sourceOutcome', - 'deliveryKind', - ] as const) { - if (row[field] !== canonicalIdentity[field]) { - throw new Error(`Callback delivery ${row.id} has noncanonical ${field}`); - } - } - if (!Number.isInteger(row.sourceVersion) || row.sourceVersion < 1) { - throw new Error(`Session delivery ${row.id} has an invalid source version`); - } - if (row.sourceKind !== 'session' && row.sourceKind !== 'workflow-run') { - throw new Error(`Session delivery ${row.id} has an invalid source kind`); - } - if (!['pending', 'accepted', 'dead_letter'].includes(row.status)) { - throw new Error(`Callback delivery ${row.id} has an invalid lifecycle status`); - } - if (!Number.isInteger(row.attemptCount) || row.attemptCount < 0) { - throw new Error(`Callback delivery ${row.id} has an invalid attempt count`); - } - for (const [field, value] of Object.entries({ - nextAttemptAt: row.nextAttemptAt, - lastAttemptAt: row.lastAttemptAt, - deadLetteredAt: row.deadLetteredAt, - })) { - if (value !== null && (!Number.isInteger(value) || value < 0)) { - throw new Error(`Callback delivery ${row.id} has an invalid ${field}`); - } - } - if (typeof row.createdAt !== 'string' || !row.createdAt || !Number.isFinite(Date.parse(row.createdAt))) { - throw new Error(`Callback delivery ${row.id} has an invalid createdAt`); - } - for (const [field, value] of Object.entries({ - messageId: row.messageId, - queueItemId: row.queueItemId, - acceptedAt: row.acceptedAt, - lastError: row.lastError, - })) { - if (value !== null && (typeof value !== 'string' || value.length === 0)) { - throw new Error(`Callback delivery ${row.id} has an invalid ${field}`); - } - } - if (row.acceptedAt !== null && !Number.isFinite(Date.parse(row.acceptedAt))) { - throw new Error(`Callback delivery ${row.id} has an invalid acceptedAt`); - } - const createdAtMs = Date.parse(row.createdAt); - const acceptedAtMs = row.acceptedAt === null ? null : Date.parse(row.acceptedAt); - if (acceptedAtMs !== null && acceptedAtMs < createdAtMs) { - throw new Error(`Callback delivery ${row.id} has acceptedAt before createdAt`); - } - if (row.deadLetteredAt !== null && row.deadLetteredAt < createdAtMs) { - throw new Error(`Callback delivery ${row.id} has deadLetteredAt before createdAt`); - } - if (row.lastError !== null && row.lastError.trim() === '') { - throw new Error(`Callback delivery ${row.id} has an empty lastError`); - } - if (row.attemptCount === 0 && (row.nextAttemptAt !== null || row.lastAttemptAt !== null || row.lastError !== null)) { - throw new Error(`Callback delivery ${row.id} has attempt state without an attempt`); - } - if (row.attemptCount > 0 && row.lastAttemptAt === null) { - throw new Error(`Callback delivery ${row.id} has an attempt without lastAttemptAt`); - } - if (row.status === 'pending' && row.attemptCount > 0 && row.nextAttemptAt === null) { - throw new Error(`Callback delivery ${row.id} has a pending attempt without nextAttemptAt`); - } - if (row.lastAttemptAt !== null && row.lastAttemptAt < createdAtMs) { - throw new Error(`Callback delivery ${row.id} has lastAttemptAt before createdAt`); - } - if (row.nextAttemptAt !== null && row.lastAttemptAt === null) { - throw new Error(`Callback delivery ${row.id} has nextAttemptAt without lastAttemptAt`); - } - if (row.nextAttemptAt !== null && row.lastAttemptAt !== null && row.nextAttemptAt < row.lastAttemptAt) { - throw new Error(`Callback delivery ${row.id} has nextAttemptAt before lastAttemptAt`); - } - if (row.status === 'accepted') { - if ( - !row.messageId - || !row.queueItemId - || !row.acceptedAt - || row.nextAttemptAt !== null - || row.lastError !== null - || row.deadLetteredAt !== null - ) { - throw new Error(`Callback delivery ${row.id} has an invalid accepted lifecycle`); - } - if (acceptedAtMs !== null && row.lastAttemptAt !== null && acceptedAtMs < row.lastAttemptAt) { - throw new Error(`Callback delivery ${row.id} has acceptedAt before lastAttemptAt`); - } - } else if (row.messageId !== null || row.queueItemId !== null || row.acceptedAt !== null) { - throw new Error(`Callback delivery ${row.id} has callback acceptance state before acceptance`); - } - if (row.status === 'dead_letter') { - if (row.deadLetteredAt === null || row.nextAttemptAt !== null || !row.lastError) { - throw new Error(`Callback delivery ${row.id} has an invalid dead-letter lifecycle`); - } - if (row.lastAttemptAt !== null && row.deadLetteredAt < row.lastAttemptAt) { - throw new Error(`Callback delivery ${row.id} has deadLetteredAt before lastAttemptAt`); - } - } - if (row.status === 'pending' && row.deadLetteredAt !== null) { - throw new Error(`Callback delivery ${row.id} has dead-letter state while pending`); - } - if (row.status === 'pending' && row.lastError !== null && row.nextAttemptAt === null) { - throw new Error(`Callback delivery ${row.id} has retry error without nextAttemptAt`); - } - let payload: SessionDeliveryPayload; - try { - payload = JSON.parse(row.payload) as SessionDeliveryPayload; - } catch { - throw new Error(`Callback delivery ${row.id} has invalid payload JSON`); - } - if ( - !payload - || typeof payload !== 'object' - || typeof payload.message !== 'string' - || typeof payload.displayMessage !== 'string' - ) { - throw new Error(`Callback delivery ${row.id} has an invalid payload`); - } - return { ...row, payload }; -} - -function canonicalSessionDeliveryIdentity(identity: SessionDeliveryIdentity): SessionDeliveryIdentity { - return { - targetSessionId: canonicalCallbackIdentityText(identity.targetSessionId), - sourceKind: identity.sourceKind, - sourceId: canonicalCallbackIdentityText(identity.sourceId), - sourceAttempt: canonicalCallbackIdentityText(identity.sourceAttempt), - sourceOutcome: canonicalCallbackIdentityText(identity.sourceOutcome), - sourceVersion: identity.sourceVersion, - deliveryKind: canonicalCallbackIdentityText(identity.deliveryKind), - }; -} - -function validateSessionDeliveryIdentity(identity: SessionDeliveryIdentity): void { - for (const [name, value] of Object.entries({ - targetSessionId: identity.targetSessionId, - sourceId: identity.sourceId, - sourceAttempt: identity.sourceAttempt, - sourceOutcome: identity.sourceOutcome, - deliveryKind: identity.deliveryKind, - })) { - if (typeof value !== 'string' || !canonicalCallbackIdentityText(value)) throw new Error(`${name} is required for session delivery`); - } - if (identity.sourceKind !== 'session' && identity.sourceKind !== 'workflow-run') { - throw new Error('sourceKind is invalid for session delivery'); - } - if (!Number.isInteger(identity.sourceVersion) || identity.sourceVersion < 1) { - throw new Error('sourceVersion must be a positive integer for session delivery'); - } -} - export function getSessionDelivery(id: string): SessionDelivery | undefined { const row = initDb().prepare(`${CALLBACK_DELIVERY_SELECT} WHERE id = ?`).get(id) as SessionDeliveryRow | undefined; return row ? sessionDeliveryFromRow(row) : undefined; diff --git a/packages/jinn/src/shared/__tests__/test-home-guard.test.ts b/packages/jinn/src/shared/__tests__/test-home-guard.test.ts index bfd9dc930..f040f5d75 100644 --- a/packages/jinn/src/shared/__tests__/test-home-guard.test.ts +++ b/packages/jinn/src/shared/__tests__/test-home-guard.test.ts @@ -11,7 +11,7 @@ import { import vitestConfig from '../../../vitest.config.js'; import setupVitest from '../../../vitest.global-setup.js'; import { JINN_HOME, SESSIONS_DB, assertTestRunIsIsolated } from '../paths.js'; -import { initDb } from '../../sessions/registry.js'; +import { initDb } from '../db.js'; import { createWorkItem } from '../../work-items/store.js'; const createdHomes: string[] = []; diff --git a/packages/jinn/src/shared/db.ts b/packages/jinn/src/shared/db.ts new file mode 100644 index 000000000..4d40a77c6 --- /dev/null +++ b/packages/jinn/src/shared/db.ts @@ -0,0 +1,252 @@ +// The process-wide SQLite connection. Owns opening, upgrade safety and the one +// initialize transaction; each core module owns its own DDL in its migrate.ts and +// this composition root sequences them in a fixed order. +import path from 'node:path'; +import { mkdirSync, existsSync, statSync, statfsSync, copyFileSync, writeFileSync, readFileSync, readdirSync, rmSync } from 'node:fs'; +import Database from 'better-sqlite3'; +import { SESSIONS_DB } from './paths.js'; +import { getPackageVersion } from './version.js'; +import { logger } from './logger.js'; +import { migrateWorkItemsSchema, preflightWorkItemsDatabase, UNSUPPORTED_PRERELEASE_TODO_DATA, WORK_ITEMS_BACKUP_SUFFIX } from '../work-items/migrate.js'; +import type { WorkItemSchemaPreflight } from '../work-items/migrate.js'; +import { CREATE_TABLE, CREATE_MESSAGES_TABLE, CREATE_MESSAGES_INDEX, CREATE_META_TABLE, CREATE_MESSAGES_ORDER_INDEX, CREATE_MESSAGES_PARTIAL_INDEX, CREATE_SESSION_KEY_INDEX, CREATE_DELEGATION_IDEMPOTENCY_INDEX, CREATE_LAST_ACTIVITY_INDEX, CREATE_PARENT_INDEX, CREATE_WORKFLOW_RUN_INDEX, CREATE_STATUS_INDEX, CREATE_WORK_ITEM_SESSION_INDEX, CREATE_QUEUE_ITEMS_TABLE, CREATE_FILES_TABLE, CREATE_CHAT_PINS_TABLE, migrateMessagesSchema, migrateFtsSchema, migrateSessionsSchema, migrateQueueItemsSchema, migrateCallbackDeliveriesSchema, runImmediateMigrationWithRetry, runSqliteBusyRetry } from '../sessions/migrate.js'; + +let db: Database.Database | undefined; + +// --- Upgrade safety around the session database ----------------------------- +// Migrations are transactional (atomic rollback on failure), but two failure +// modes still deserve belt-and-suspenders: (1) running out of disk mid-migration +// — the classic corruption trigger — and (2) an operator wanting to undo an +// upgrade. So before an upgrade migration we refuse to proceed on a near-full +// disk and snapshot the existing DB, logging where it went. +const MIN_FREE_BYTES_FOR_DB = 200 * 1024 * 1024; // 200 MB headroom for a migration +const DB_VERSION_SIDECAR = `${SESSIONS_DB}.version`; +const DB_BACKUP_DIR = path.join(path.dirname(SESSIONS_DB), 'backups'); +const KEEP_PREMIGRATION_BACKUPS = 3; + +function preflightSessionDiskSpace(): void { + let free: number; + try { + const dir = existsSync(path.dirname(SESSIONS_DB)) + ? path.dirname(SESSIONS_DB) + : path.dirname(path.dirname(SESSIONS_DB)); + const fs = statfsSync(dir); + free = fs.bavail * fs.bsize; + } catch { + return; // can't stat the filesystem — don't block boot on that alone + } + if (free < MIN_FREE_BYTES_FOR_DB) { + const mb = Math.round(free / (1024 * 1024)); + throw new Error( + `Refusing to open the session database: only ${mb} MB free on the disk holding ${SESSIONS_DB}. ` + + `Free up space before starting — running out of disk during a schema migration can corrupt the database.`, + ); + } +} + +function prunePremigrationBackups(): void { + try { + const bases = readdirSync(DB_BACKUP_DIR) + .filter((n) => n.startsWith('registry.db.pre-') && !n.endsWith('-wal') && !n.endsWith('-shm')) + .sort(); // ISO-timestamped names sort chronologically + for (const name of bases.slice(0, Math.max(0, bases.length - KEEP_PREMIGRATION_BACKUPS))) { + for (const suffix of ['', '-wal', '-shm']) rmSync(path.join(DB_BACKUP_DIR, name + suffix), { force: true }); + } + } catch { + /* best-effort pruning */ + } +} + +function maybeBackupBeforeMigration(): void { + if (!existsSync(SESSIONS_DB) || statSync(SESSIONS_DB).size === 0) return; // fresh install — nothing to snapshot + const current = getPackageVersion(); + let last = ''; + try { + last = readFileSync(DB_VERSION_SIDECAR, 'utf8').trim(); + } catch { + last = ''; + } + if (last === current) return; // same version already booted — no upgrade migration expected + try { + mkdirSync(DB_BACKUP_DIR, { recursive: true }); + // Idempotent per version: if a backup for this target version already exists + // (a prior boot, or a racing concurrent first-boot process just made one), + // don't snapshot again. + if (readdirSync(DB_BACKUP_DIR).some((n) => n.startsWith(`registry.db.pre-${current}-`))) return; + const stamp = new Date().toISOString().replace(/[:.]/g, '-'); + const from = last || 'preversioned'; + const dest = path.join(DB_BACKUP_DIR, `registry.db.pre-${current}-from-${from}-${stamp}`); + // Copy the whole consistent set so a checkpoint-pending WAL rides with its base file. + for (const suffix of ['', '-wal', '-shm']) { + if (existsSync(SESSIONS_DB + suffix)) copyFileSync(SESSIONS_DB + suffix, dest + suffix); + } + logger.info(`Pre-migration session DB backup created: ${dest} (upgrade ${from} → ${current})`); + prunePremigrationBackups(); + } catch (err) { + // A backup failure must not block boot, but it must be loud. + logger.warn(`Could not create pre-migration session DB backup: ${err instanceof Error ? err.message : err}`); + } +} + +function recordDbVersion(): void { + try { + writeFileSync(DB_VERSION_SIDECAR, getPackageVersion(), 'utf8'); + } catch { + /* best-effort — sidecar only gates the backup, never correctness */ + } +} + +/** + * Read-only Todo preflight that tolerates a peer's concurrent first-boot migration. + * + * The preflight is deliberately read-only and runs before any lock, so several + * gateway processes discovering the same fresh/upgraded home at once can have one + * of them mid-migration while another probes. During that window the probe can + * momentarily observe an inconsistent schema shape (e.g. an uncheckpointed WAL a + * read-only connection can't fully resolve) and classify it as an unsupported + * prerelease refusal even though the database is perfectly valid. + * + * That early refusal is NOT authoritative: {@link migrateWorkItemsSchema} re-runs + * the SAME classification under `BEGIN IMMEDIATE` on the write connection — which + * has full, consistent visibility — and refuses genuinely-unsupported data there, + * rolling back without persisting any write. So on that specific refusal we retry + * the read-only probe within a bounded budget: genuinely-unsupported data is stable + * and keeps refusing (so we still refuse fast, before any write), while a racing + * migration commits a valid schema within the window and a retry then succeeds. + * Corruption/disk-space errors are not this refusal and propagate immediately. + */ +function preflightWorkItemsToleratingConcurrentInit( + filename: string, +): WorkItemSchemaPreflight { + const deadline = Date.now() + 8000; + for (;;) { + try { + return preflightWorkItemsDatabase(filename); + } catch (error) { + const isPrereleaseRefusal = + error instanceof Error && error.message === UNSUPPORTED_PRERELEASE_TODO_DATA; + if (!isPrereleaseRefusal || Date.now() >= deadline) throw error; + // Synchronous sleep (initDb is sync) before re-reading a fresh snapshot. + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 25); + } + } +} + +/** Tables of the removed Activity ledger, dropped in dependency-free order. */ +const ACTIVITY_LEDGER_TABLES = [ + 'activity_event_search', + 'activity_story_versions', + 'activity_stories', + 'activity_events', + 'activity_ledger_meta', +] as const; + +/** + * Drop the Activity ledger left behind on homes that booted a version which + * created it. No shipped code path ever appended to it, so those tables are + * empty, and fresh homes never create them — this is a no-op there. SQLite + * removes a table's indexes and triggers along with the table, so naming the + * tables is enough. Idempotent; runs inside the boot migration transaction. + * + * If a home somehow does hold events, keep everything and say so loudly: + * silently deleting operator data is never the right answer to a surprise. + */ +function dropActivityLedgerSchema(database: Database.Database): void { + const lookup = database.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?").pluck(); + const present = ACTIVITY_LEDGER_TABLES.filter((table) => lookup.get(table) !== undefined); + if (present.length === 0) return; + if (present.includes('activity_events')) { + const rows = database.prepare('SELECT COUNT(*) FROM activity_events').pluck().get() as number; + if (rows > 0) { + logger.warn( + `Refusing to drop the removed Activity ledger: activity_events holds ${rows} row(s). ` + + `Leaving ${present.join(', ')} in place — drop them by hand once those rows are exported.`, + ); + return; + } + } + for (const table of present) database.exec(`DROP TABLE ${table}`); +} + +export function initDb(): Database.Database { + if (db) return db; + // Fail fast on a near-full disk before any write — running out of space during + // a migration is the classic corruption trigger. + preflightSessionDiskSpace(); + // Todo classification is deliberately the first database operation. It opens + // an existing file read-only and refuses unsupported prerelease data before + // WAL mode, migrations, or any other schema write can occur. + const todoPreflight = preflightWorkItemsToleratingConcurrentInit(SESSIONS_DB); + // A v1 ledger is about to be rebuilt in place — keep a one-time pristine file + // copy (plus WAL/SHM sidecars) beside it so the operator can always roll back. + if (todoPreflight === 'v1') { + const backup = `${SESSIONS_DB}${WORK_ITEMS_BACKUP_SUFFIX}`; + if (!existsSync(backup)) { + copyFileSync(SESSIONS_DB, backup); + for (const suffix of ['-wal', '-shm'] as const) { + if (existsSync(`${SESSIONS_DB}${suffix}`)) copyFileSync(`${SESSIONS_DB}${suffix}`, `${backup}${suffix}`); + } + } + } + mkdirSync(path.dirname(SESSIONS_DB), { recursive: true }); + // Snapshot the existing DB before an upgrade migration mutates it (version-gated, + // so this runs once per upgrade, never on a steady-state boot). + maybeBackupBeforeMigration(); + const database = new Database(SESSIONS_DB); + db = database; + // Register the busy handler before WAL/DDL. Several gateway processes may + // discover the same fresh or upgraded home concurrently; initialization is + // serialized by SQLite instead of surfacing a transient SQLITE_BUSY. + database.pragma('busy_timeout = 10000'); + runSqliteBusyRetry(() => database.pragma('journal_mode = WAL')); + const initialize = database.transaction(() => { + database.exec(CREATE_TABLE); + database.exec(CREATE_MESSAGES_TABLE); + database.exec(CREATE_MESSAGES_INDEX); + database.exec(CREATE_META_TABLE); + migrateMessagesSchema(database); + database.exec(CREATE_MESSAGES_ORDER_INDEX); + // Partial-message index needs the `partial` column, added by migrateMessagesSchema above. + database.exec(CREATE_MESSAGES_PARTIAL_INDEX); + migrateFtsSchema(database); + // Pre-existing rows are intentionally NOT drained here. startGateway schedules + // the chunked backfill after server.listen(), and searchMessages also schedules + // it as a lazy fallback for non-gateway callers. The guarded AD/AU triggers above + // keep writes safe while that one-time backfill is incomplete. + migrateSessionsSchema(database); + database.exec(CREATE_SESSION_KEY_INDEX); + database.exec(CREATE_DELEGATION_IDEMPOTENCY_INDEX); + database.exec(CREATE_LAST_ACTIVITY_INDEX); + database.exec(CREATE_PARENT_INDEX); + database.exec(CREATE_WORKFLOW_RUN_INDEX); + database.exec(CREATE_STATUS_INDEX); + // The next public release is the first Todo release: create the clean model + // directly, or replace only a read-only-preflighted empty prerelease shape. + migrateWorkItemsSchema(database, todoPreflight); + database.exec(CREATE_WORK_ITEM_SESSION_INDEX); + dropActivityLedgerSchema(database); + database.exec(CREATE_QUEUE_ITEMS_TABLE); + migrateQueueItemsSchema(database); + migrateCallbackDeliveriesSchema(database); + database.exec(CREATE_FILES_TABLE); + database.exec(CREATE_CHAT_PINS_TABLE); + }); + try { + runImmediateMigrationWithRetry(initialize); + // Migration succeeded — stamp the version so the next boot at the same version + // skips the pre-migration backup. + recordDbVersion(); + return database; + } catch (error) { + database.close(); + db = undefined; + throw error; + } +} + +/** Test-only restart seam: close the process singleton so the next initDb() + * reopens the same sanitized home and reruns migrations. */ +export function __closeDbForTest(): void { + db?.close(); + db = undefined; +} diff --git a/packages/jinn/src/shared/sanitize.ts b/packages/jinn/src/shared/sanitize.ts new file mode 100644 index 000000000..d476c8d6d --- /dev/null +++ b/packages/jinn/src/shared/sanitize.ts @@ -0,0 +1,22 @@ +// Control-byte hygiene for untrusted text. Two deliberately different policies: +// repair for display/search input, reject for security-critical path params. + +/** Replace NUL and other non-printing control bytes with spaces (GRS-020a-fix + * finding 2). Shared by the FTS sanitizer and the search routes so hostile + * encoded input (%00 etc.) yields a normal result everywhere, never a 500. */ +export function stripControlChars(value: string): string { + // eslint-disable-next-line no-control-regex + return value.replace(/[\u0000-\u001f\u007f]/g, ' '); +} + +/** True if the string carries a NUL or other non-printing control byte. The + * REJECT-don't-strip gate for security-critical PATH params (GRS-020b-fix): + * {@link stripControlChars} would silently REPAIR a `%00`-tampered path into a + * valid one, so the knowledge read surface rejects on the raw param instead. */ +export function hasControlBytes(value: string): boolean { + for (let i = 0; i < value.length; i++) { + const c = value.charCodeAt(i); + if (c <= 0x1f || c === 0x7f) return true; + } + return false; +} diff --git a/packages/jinn/src/work-items/__tests__/approval-choices.test.ts b/packages/jinn/src/work-items/__tests__/approval-choices.test.ts index adc503030..2acf83581 100644 --- a/packages/jinn/src/work-items/__tests__/approval-choices.test.ts +++ b/packages/jinn/src/work-items/__tests__/approval-choices.test.ts @@ -10,18 +10,15 @@ process.env.JINN_HOME = tmp; type Store = typeof import("../store.js"); type Approvals = typeof import("../approvals.js"); -type Reg = typeof import("../../sessions/registry.js"); let store: Store; let approvals: Approvals; -let reg: Reg; const VARIANTS = ["variant-a", "variant-b", "variant-c"]; beforeAll(async () => { store = await import("../store.js"); approvals = await import("../approvals.js"); - reg = await import("../../sessions/registry.js"); - reg.initDb(); + (await import("../../shared/db.js")).initDb(); }); afterEach(() => { diff --git a/packages/jinn/src/work-items/__tests__/approvals-atomicity.test.ts b/packages/jinn/src/work-items/__tests__/approvals-atomicity.test.ts index 55708ea17..af16c5fb1 100644 --- a/packages/jinn/src/work-items/__tests__/approvals-atomicity.test.ts +++ b/packages/jinn/src/work-items/__tests__/approvals-atomicity.test.ts @@ -31,7 +31,7 @@ let approvals: Approvals; beforeAll(async () => { store = await import("../store.js"); approvals = await import("../approvals.js"); - (await import("../../sessions/registry.js")).initDb(); + (await import("../../shared/db.js")).initDb(); }); function pendingInReview(id: string) { diff --git a/packages/jinn/src/work-items/__tests__/approvals.test.ts b/packages/jinn/src/work-items/__tests__/approvals.test.ts index 9e9e6022b..1b7789c58 100644 --- a/packages/jinn/src/work-items/__tests__/approvals.test.ts +++ b/packages/jinn/src/work-items/__tests__/approvals.test.ts @@ -7,22 +7,20 @@ import path from "node:path"; // keep the suite off the live DB. Set BEFORE importing the store. const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "jinn-wi-appr-")); process.env.JINN_HOME = tmp; +const dbModule = await import("../../shared/db.js"); type Store = typeof import("../store.js"); type Approvals = typeof import("../approvals.js"); -type Reg = typeof import("../../sessions/registry.js"); type ApprovalAuthority = typeof import("../../gateway/approval-authority.js"); let store: Store; let approvals: Approvals; -let reg: Reg; let approvalAuthority: ApprovalAuthority; beforeAll(async () => { store = await import("../store.js"); approvals = await import("../approvals.js"); - reg = await import("../../sessions/registry.js"); approvalAuthority = await import("../../gateway/approval-authority.js"); - reg.initDb(); + (await import("../../shared/db.js")).initDb(); }); function kinds(id: string): string[] { @@ -203,7 +201,7 @@ describe("decideWorkItemApproval — native consequence rules", () => { describe("approvals off-row — writes land in work_item_approvals, columns stay frozen", () => { function rawColumns(id: string): Record { - return reg + return dbModule .initDb() .prepare( `SELECT approval_state, approval_request, approval_ref, approval_target, @@ -299,7 +297,7 @@ describe("approvals off-row — writes land in work_item_approvals, columns stay const item = store.createWorkItem({ title: "Unique pending", status: "backlog", source: "human" }); approvals.requestApproval(item.id, { request: "first", target: null }); expect(() => - reg + dbModule .initDb() .prepare( `INSERT INTO work_item_approvals (id, work_item_id, state, request, requested_by, requested_at) diff --git a/packages/jinn/src/work-items/__tests__/attachments.test.ts b/packages/jinn/src/work-items/__tests__/attachments.test.ts index a9f751471..2e9e103a3 100644 --- a/packages/jinn/src/work-items/__tests__/attachments.test.ts +++ b/packages/jinn/src/work-items/__tests__/attachments.test.ts @@ -29,7 +29,7 @@ beforeAll(async () => { comments = await import("../comments.js"); attachments = await import("../attachments.js"); migrate = await import("../migrate.js"); - db = (await import("../../sessions/registry.js")).initDb(); + db = (await import("../../shared/db.js")).initDb(); }); function stage(content: Buffer | string): string { diff --git a/packages/jinn/src/work-items/__tests__/board-legality-parity.test.ts b/packages/jinn/src/work-items/__tests__/board-legality-parity.test.ts index cabe23c7e..8e8237f4c 100644 --- a/packages/jinn/src/work-items/__tests__/board-legality-parity.test.ts +++ b/packages/jinn/src/work-items/__tests__/board-legality-parity.test.ts @@ -21,11 +21,9 @@ process.env.JINN_HOME = tmp; type Store = typeof import("../store.js"); type Transitions = typeof import("../transitions.js"); -type Reg = typeof import("../../sessions/registry.js"); let store: Store; let tr: Transitions; -let reg: Reg; const FIXTURE_PATH = path.resolve( path.dirname(fileURLToPath(import.meta.url)), @@ -45,8 +43,7 @@ const fixture = JSON.parse(fs.readFileSync(FIXTURE_PATH, "utf8")) as EdgesFixtur beforeAll(async () => { store = await import("../store.js"); tr = await import("../transitions.js"); - reg = await import("../../sessions/registry.js"); - reg.initDb(); + (await import("../../shared/db.js")).initDb(); }); type Status = import("../store.js").WorkItemStatus; diff --git a/packages/jinn/src/work-items/__tests__/comments.test.ts b/packages/jinn/src/work-items/__tests__/comments.test.ts index 61be8e3a4..fd822fa61 100644 --- a/packages/jinn/src/work-items/__tests__/comments.test.ts +++ b/packages/jinn/src/work-items/__tests__/comments.test.ts @@ -20,7 +20,7 @@ beforeAll(async () => { store = await import("../store.js"); comments = await import("../comments.js"); migrate = await import("../migrate.js"); - (await import("../../sessions/registry.js")).initDb(); + (await import("../../shared/db.js")).initDb(); }); describe("addComment", () => { diff --git a/packages/jinn/src/work-items/__tests__/department-registry-writes.test.ts b/packages/jinn/src/work-items/__tests__/department-registry-writes.test.ts index c15d6bc5a..199a6b491 100644 --- a/packages/jinn/src/work-items/__tests__/department-registry-writes.test.ts +++ b/packages/jinn/src/work-items/__tests__/department-registry-writes.test.ts @@ -23,7 +23,7 @@ beforeAll(async () => { transitions = await import("../transitions.js"); departments = await import("../departments.js"); migrate = await import("../migrate.js"); - db = (await import("../../sessions/registry.js")).initDb(); + db = (await import("../../shared/db.js")).initDb(); }); function registeredSlugs(): string[] { diff --git a/packages/jinn/src/work-items/__tests__/labels.test.ts b/packages/jinn/src/work-items/__tests__/labels.test.ts index 72d0d93d9..1937f0fba 100644 --- a/packages/jinn/src/work-items/__tests__/labels.test.ts +++ b/packages/jinn/src/work-items/__tests__/labels.test.ts @@ -17,7 +17,7 @@ let db: import("better-sqlite3").Database; beforeAll(async () => { store = await import("../store.js"); labels = await import("../labels.js"); - db = (await import("../../sessions/registry.js")).initDb(); + db = (await import("../../shared/db.js")).initDb(); }); describe("createLabel", () => { diff --git a/packages/jinn/src/work-items/__tests__/list-limit.test.ts b/packages/jinn/src/work-items/__tests__/list-limit.test.ts index cb16b0fa9..ed8945ed9 100644 --- a/packages/jinn/src/work-items/__tests__/list-limit.test.ts +++ b/packages/jinn/src/work-items/__tests__/list-limit.test.ts @@ -6,16 +6,14 @@ import path from "node:path"; // Throwaway DB before importing the registry (SESSIONS_DB resolves from JINN_HOME). const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "jinn-wi-limit-")); process.env.JINN_HOME = tmp; +const dbModule = await import("../../shared/db.js"); type Store = typeof import("../store.js"); -type Reg = typeof import("../../sessions/registry.js"); let store: Store; -let reg: Reg; beforeAll(async () => { store = await import("../store.js"); - reg = await import("../../sessions/registry.js"); - reg.initDb(); + dbModule.initDb(); }); describe("listWorkItems SQL LIMIT", () => { @@ -103,7 +101,7 @@ describe("listWorkItems SQL LIMIT", () => { department: "filter-department", source: "connector", }); - const db = reg.initDb(); + const db = dbModule.initDb(); db.prepare("UPDATE work_items SET updated_at = ? WHERE id = ?").run("2031-04-10T12:00:00.000Z", match.id); db.prepare("UPDATE work_items SET updated_at = ? WHERE id = ?").run("2031-04-11T12:00:00.000Z", bodyMatch.id); db.prepare("UPDATE work_items SET updated_at = ? WHERE id = ?").run("2031-05-01T12:00:00.000Z", outsideWindow.id); @@ -131,7 +129,7 @@ describe("listWorkItems SQL LIMIT", () => { }); it("the ordered read is index-backed (LIMIT does not sort the whole table)", () => { - const db = reg.initDb(); + const db = dbModule.initDb(); const plan = db .prepare( "EXPLAIN QUERY PLAN SELECT * FROM work_items WHERE status = ? ORDER BY (rank IS NULL) ASC, rank ASC, updated_at DESC, created_at DESC, id ASC LIMIT ? OFFSET ?", @@ -143,7 +141,7 @@ describe("listWorkItems SQL LIMIT", () => { }); it("the default no-filter page is index-backed", () => { - const db = reg.initDb(); + const db = dbModule.initDb(); const plan = db .prepare( "EXPLAIN QUERY PLAN SELECT * FROM work_items ORDER BY (rank IS NULL) ASC, rank ASC, updated_at DESC, created_at DESC, id ASC LIMIT ? OFFSET ?", diff --git a/packages/jinn/src/work-items/__tests__/optimistic-concurrency.test.ts b/packages/jinn/src/work-items/__tests__/optimistic-concurrency.test.ts index 73cb0e0c0..cfd11c0ae 100644 --- a/packages/jinn/src/work-items/__tests__/optimistic-concurrency.test.ts +++ b/packages/jinn/src/work-items/__tests__/optimistic-concurrency.test.ts @@ -131,12 +131,12 @@ describe("conditional Todo metadata updates", () => { ); }); - it("persists only a digest of the caller idempotency key", () => { + it("persists only a digest of the caller idempotency key", async () => { const item = store.createWorkItem({ title: "digest receipt" }); const key = "todo:edit:caller-private-key"; conditional(item.id, { title: "digested" }, item.version, key); - const receipt = registry.initDb().prepare("SELECT * FROM work_item_edit_receipts ORDER BY created_at DESC LIMIT 1").get() as Record; + const receipt = (await import("../../shared/db.js")).initDb().prepare("SELECT * FROM work_item_edit_receipts ORDER BY created_at DESC LIMIT 1").get() as Record; expect(receipt.key_digest).toMatch(/^[a-f0-9]{64}$/); expect(JSON.stringify(receipt)).not.toContain(key); }); diff --git a/packages/jinn/src/work-items/__tests__/phase-a-identity.test.ts b/packages/jinn/src/work-items/__tests__/phase-a-identity.test.ts index 817e5d1ec..0f20b185e 100644 --- a/packages/jinn/src/work-items/__tests__/phase-a-identity.test.ts +++ b/packages/jinn/src/work-items/__tests__/phase-a-identity.test.ts @@ -16,7 +16,7 @@ let db: import("better-sqlite3").Database; beforeAll(async () => { store = await import("../store.js"); registry = await import("../../sessions/registry.js"); - db = registry.initDb(); + db = (await import("../../shared/db.js")).initDb(); }); describe("Phase A Todo identity", () => { diff --git a/packages/jinn/src/work-items/__tests__/reconcile.test.ts b/packages/jinn/src/work-items/__tests__/reconcile.test.ts index 9dcb7c1e9..64875dbcf 100644 --- a/packages/jinn/src/work-items/__tests__/reconcile.test.ts +++ b/packages/jinn/src/work-items/__tests__/reconcile.test.ts @@ -10,11 +10,9 @@ process.env.JINN_HOME = tmp; type Store = typeof import("../store.js"); type Reconcile = typeof import("../reconcile.js"); -type Reg = typeof import("../../sessions/registry.js"); let store: Store; let reconcile: Reconcile; -let reg: Reg; let db: import("better-sqlite3").Database; type SessionStatus = "idle" | "running" | "error" | "waiting" | "interrupted"; @@ -48,8 +46,7 @@ function phaseSession(id: string, workItemId: string, status: SessionStatus, at: beforeAll(async () => { store = await import("../store.js"); reconcile = await import("../reconcile.js"); - reg = await import("../../sessions/registry.js"); - db = reg.initDb(); + db = (await import("../../shared/db.js")).initDb(); }); describe("deriveWorkItemStatus — pure truth table (GRS-021a elevated vocabulary)", () => { diff --git a/packages/jinn/src/work-items/__tests__/relations.test.ts b/packages/jinn/src/work-items/__tests__/relations.test.ts index 48838e888..2c4ec9e0b 100644 --- a/packages/jinn/src/work-items/__tests__/relations.test.ts +++ b/packages/jinn/src/work-items/__tests__/relations.test.ts @@ -21,7 +21,7 @@ beforeAll(async () => { store = await import("../store.js"); relations = await import("../relations.js"); migrate = await import("../migrate.js"); - db = (await import("../../sessions/registry.js")).initDb(); + db = (await import("../../shared/db.js")).initDb(); }); function setStatus(id: string, status: string): void { diff --git a/packages/jinn/src/work-items/__tests__/rollup-gate.test.ts b/packages/jinn/src/work-items/__tests__/rollup-gate.test.ts index ec899a681..0aa8add40 100644 --- a/packages/jinn/src/work-items/__tests__/rollup-gate.test.ts +++ b/packages/jinn/src/work-items/__tests__/rollup-gate.test.ts @@ -17,7 +17,7 @@ beforeAll(async () => { store = await import("../store.js"); transitions = await import("../transitions.js"); approvals = await import("../approvals.js"); - (await import("../../sessions/registry.js")).initDb(); + (await import("../../shared/db.js")).initDb(); }); describe("roll-up close gate", () => { diff --git a/packages/jinn/src/work-items/__tests__/store.test.ts b/packages/jinn/src/work-items/__tests__/store.test.ts index 21b4a05b8..9b7653d95 100644 --- a/packages/jinn/src/work-items/__tests__/store.test.ts +++ b/packages/jinn/src/work-items/__tests__/store.test.ts @@ -25,7 +25,7 @@ function insertSession(id: string, engine = "claude"): void { beforeAll(async () => { store = await import("../store.js"); reg = await import("../../sessions/registry.js"); - db = reg.initDb(); + db = (await import("../../shared/db.js")).initDb(); }); describe("work-item store — schema", () => { diff --git a/packages/jinn/src/work-items/__tests__/subtasks.test.ts b/packages/jinn/src/work-items/__tests__/subtasks.test.ts index d7d8c7849..326150ea4 100644 --- a/packages/jinn/src/work-items/__tests__/subtasks.test.ts +++ b/packages/jinn/src/work-items/__tests__/subtasks.test.ts @@ -14,7 +14,7 @@ let transitions: Transitions; beforeAll(async () => { store = await import("../store.js"); transitions = await import("../transitions.js"); - (await import("../../sessions/registry.js")).initDb(); + (await import("../../shared/db.js")).initDb(); }); describe("sub-tasks", () => { diff --git a/packages/jinn/src/work-items/__tests__/transitions.test.ts b/packages/jinn/src/work-items/__tests__/transitions.test.ts index 8cfe34da9..c41b440e3 100644 --- a/packages/jinn/src/work-items/__tests__/transitions.test.ts +++ b/packages/jinn/src/work-items/__tests__/transitions.test.ts @@ -9,18 +9,15 @@ process.env.JINN_HOME = tmp; type Store = typeof import("../store.js"); type Transitions = typeof import("../transitions.js"); -type Reg = typeof import("../../sessions/registry.js"); let store: Store; let tr: Transitions; -let reg: Reg; let db: import("better-sqlite3").Database; beforeAll(async () => { store = await import("../store.js"); tr = await import("../transitions.js"); - reg = await import("../../sessions/registry.js"); - db = reg.initDb(); + db = (await import("../../shared/db.js")).initDb(); }); const mk = (status: Store["createWorkItem"] extends (i: infer I) => unknown ? (I extends { status?: infer S } ? S : never) : never, extra: Partial[0]> = {}) => diff --git a/packages/jinn/src/work-items/__tests__/tree.test.ts b/packages/jinn/src/work-items/__tests__/tree.test.ts index 605fadb85..cd49df619 100644 --- a/packages/jinn/src/work-items/__tests__/tree.test.ts +++ b/packages/jinn/src/work-items/__tests__/tree.test.ts @@ -11,7 +11,7 @@ let store: Store; beforeAll(async () => { store = await import("../store.js"); - (await import("../../sessions/registry.js")).initDb(); + (await import("../../shared/db.js")).initDb(); }); describe("getWorkItemTree", () => { diff --git a/packages/jinn/src/work-items/__tests__/version-mutations.test.ts b/packages/jinn/src/work-items/__tests__/version-mutations.test.ts index 8ae165edf..c7b73ff16 100644 --- a/packages/jinn/src/work-items/__tests__/version-mutations.test.ts +++ b/packages/jinn/src/work-items/__tests__/version-mutations.test.ts @@ -24,7 +24,7 @@ beforeAll(async () => { transitions = await import("../transitions.js"); approvals = await import("../approvals.js"); reconcile = await import("../reconcile.js"); - registry.initDb(); + (await import("../../shared/db.js")).initDb(); }); describe("Todo version mutation sensitivity", () => { diff --git a/packages/jinn/src/work-items/approval-rows.ts b/packages/jinn/src/work-items/approval-rows.ts index 2146e0cd7..1dcc6467b 100644 --- a/packages/jinn/src/work-items/approval-rows.ts +++ b/packages/jinn/src/work-items/approval-rows.ts @@ -1,4 +1,4 @@ -import { initDb } from '../sessions/registry.js'; +import { initDb } from '../shared/db.js'; import { parseTodoId } from './id.js'; import type { ApprovalState, ApprovalTargetKind } from './store.js'; diff --git a/packages/jinn/src/work-items/approvals.ts b/packages/jinn/src/work-items/approvals.ts index 4ffff1f40..baddd6bd2 100644 --- a/packages/jinn/src/work-items/approvals.ts +++ b/packages/jinn/src/work-items/approvals.ts @@ -1,5 +1,5 @@ import { randomUUID } from 'node:crypto'; -import { initDb } from '../sessions/registry.js'; +import { initDb } from '../shared/db.js'; import { resolveApprovalRouteTarget, resolveRootApprovalTarget } from '../gateway/approval-authority.js'; import { parseTodoApprovalRef } from '../workflows/todo-approval-ref.js'; import { currentApproval, type WorkItemApproval } from './approval-rows.js'; diff --git a/packages/jinn/src/work-items/attachments.ts b/packages/jinn/src/work-items/attachments.ts index 8203c9f67..c932f7816 100644 --- a/packages/jinn/src/work-items/attachments.ts +++ b/packages/jinn/src/work-items/attachments.ts @@ -1,7 +1,7 @@ import { createHash, randomUUID } from 'node:crypto'; import fs from 'node:fs'; import path from 'node:path'; -import { initDb } from '../sessions/registry.js'; +import { initDb } from '../shared/db.js'; import { ATTACHMENTS_DIR } from '../shared/paths.js'; import { parseTodoId } from './id.js'; import { appendWorkItemEvent } from './store.js'; diff --git a/packages/jinn/src/work-items/comments.ts b/packages/jinn/src/work-items/comments.ts index 83b216631..eb6fa503d 100644 --- a/packages/jinn/src/work-items/comments.ts +++ b/packages/jinn/src/work-items/comments.ts @@ -1,5 +1,5 @@ import { randomUUID } from 'node:crypto'; -import { initDb } from '../sessions/registry.js'; +import { initDb } from '../shared/db.js'; import { parseTodoId } from './id.js'; import { appendWorkItemEvent } from './store.js'; diff --git a/packages/jinn/src/work-items/labels.ts b/packages/jinn/src/work-items/labels.ts index 523859b15..dc6f032c1 100644 --- a/packages/jinn/src/work-items/labels.ts +++ b/packages/jinn/src/work-items/labels.ts @@ -1,5 +1,5 @@ import { randomUUID } from 'node:crypto'; -import { initDb } from '../sessions/registry.js'; +import { initDb } from '../shared/db.js'; import { parseTodoId } from './id.js'; import { appendWorkItemEvent } from './store.js'; diff --git a/packages/jinn/src/work-items/relations.ts b/packages/jinn/src/work-items/relations.ts index fa5e6caeb..502eabf8c 100644 --- a/packages/jinn/src/work-items/relations.ts +++ b/packages/jinn/src/work-items/relations.ts @@ -1,4 +1,4 @@ -import { initDb } from '../sessions/registry.js'; +import { initDb } from '../shared/db.js'; import { parseTodoId } from './id.js'; import { appendWorkItemEvent, type WorkItemStatus } from './store.js'; diff --git a/packages/jinn/src/work-items/store.ts b/packages/jinn/src/work-items/store.ts index 918220c39..818127fda 100644 --- a/packages/jinn/src/work-items/store.ts +++ b/packages/jinn/src/work-items/store.ts @@ -1,6 +1,6 @@ import { createHash, randomUUID } from 'node:crypto'; import fs from 'node:fs'; -import { initDb } from '../sessions/registry.js'; +import { initDb } from '../shared/db.js'; import { loadConfig } from '../shared/config.js'; import { CONFIG_PATH } from '../shared/paths.js'; import { parseTodoId, resolveTodoIdPrefix } from './id.js'; diff --git a/packages/jinn/src/work-items/transitions.ts b/packages/jinn/src/work-items/transitions.ts index c68bc64f2..6c309776d 100644 --- a/packages/jinn/src/work-items/transitions.ts +++ b/packages/jinn/src/work-items/transitions.ts @@ -1,4 +1,5 @@ -import { initDb, listSessionsByWorkItem } from '../sessions/registry.js'; +import { listSessionsByWorkItem } from '../sessions/registry.js'; +import { initDb } from '../shared/db.js'; import { appendWorkItemEvent, effectiveMaxRounds, diff --git a/packages/jinn/src/work-items/workflow-event-feed.ts b/packages/jinn/src/work-items/workflow-event-feed.ts index 925ace794..cf6ff4610 100644 --- a/packages/jinn/src/work-items/workflow-event-feed.ts +++ b/packages/jinn/src/work-items/workflow-event-feed.ts @@ -1,5 +1,5 @@ import { randomUUID } from 'node:crypto'; -import { initDb } from '../sessions/registry.js'; +import { initDb } from '../shared/db.js'; import type { WorkItemSource, WorkItemStatus } from './store.js'; import { getWorkItemLabels } from './labels.js'; import { isTodoId } from './id.js'; diff --git a/packages/jinn/src/workflows/__tests__/workflow-recovery.test.ts b/packages/jinn/src/workflows/__tests__/workflow-recovery.test.ts index cddcbf2a5..5e8ae7785 100644 --- a/packages/jinn/src/workflows/__tests__/workflow-recovery.test.ts +++ b/packages/jinn/src/workflows/__tests__/workflow-recovery.test.ts @@ -103,7 +103,7 @@ beforeEach(() => { afterEach(() => { service.dispose(); vi.useRealTimers(); database.close(); fs.rmSync(root, { recursive: true, force: true }); }); afterAll(async () => { const registry = await import("../../sessions/registry.js"); - registry.__closeDbForTest(); + (await import("../../shared/db.js")).__closeDbForTest(); fs.rmSync(sessionHome, { recursive: true, force: true }); }); describe("Workflow retry, cancellation, and restart recovery", () => { @@ -377,7 +377,7 @@ describe("Workflow retry, cancellation, and restart recovery", () => { ]); expect(await service.recover(now.toISOString())).toEqual({ resumedRuns: 0, resumedWaits: 0 }); expect(service.getRun(definition.id, created.id)?.attempts).toHaveLength(1); - expect(registry.initDb().prepare("SELECT COUNT(*) AS count FROM sessions WHERE session_key = ?").get(key)) + expect((await import("../../shared/db.js")).initDb().prepare("SELECT COUNT(*) AS count FROM sessions WHERE session_key = ?").get(key)) .toEqual({ count: 1 }); expect(engine.calls).toBe(1); });