From ad5205f186cee19d18c232b0a548d0cd61b60266 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Thu, 4 Jun 2026 22:52:20 -0700 Subject: [PATCH] feat(core): add cli_sessions table and CliSessionStore (U1) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/__tests__/cli-session-store.test.ts | 211 +++++++++++ .../core/src/__tests__/db-migrate.test.ts | 79 +++- packages/core/src/__tests__/db.test.ts | 42 +-- .../core/src/__tests__/goals-schema.test.ts | 2 +- .../core/src/__tests__/insight-store.test.ts | 10 +- .../__tests__/merge-request-record.test.ts | 2 +- .../core/src/__tests__/mission-store.test.ts | 2 +- packages/core/src/__tests__/run-audit.test.ts | 2 +- .../src/__tests__/store-merge-queue.test.ts | 2 +- .../core/src/__tests__/task-documents.test.ts | 2 +- packages/core/src/cli-session-store.ts | 336 ++++++++++++++++++ packages/core/src/cli-session-types.ts | 196 ++++++++++ packages/core/src/db.ts | 55 ++- packages/core/src/index.ts | 19 + 14 files changed, 919 insertions(+), 41 deletions(-) create mode 100644 packages/core/src/__tests__/cli-session-store.test.ts create mode 100644 packages/core/src/cli-session-store.ts create mode 100644 packages/core/src/cli-session-types.ts diff --git a/packages/core/src/__tests__/cli-session-store.test.ts b/packages/core/src/__tests__/cli-session-store.test.ts new file mode 100644 index 0000000000..41583cbbed --- /dev/null +++ b/packages/core/src/__tests__/cli-session-store.test.ts @@ -0,0 +1,211 @@ +import { describe, it, expect, beforeAll, beforeEach, afterAll } from "vitest"; +import { CliSessionStore } from "../cli-session-store.js"; +import { Database } from "../db.js"; +import { mkdtempSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { rm } from "node:fs/promises"; + +function makeTmpDir(): string { + return mkdtempSync(join(tmpdir(), "kb-cli-session-store-test-")); +} + +describe("CliSessionStore", () => { + let tmpDir: string; + let fusionDir: string; + let db: Database; + let store: CliSessionStore; + + beforeAll(() => { + tmpDir = makeTmpDir(); + fusionDir = join(tmpDir, ".fusion"); + db = new Database(fusionDir, { inMemory: true }); + db.init(); + store = new CliSessionStore(fusionDir, db); + }); + + beforeEach(() => { + db.exec("DELETE FROM cli_sessions"); + store.removeAllListeners(); + }); + + afterAll(async () => { + db.close(); + await rm(tmpDir, { recursive: true, force: true }); + }); + + it("creates and reads a session record", () => { + const created = store.createSession({ + taskId: "FN-100", + purpose: "execute", + projectId: "proj-1", + adapterId: "claude-local", + worktreePath: "/tmp/wt/FN-100", + autonomyPosture: { autoApprove: true, maxResumeAttempts: 3 }, + }); + + expect(created.id).toMatch(/^cli-/); + expect(created.agentState).toBe("starting"); + expect(created.terminationReason).toBeNull(); + expect(created.resumeAttempts).toBe(0); + expect(created.chatSessionId).toBeNull(); + expect(created.autonomyPosture).toEqual({ autoApprove: true, maxResumeAttempts: 3 }); + + const fetched = store.getSession(created.id); + expect(fetched).toEqual(created); + }); + + it("persists state transitions", () => { + const s = store.createSession({ + taskId: "FN-101", + purpose: "planning", + projectId: "proj-1", + adapterId: "codex-local", + }); + + const states = ["ready", "busy", "waitingOnInput", "busy", "done"] as const; + for (const state of states) { + const updated = store.updateSession(s.id, { agentState: state }); + expect(updated?.agentState).toBe(state); + // Persisted, not just returned. + expect(store.getSession(s.id)?.agentState).toBe(state); + } + }); + + it("round-trips the native session id", () => { + const s = store.createSession({ + taskId: "FN-102", + purpose: "execute", + projectId: "proj-1", + adapterId: "claude-local", + }); + expect(s.nativeSessionId).toBeNull(); + + store.updateSession(s.id, { nativeSessionId: "native-abc-123" }); + expect(store.getSession(s.id)?.nativeSessionId).toBe("native-abc-123"); + + // Reopen via a fresh store instance on the same DB to prove durability. + const reopened = new CliSessionStore(fusionDir, db); + expect(reopened.getSession(s.id)?.nativeSessionId).toBe("native-abc-123"); + }); + + it("updates terminationReason and resumeAttempts atomically with state", () => { + const s = store.createSession({ + taskId: "FN-103", + purpose: "validator", + projectId: "proj-1", + adapterId: "claude-local", + }); + + const updated = store.updateSession(s.id, { + agentState: "dead", + terminationReason: "crashed", + resumeAttempts: 2, + }); + + expect(updated?.agentState).toBe("dead"); + expect(updated?.terminationReason).toBe("crashed"); + expect(updated?.resumeAttempts).toBe(2); + + const persisted = store.getSession(s.id)!; + expect(persisted.agentState).toBe("dead"); + expect(persisted.terminationReason).toBe("crashed"); + expect(persisted.resumeAttempts).toBe(2); + }); + + it("clears terminationReason when set back to null", () => { + const s = store.createSession({ + taskId: "FN-104", + purpose: "execute", + projectId: "proj-1", + adapterId: "claude-local", + agentState: "dead", + terminationReason: "killed", + }); + expect(s.terminationReason).toBe("killed"); + + store.updateSession(s.id, { agentState: "starting", terminationReason: null }); + const persisted = store.getSession(s.id)!; + expect(persisted.terminationReason).toBeNull(); + expect(persisted.agentState).toBe("starting"); + }); + + it("queries sessions by task and by chat entity", () => { + store.createSession({ taskId: "FN-200", purpose: "execute", projectId: "p", adapterId: "a" }); + store.createSession({ taskId: "FN-200", purpose: "validator", projectId: "p", adapterId: "a" }); + store.createSession({ taskId: "FN-201", purpose: "execute", projectId: "p", adapterId: "a" }); + store.createSession({ chatSessionId: "chat-xyz", purpose: "chat", projectId: "p", adapterId: "a" }); + + expect(store.listByTask("FN-200")).toHaveLength(2); + expect(store.listByTask("FN-201")).toHaveLength(1); + expect(store.listByTask("FN-999")).toHaveLength(0); + + const chatSessions = store.listByChatSession("chat-xyz"); + expect(chatSessions).toHaveLength(1); + expect(chatSessions[0].purpose).toBe("chat"); + }); + + it("filters by projectId and agentState", () => { + store.createSession({ taskId: "FN-300", purpose: "execute", projectId: "pA", adapterId: "a", agentState: "busy" }); + store.createSession({ taskId: "FN-301", purpose: "execute", projectId: "pA", adapterId: "a", agentState: "done" }); + store.createSession({ taskId: "FN-302", purpose: "execute", projectId: "pB", adapterId: "a", agentState: "busy" }); + + expect(store.listSessions({ projectId: "pA" })).toHaveLength(2); + expect(store.listSessions({ projectId: "pA", agentState: "busy" })).toHaveLength(1); + expect(store.listSessions({ agentState: "busy" })).toHaveLength(2); + }); + + it("rejects an invalid agent state at the store boundary", () => { + const s = store.createSession({ + taskId: "FN-400", + purpose: "execute", + projectId: "p", + adapterId: "a", + }); + + expect(() => + // @ts-expect-error invalid state value rejected at runtime + store.updateSession(s.id, { agentState: "bogus" }), + ).toThrow(/Invalid CLI agent state/); + + expect(() => + // @ts-expect-error invalid state value rejected at runtime + store.createSession({ purpose: "execute", projectId: "p", adapterId: "a", agentState: "nope" }), + ).toThrow(/Invalid CLI agent state/); + + // The original record was untouched by the failed update. + expect(store.getSession(s.id)?.agentState).toBe("starting"); + }); + + it("rejects an invalid purpose and termination reason at the store boundary", () => { + expect(() => + // @ts-expect-error invalid purpose rejected at runtime + store.createSession({ purpose: "wat", projectId: "p", adapterId: "a" }), + ).toThrow(/Invalid CLI session purpose/); + + const s = store.createSession({ taskId: "FN-401", purpose: "execute", projectId: "p", adapterId: "a" }); + expect(() => + // @ts-expect-error invalid termination reason rejected at runtime + store.updateSession(s.id, { terminationReason: "exploded" }), + ).toThrow(/Invalid CLI termination reason/); + }); + + it("emits create/update/delete events", () => { + const events: string[] = []; + store.on("cli-session:created", () => events.push("created")); + store.on("cli-session:updated", () => events.push("updated")); + store.on("cli-session:deleted", () => events.push("deleted")); + + const s = store.createSession({ taskId: "FN-500", purpose: "ce", projectId: "p", adapterId: "a" }); + store.updateSession(s.id, { agentState: "ready" }); + expect(store.deleteSession(s.id)).toBe(true); + expect(store.getSession(s.id)).toBeUndefined(); + + expect(events).toEqual(["created", "updated", "deleted"]); + }); + + it("returns undefined when updating a missing session and false when deleting one", () => { + expect(store.updateSession("cli-missing", { agentState: "ready" })).toBeUndefined(); + expect(store.deleteSession("cli-missing")).toBe(false); + }); +}); diff --git a/packages/core/src/__tests__/db-migrate.test.ts b/packages/core/src/__tests__/db-migrate.test.ts index b36c04e128..30407179a4 100644 --- a/packages/core/src/__tests__/db-migrate.test.ts +++ b/packages/core/src/__tests__/db-migrate.test.ts @@ -715,7 +715,7 @@ describe("schema migration", () => { const row = db.prepare("SELECT deletedAt FROM tasks WHERE id = 'FN-legacy'").get() as { deletedAt: string | null }; expect(row.deletedAt).toBeNull(); - expect(db.getSchemaVersion()).toBe(108); + expect(db.getSchemaVersion()).toBe(109); db.close(); }); @@ -748,7 +748,7 @@ describe("schema migration", () => { { id: "WS-001", mode: "prompt", gateMode: "advisory" }, { id: "WS-002", mode: "script", gateMode: "advisory" }, ]); - expect(db.getSchemaVersion()).toBe(108); + expect(db.getSchemaVersion()).toBe(109); db.close(); }); @@ -798,7 +798,7 @@ describe("schema migration", () => { reviewerContextRetryCount: 0, reviewerFallbackRetryCount: 0, }); - expect(db.getSchemaVersion()).toBe(108); + expect(db.getSchemaVersion()).toBe(109); db.close(); }); @@ -827,7 +827,7 @@ describe("schema migration", () => { const columns = db.prepare("PRAGMA table_info(milestones)").all() as Array<{ name: string }>; expect(columns.map((column) => column.name)).toContain("acceptanceCriteria"); - expect(db.getSchemaVersion()).toBe(108); + expect(db.getSchemaVersion()).toBe(109); db.close(); }); @@ -868,7 +868,7 @@ describe("schema migration", () => { const missionColumns = db.prepare("PRAGMA table_info(missions)").all() as Array<{ name: string }>; expect(missionColumns.map((column) => column.name)).toContain("autoMerge"); - expect(db.getSchemaVersion()).toBe(108); + expect(db.getSchemaVersion()).toBe(109); db.close(); }); @@ -902,7 +902,7 @@ describe("schema migration", () => { { id: "WS-002", mode: "script", enabled: 1, gateMode: "advisory" }, { id: "WS-003", mode: "prompt", enabled: 0, gateMode: "advisory" }, ]); - expect(db.getSchemaVersion()).toBe(108); + expect(db.getSchemaVersion()).toBe(109); db.close(); }); @@ -939,7 +939,7 @@ describe("schema migration", () => { const indexes = db.prepare("PRAGMA index_list(mission_goals)").all() as Array<{ name: string }>; expect(indexes.some((index) => index.name === "idxMissionGoalsGoalId")).toBe(true); - expect(db.getSchemaVersion()).toBe(108); + expect(db.getSchemaVersion()).toBe(109); db.close(); }); @@ -1000,7 +1000,70 @@ describe("schema migration", () => { expect(customFieldsColumn).toBeDefined(); expect(customFieldsColumn?.dflt_value).toBe("'{}'"); - expect(db.getSchemaVersion()).toBe(108); + expect(db.getSchemaVersion()).toBe(109); + db.close(); + }); + + it("adds cli_sessions table + indexes when migrating from schema version 108", () => { + const db = new Database(fusionDir); + db.exec("CREATE TABLE IF NOT EXISTS __meta (key TEXT PRIMARY KEY, value TEXT)"); + db.exec("INSERT INTO __meta (key, value) VALUES ('schemaVersion', '108')"); + db.exec("INSERT INTO __meta (key, value) VALUES ('lastModified', '1000')"); + db.exec(` + CREATE TABLE IF NOT EXISTS tasks ( + id TEXT PRIMARY KEY, + description TEXT NOT NULL, + "column" TEXT NOT NULL, + createdAt TEXT NOT NULL, + updatedAt TEXT NOT NULL + ) + `); + + db.init(); + + // The durable CLI-session record table exists. + const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all() as Array<{ name: string }>; + expect(tables.map((row) => row.name)).toContain("cli_sessions"); + + const cliSessionColumns = db + .prepare("PRAGMA table_info(cli_sessions)") + .all() as Array<{ name: string }>; + expect(cliSessionColumns.map((column) => column.name)).toEqual([ + "id", + "taskId", + "chatSessionId", + "purpose", + "projectId", + "adapterId", + "agentState", + "terminationReason", + "nativeSessionId", + "resumeAttempts", + "autonomyPosture", + "worktreePath", + "createdAt", + "updatedAt", + ]); + + const cliSessionIndexes = db + .prepare("PRAGMA index_list(cli_sessions)") + .all() as Array<{ name: string }>; + const indexNames = cliSessionIndexes.map((index) => index.name); + expect(indexNames).toContain("idx_cli_sessions_taskId"); + expect(indexNames).toContain("idx_cli_sessions_chatSessionId"); + expect(indexNames).toContain("idx_cli_sessions_project_state"); + + expect(db.getSchemaVersion()).toBe(109); + db.close(); + }); + + it("creates cli_sessions on a fresh database (fresh-create path)", () => { + const db = new Database(fusionDir); + db.init(); + + const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all() as Array<{ name: string }>; + expect(tables.map((row) => row.name)).toContain("cli_sessions"); + expect(db.getSchemaVersion()).toBe(109); db.close(); }); }); diff --git a/packages/core/src/__tests__/db.test.ts b/packages/core/src/__tests__/db.test.ts index 8a618bd583..191b67591a 100644 --- a/packages/core/src/__tests__/db.test.ts +++ b/packages/core/src/__tests__/db.test.ts @@ -334,7 +334,7 @@ describe("Database", () => { }); it("seeds schema version", () => { - expect(db.getSchemaVersion()).toBe(108); + expect(db.getSchemaVersion()).toBe(109); }); it("includes tokenUsageCacheWriteTokens on freshly initialized tasks table", () => { @@ -393,7 +393,7 @@ describe("Database", () => { it("is idempotent - calling init() twice does not fail", () => { expect(() => db.init()).not.toThrow(); - expect(db.getSchemaVersion()).toBe(108); + expect(db.getSchemaVersion()).toBe(109); }); it("does not overwrite existing config on re-init", () => { // Update the config @@ -1463,7 +1463,7 @@ describe("schema migrations", () => { db.init(); // Verify version bumped to 29 (includes v1→v2 through v26→v29) - expect(db.getSchemaVersion()).toBe(108); + expect(db.getSchemaVersion()).toBe(109); // Verify new columns exist and existing data is intact const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>; @@ -1488,11 +1488,11 @@ describe("schema migrations", () => { const db = new Database(fusionDir); db.init(); - expect(db.getSchemaVersion()).toBe(108); + expect(db.getSchemaVersion()).toBe(109); // Re-init should not fail db.init(); - expect(db.getSchemaVersion()).toBe(108); + expect(db.getSchemaVersion()).toBe(109); db.close(); }); @@ -1527,7 +1527,7 @@ describe("schema migrations", () => { db.init(); - expect(db.getSchemaVersion()).toBe(108); + expect(db.getSchemaVersion()).toBe(109); const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>; expect(cols.map((col) => col.name)).toContain("priority"); @@ -1568,7 +1568,7 @@ describe("schema migrations", () => { db.init(); - expect(db.getSchemaVersion()).toBe(108); + expect(db.getSchemaVersion()).toBe(109); const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>; const colNames = cols.map((col) => col.name); @@ -1640,7 +1640,7 @@ describe("schema migrations", () => { db.init(); - expect(db.getSchemaVersion()).toBe(108); + expect(db.getSchemaVersion()).toBe(109); const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>; const colNames = cols.map((col) => col.name); @@ -1880,7 +1880,7 @@ describe("schema migrations", () => { db.init(); - expect(db.getSchemaVersion()).toBe(108); + expect(db.getSchemaVersion()).toBe(109); const cols = db.prepare("PRAGMA table_info(chat_messages)").all() as Array<{ name: string }>; expect(cols.map((col) => col.name)).toContain("attachments"); @@ -1954,7 +1954,7 @@ describe("schema migrations", () => { db.init(); - expect(db.getSchemaVersion()).toBe(108); + expect(db.getSchemaVersion()).toBe(109); const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = 'agentRatings'").all() as Array<{ name: string }>; expect(tables).toEqual([{ name: "agentRatings" }]); @@ -1978,7 +1978,7 @@ describe("schema migrations", () => { db.init(); - expect(db.getSchemaVersion()).toBe(108); + expect(db.getSchemaVersion()).toBe(109); const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = 'mission_events'").all() as Array<{ name: string }>; expect(tables).toEqual([{ name: "mission_events" }]); @@ -2082,7 +2082,7 @@ describe("schema migrations", () => { db.init(); // Verify version bumped to 29 - expect(db.getSchemaVersion()).toBe(108); + expect(db.getSchemaVersion()).toBe(109); // Verify new columns exist and existing data is intact const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>; @@ -2301,7 +2301,7 @@ describe("schema migrations", () => { localDb.init(); - expect(localDb.getSchemaVersion()).toBe(108); + expect(localDb.getSchemaVersion()).toBe(109); const columns = localDb.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>; expect(columns.map((column) => column.name)).toContain("tokenUsageCacheWriteTokens"); @@ -2612,7 +2612,7 @@ describe("createDatabase factory", () => { const db = createDatabase(fusionDir); db.init(); - expect(db.getSchemaVersion()).toBe(108); + expect(db.getSchemaVersion()).toBe(109); expect(db.getLastModified()).toBeGreaterThan(0); db.close(); @@ -2766,7 +2766,7 @@ describe("migration v77 task token budget columns", () => { migrated = new Database(fusion); migrated.init(); - expect(migrated.getSchemaVersion()).toBe(108); + expect(migrated.getSchemaVersion()).toBe(109); const rows = migrated.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>; const names = new Set(rows.map((row) => row.name)); expect(names.has("tokenBudgetSoftAlertedAt")).toBe(true); @@ -2797,7 +2797,7 @@ describe("migration v106 adds tasks.transitionPending (FN-1417)", () => { const fresh = new Database(fusion); try { fresh.init(); - expect(fresh.getSchemaVersion()).toBe(108); + expect(fresh.getSchemaVersion()).toBe(109); const names = new Set( (fresh.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>).map((r) => r.name), ); @@ -2825,7 +2825,7 @@ describe("migration v106 adds tasks.transitionPending (FN-1417)", () => { migrated = new Database(fusion); migrated.init(); - expect(migrated.getSchemaVersion()).toBe(108); + expect(migrated.getSchemaVersion()).toBe(109); const names = new Set( (migrated.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>).map((r) => r.name), ); @@ -2851,7 +2851,7 @@ describe("migration v107 adds workflow_run_branches + index (FN-1417)", () => { const fresh = new Database(fusion); try { fresh.init(); - expect(fresh.getSchemaVersion()).toBe(108); + expect(fresh.getSchemaVersion()).toBe(109); const table = fresh .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = 'workflow_run_branches'") .get() as { name: string } | undefined; @@ -2885,7 +2885,7 @@ describe("migration v107 adds workflow_run_branches + index (FN-1417)", () => { migrated = new Database(fusion); migrated.init(); - expect(migrated.getSchemaVersion()).toBe(108); + expect(migrated.getSchemaVersion()).toBe(109); const table = migrated .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = 'workflow_run_branches'") .get() as { name: string } | undefined; @@ -2926,7 +2926,7 @@ describe("migration v67 drops orphan project auth tables", () => { migrated = new Database(fusion); migrated.init(); - expect(migrated.getSchemaVersion()).toBe(108); + expect(migrated.getSchemaVersion()).toBe(109); const tables = migrated .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'project_auth_%'") .all() as Array<{ name: string }>; @@ -2953,7 +2953,7 @@ describe("migration v67 drops orphan project auth tables", () => { try { fresh.init(); - expect(fresh.getSchemaVersion()).toBe(108); + expect(fresh.getSchemaVersion()).toBe(109); const tables = fresh .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'project_auth_%'") .all() as Array<{ name: string }>; diff --git a/packages/core/src/__tests__/goals-schema.test.ts b/packages/core/src/__tests__/goals-schema.test.ts index 333a68e551..03398b9515 100644 --- a/packages/core/src/__tests__/goals-schema.test.ts +++ b/packages/core/src/__tests__/goals-schema.test.ts @@ -91,6 +91,6 @@ describe("goals schema", () => { }); it("reports schema version 101", () => { - expect(db.getSchemaVersion()).toBe(108); + expect(db.getSchemaVersion()).toBe(109); }); }); diff --git a/packages/core/src/__tests__/insight-store.test.ts b/packages/core/src/__tests__/insight-store.test.ts index 7aa0e611ad..5d8ac9b47a 100644 --- a/packages/core/src/__tests__/insight-store.test.ts +++ b/packages/core/src/__tests__/insight-store.test.ts @@ -1000,7 +1000,7 @@ describe("Migration: pre-33 DB upgrade", () => { // Step 1: Create a fresh database at v33 (runs all migrations up to 33) const db1 = createDatabase(legacyDir); db1.init(); - expect(db1.getSchemaVersion()).toBe(108); + expect(db1.getSchemaVersion()).toBe(109); db1.close(); // Step 2: Manually downgrade to version 32 and drop insight tables @@ -1035,7 +1035,7 @@ describe("Migration: pre-33 DB upgrade", () => { expect(tableNamesBefore).not.toContain("project_insight_runs"); // Now run init — this triggers the v32→v33 migration db3.init(); - expect(db3.getSchemaVersion()).toBe(108); + expect(db3.getSchemaVersion()).toBe(109); // Step 4: Verify insight tables exist after migration const tablesAfter = db3.prepare( @@ -1066,12 +1066,12 @@ describe("Migration: pre-33 DB upgrade", () => { try { const db1 = createDatabase(testDir); db1.init(); - expect(db1.getSchemaVersion()).toBe(108); + expect(db1.getSchemaVersion()).toBe(109); db1.close(); const db2 = createDatabase(testDir); expect(() => db2.init()).not.toThrow(); - expect(db2.getSchemaVersion()).toBe(108); + expect(db2.getSchemaVersion()).toBe(109); db2.close(); } finally { rmSync(testDir, { recursive: true, force: true }); @@ -1085,7 +1085,7 @@ describe("Migration: pre-33 DB upgrade", () => { // Step 1: Create a fresh DB and run migrations const db1 = createDatabase(compatDir); db1.init(); - expect(db1.getSchemaVersion()).toBe(108); + expect(db1.getSchemaVersion()).toBe(109); // Step 2: Strip lifecycle and cancelledAt columns by recreating the // table without them. This simulates a DB that was created before the diff --git a/packages/core/src/__tests__/merge-request-record.test.ts b/packages/core/src/__tests__/merge-request-record.test.ts index ef2a10d136..64dc82817b 100644 --- a/packages/core/src/__tests__/merge-request-record.test.ts +++ b/packages/core/src/__tests__/merge-request-record.test.ts @@ -38,7 +38,7 @@ describe("TaskStore merge request record + completion handoff marker", () => { .all() as Array<{ name: string }>; expect(tableRows).toEqual([{ name: "completion_handoff_markers" }, { name: "merge_requests" }]); - expect(db.getSchemaVersion()).toBe(108); + expect(db.getSchemaVersion()).toBe(109); }); it("upserts merge request records", async () => { diff --git a/packages/core/src/__tests__/mission-store.test.ts b/packages/core/src/__tests__/mission-store.test.ts index 7410346c67..2ded53b090 100644 --- a/packages/core/src/__tests__/mission-store.test.ts +++ b/packages/core/src/__tests__/mission-store.test.ts @@ -3746,7 +3746,7 @@ describe("MissionStore", () => { describe("Loop State & Validator Run Schema (v31)", () => { it("schema version is 101 after migration", () => { - expect(db.getSchemaVersion()).toBe(108); + expect(db.getSchemaVersion()).toBe(109); }); it("mission_features table has loop state columns", () => { diff --git a/packages/core/src/__tests__/run-audit.test.ts b/packages/core/src/__tests__/run-audit.test.ts index 3be684ea9a..ca428b6914 100644 --- a/packages/core/src/__tests__/run-audit.test.ts +++ b/packages/core/src/__tests__/run-audit.test.ts @@ -584,7 +584,7 @@ describe("Run Audit", () => { }); it("schema version is bumped to 40", () => { - expect(db.getSchemaVersion()).toBe(108); + expect(db.getSchemaVersion()).toBe(109); }); }); }); diff --git a/packages/core/src/__tests__/store-merge-queue.test.ts b/packages/core/src/__tests__/store-merge-queue.test.ts index 6c09641156..96a9ea905b 100644 --- a/packages/core/src/__tests__/store-merge-queue.test.ts +++ b/packages/core/src/__tests__/store-merge-queue.test.ts @@ -60,7 +60,7 @@ describe("TaskStore merge queue", () => { expect.arrayContaining(["idx_mergeQueue_lease_ready", "idx_mergeQueue_leaseExpiresAt"]), ); - expect(store.getDatabase().getSchemaVersion()).toBe(108); + expect(store.getDatabase().getSchemaVersion()).toBe(109); }); it("migrates a legacy v88 database and preserves task rows", async () => { diff --git a/packages/core/src/__tests__/task-documents.test.ts b/packages/core/src/__tests__/task-documents.test.ts index 7352ec08bf..63e5eebfad 100644 --- a/packages/core/src/__tests__/task-documents.test.ts +++ b/packages/core/src/__tests__/task-documents.test.ts @@ -51,7 +51,7 @@ describe("TaskStore task documents", () => { expect(tableNames.has("task_documents")).toBe(true); expect(tableNames.has("task_document_revisions")).toBe(true); - expect(db.getSchemaVersion()).toBe(108); + expect(db.getSchemaVersion()).toBe(109); const index = db .prepare( diff --git a/packages/core/src/cli-session-store.ts b/packages/core/src/cli-session-store.ts new file mode 100644 index 0000000000..05c624f9ae --- /dev/null +++ b/packages/core/src/cli-session-store.ts @@ -0,0 +1,336 @@ +/** + * CliSessionStore - Data layer for durable CLI agent session records + * (CLI Agent Executor, U1). + * + * Manages CRUD for the `cli_sessions` table: the long-lived record that + * survives executor restarts so a session can be reasoned about, resumed, + * or reaped from its persisted state. + * + * Follows the same patterns as ChatStore: + * - EventEmitter for change notifications. + * - SQLite for structured data storage. + * - JSON columns for nested data (autonomyPosture). + * - Validation at the store boundary: invalid enum values are rejected. + */ + +import { EventEmitter } from "node:events"; +import { randomUUID } from "node:crypto"; +import type { Database } from "./db.js"; +import { fromJson, toJsonNullable } from "./db.js"; +import { + isCliAgentState, + isCliSessionPurpose, + isCliTerminationReason, + type CliAgentState, + type CliAutonomyPosture, + type CliSession, + type CliSessionCreateInput, + type CliSessionPurpose, + type CliSessionUpdateInput, + type CliTerminationReason, +} from "./cli-session-types.js"; + +// ── Event Types ───────────────────────────────────────────────────────── + +export interface CliSessionStoreEvents { + /** Emitted when a CLI session record is created. */ + "cli-session:created": [session: CliSession]; + /** Emitted when a CLI session record is updated. */ + "cli-session:updated": [session: CliSession]; + /** Emitted when a CLI session record is deleted. */ + "cli-session:deleted": [sessionId: string]; +} + +// ── Row Interface ──────────────────────────────────────────────────────── + +/** Database row shape for cli_sessions. */ +interface CliSessionRow { + id: string; + taskId: string | null; + chatSessionId: string | null; + purpose: string; + projectId: string; + adapterId: string; + agentState: string; + terminationReason: string | null; + nativeSessionId: string | null; + resumeAttempts: number; + autonomyPosture: string | null; + worktreePath: string | null; + createdAt: string; + updatedAt: string; +} + +// ── CliSessionStore Class ──────────────────────────────────────────────── + +export class CliSessionStore extends EventEmitter { + constructor( + private fusionDir: string, + private db: Database, + ) { + super(); + this.setMaxListeners(100); + } + + // ── Row-to-Object Converter ────────────────────────────────────────── + + private rowToSession(row: CliSessionRow): CliSession { + return { + id: row.id, + taskId: row.taskId ?? null, + chatSessionId: row.chatSessionId ?? null, + purpose: row.purpose as CliSessionPurpose, + projectId: row.projectId, + adapterId: row.adapterId, + agentState: row.agentState as CliAgentState, + terminationReason: (row.terminationReason as CliTerminationReason | null) ?? null, + nativeSessionId: row.nativeSessionId ?? null, + resumeAttempts: row.resumeAttempts ?? 0, + autonomyPosture: fromJson(row.autonomyPosture) ?? null, + worktreePath: row.worktreePath ?? null, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + }; + } + + // ── Boundary validation ────────────────────────────────────────────── + + private assertAgentState(value: unknown): asserts value is CliAgentState { + if (!isCliAgentState(value)) { + throw new Error(`Invalid CLI agent state: ${JSON.stringify(value)}`); + } + } + + private assertPurpose(value: unknown): asserts value is CliSessionPurpose { + if (!isCliSessionPurpose(value)) { + throw new Error(`Invalid CLI session purpose: ${JSON.stringify(value)}`); + } + } + + private assertTerminationReason( + value: unknown, + ): asserts value is CliTerminationReason | null { + if (value === null || value === undefined) return; + if (!isCliTerminationReason(value)) { + throw new Error(`Invalid CLI termination reason: ${JSON.stringify(value)}`); + } + } + + // ── CRUD Operations ────────────────────────────────────────────────── + + /** + * Create a new CLI session record. + * + * @throws Error if any enum value (purpose / agentState / terminationReason) + * is invalid, or required fields are missing. + */ + createSession(input: CliSessionCreateInput): CliSession { + this.assertPurpose(input.purpose); + const agentState: CliAgentState = input.agentState ?? "starting"; + this.assertAgentState(agentState); + this.assertTerminationReason(input.terminationReason ?? null); + + if (!input.projectId) { + throw new Error("CLI session requires a projectId"); + } + if (!input.adapterId) { + throw new Error("CLI session requires an adapterId"); + } + + const now = new Date().toISOString(); + const id = input.id ?? `cli-${randomUUID().slice(0, 8)}`; + const resumeAttempts = input.resumeAttempts ?? 0; + + const session: CliSession = { + id, + taskId: input.taskId ?? null, + chatSessionId: input.chatSessionId ?? null, + purpose: input.purpose, + projectId: input.projectId, + adapterId: input.adapterId, + agentState, + terminationReason: input.terminationReason ?? null, + nativeSessionId: input.nativeSessionId ?? null, + resumeAttempts, + autonomyPosture: input.autonomyPosture ?? null, + worktreePath: input.worktreePath ?? null, + createdAt: now, + updatedAt: now, + }; + + this.db + .prepare( + `INSERT INTO cli_sessions ( + id, taskId, chatSessionId, purpose, projectId, adapterId, + agentState, terminationReason, nativeSessionId, resumeAttempts, + autonomyPosture, worktreePath, createdAt, updatedAt + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ) + .run( + session.id, + session.taskId, + session.chatSessionId, + session.purpose, + session.projectId, + session.adapterId, + session.agentState, + session.terminationReason, + session.nativeSessionId, + session.resumeAttempts, + toJsonNullable(session.autonomyPosture), + session.worktreePath, + session.createdAt, + session.updatedAt, + ); + + this.db.bumpLastModified(); + this.emit("cli-session:created", session); + return session; + } + + /** Get a CLI session record by ID. */ + getSession(id: string): CliSession | undefined { + const row = this.db + .prepare("SELECT * FROM cli_sessions WHERE id = ?") + .get(id) as unknown as CliSessionRow | undefined; + if (!row) return undefined; + return this.rowToSession(row); + } + + /** + * List CLI session records with optional filtering. + * + * @returns Array of sessions ordered by updatedAt DESC. + */ + listSessions(options?: { + taskId?: string; + chatSessionId?: string; + projectId?: string; + agentState?: CliAgentState; + purpose?: CliSessionPurpose; + }): CliSession[] { + const whereClauses: string[] = []; + const params: string[] = []; + + if (options?.taskId !== undefined) { + whereClauses.push("taskId = ?"); + params.push(options.taskId); + } + if (options?.chatSessionId !== undefined) { + whereClauses.push("chatSessionId = ?"); + params.push(options.chatSessionId); + } + if (options?.projectId !== undefined) { + whereClauses.push("projectId = ?"); + params.push(options.projectId); + } + if (options?.agentState !== undefined) { + this.assertAgentState(options.agentState); + whereClauses.push("agentState = ?"); + params.push(options.agentState); + } + if (options?.purpose !== undefined) { + this.assertPurpose(options.purpose); + whereClauses.push("purpose = ?"); + params.push(options.purpose); + } + + const whereSql = whereClauses.length > 0 ? `WHERE ${whereClauses.join(" AND ")}` : ""; + const rows = this.db + .prepare(`SELECT * FROM cli_sessions ${whereSql} ORDER BY updatedAt DESC`) + .all(...params); + + return (rows as unknown as CliSessionRow[]).map((row) => this.rowToSession(row)); + } + + /** List CLI session records owned by a task. */ + listByTask(taskId: string): CliSession[] { + return this.listSessions({ taskId }); + } + + /** List CLI session records owned by a chat session. */ + listByChatSession(chatSessionId: string): CliSession[] { + return this.listSessions({ chatSessionId }); + } + + /** + * Update a CLI session record. + * + * State, terminationReason, and resumeAttempts are written atomically in a + * single UPDATE statement, so a state transition that also records why the + * session ended and how many resumes were attempted cannot tear. + * + * @throws Error if any provided enum value is invalid. + * @returns The updated session, or undefined if not found. + */ + updateSession(id: string, input: CliSessionUpdateInput): CliSession | undefined { + const existing = this.getSession(id); + if (!existing) return undefined; + + if (input.agentState !== undefined) { + this.assertAgentState(input.agentState); + } + if (input.terminationReason !== undefined) { + this.assertTerminationReason(input.terminationReason); + } + + const now = new Date().toISOString(); + const setClauses: string[] = ["updatedAt = ?"]; + const params: (string | number | null)[] = [now]; + + if (input.taskId !== undefined) { + setClauses.push("taskId = ?"); + params.push(input.taskId); + } + if (input.chatSessionId !== undefined) { + setClauses.push("chatSessionId = ?"); + params.push(input.chatSessionId); + } + if (input.agentState !== undefined) { + setClauses.push("agentState = ?"); + params.push(input.agentState); + } + if (input.terminationReason !== undefined) { + setClauses.push("terminationReason = ?"); + params.push(input.terminationReason); + } + if (input.nativeSessionId !== undefined) { + setClauses.push("nativeSessionId = ?"); + params.push(input.nativeSessionId); + } + if (input.resumeAttempts !== undefined) { + setClauses.push("resumeAttempts = ?"); + params.push(input.resumeAttempts); + } + if (input.autonomyPosture !== undefined) { + setClauses.push("autonomyPosture = ?"); + params.push(toJsonNullable(input.autonomyPosture)); + } + if (input.worktreePath !== undefined) { + setClauses.push("worktreePath = ?"); + params.push(input.worktreePath); + } + + params.push(id); + + this.db + .prepare(`UPDATE cli_sessions SET ${setClauses.join(", ")} WHERE id = ?`) + .run(...params); + + const updated = this.getSession(id)!; + this.db.bumpLastModified(); + this.emit("cli-session:updated", updated); + return updated; + } + + /** Delete a CLI session record. */ + deleteSession(id: string): boolean { + const existing = this.getSession(id); + if (!existing) return false; + + this.db.prepare("DELETE FROM cli_sessions WHERE id = ?").run(id); + this.db.bumpLastModified(); + this.emit("cli-session:deleted", id); + return true; + } +} diff --git a/packages/core/src/cli-session-types.ts b/packages/core/src/cli-session-types.ts new file mode 100644 index 0000000000..06471d5c2a --- /dev/null +++ b/packages/core/src/cli-session-types.ts @@ -0,0 +1,196 @@ +/** + * CLI agent session type definitions (CLI Agent Executor, U1). + * + * Defines the durable record shape for a CLI agent session — the long-lived + * process that drives a single autonomy unit (a task execution, a planning + * pass, a validator run, a CE run, or an interactive chat). These records + * outlive the in-memory executor so a crashed/restarted Fusion instance can + * reason about, resume, or reap sessions from their persisted state. + * + * Follows the same conventions as chat-types.ts: + * - String-literal unions for enums. + * - Nullable owning-entity references (taskId / chatSessionId). + * - JSON-serialized structured columns (autonomyPosture). + */ + +// ── Enums / String Literals ───────────────────────────────────────────── + +/** + * Lifecycle state of a CLI agent session. + * + * Transitions (typical): starting → ready → busy ↔ waitingOnInput → done, + * with dead / needsAttention reachable from any active state on failure or + * a condition requiring operator intervention. + */ +export type CliAgentState = + | "starting" + | "ready" + | "busy" + | "waitingOnInput" + | "done" + | "dead" + | "needsAttention"; + +/** All valid agent states, for runtime validation at the store boundary. */ +export const CLI_AGENT_STATES: readonly CliAgentState[] = [ + "starting", + "ready", + "busy", + "waitingOnInput", + "done", + "dead", + "needsAttention", +] as const; + +/** + * Why a CLI agent session terminated. Null while the session is still live. + * + * Termination taxonomy (KTD): + * - completed — the agent finished its unit of work successfully. + * - userExited — the user/operator deliberately stopped the session. + * - killed — the session was force-terminated (e.g. supervisor reap). + * - crashed — the underlying process exited abnormally / unexpectedly. + * - authFailed — the session ended because credentials/auth were rejected. + * - engineDeath — the owning Fusion engine/process died, orphaning the session. + */ +export type CliTerminationReason = + | "completed" + | "userExited" + | "killed" + | "crashed" + | "authFailed" + | "engineDeath"; + +/** All valid termination reasons, for runtime validation at the store boundary. */ +export const CLI_TERMINATION_REASONS: readonly CliTerminationReason[] = [ + "completed", + "userExited", + "killed", + "crashed", + "authFailed", + "engineDeath", +] as const; + +/** + * The purpose a CLI agent session serves — which autonomy unit it drives. + * + * - execute — a task execution run. + * - planning — a planning / triage pass. + * - validator — a validator / acceptance run. + * - ce — a compound-engineering run. + * - chat — an interactive chat session. + */ +export type CliSessionPurpose = "execute" | "planning" | "validator" | "ce" | "chat"; + +/** All valid session purposes, for runtime validation at the store boundary. */ +export const CLI_SESSION_PURPOSES: readonly CliSessionPurpose[] = [ + "execute", + "planning", + "validator", + "ce", + "chat", +] as const; + +// ── Core Types ────────────────────────────────────────────────────────── + +/** + * Operator-configured autonomy posture for a session. Stored as JSON. + * + * Kept intentionally open-ended (structured but extensible) so posture + * controls can evolve without a schema migration. Persisted verbatim. + */ +export interface CliAutonomyPosture { + /** Whether the session may proceed without per-step approval. */ + autoApprove?: boolean; + /** Maximum number of resume attempts permitted before giving up. */ + maxResumeAttempts?: number; + /** Free-form, forward-compatible posture fields. */ + [key: string]: unknown; +} + +/** + * A durable CLI agent session record. + * + * Exactly one of `taskId` / `chatSessionId` is typically set, matching the + * owning entity for the session's `purpose` (chat → chatSessionId; the rest → + * taskId). Both may be null for sessions not yet attached to an entity. + */ +export interface CliSession { + /** Stable primary key. */ + id: string; + /** Owning task ID, when this session drives task work. Null otherwise. */ + taskId: string | null; + /** Owning chat session ID, when purpose is "chat". Null otherwise. */ + chatSessionId: string | null; + /** What autonomy unit this session drives. */ + purpose: CliSessionPurpose; + /** Project this session belongs to. */ + projectId: string; + /** Adapter (CLI agent integration) backing the session. */ + adapterId: string; + /** Current lifecycle state. */ + agentState: CliAgentState; + /** Why the session terminated, or null while live. */ + terminationReason: CliTerminationReason | null; + /** Native (adapter/process) session identifier, for resume. Null until known. */ + nativeSessionId: string | null; + /** Number of resume attempts made so far. */ + resumeAttempts: number; + /** Operator-configured autonomy posture. */ + autonomyPosture: CliAutonomyPosture | null; + /** Worktree path the session operates in. */ + worktreePath: string | null; + /** When the record was created (ISO 8601). */ + createdAt: string; + /** When the record was last updated (ISO 8601). */ + updatedAt: string; +} + +/** Input for creating a CLI session record. */ +export interface CliSessionCreateInput { + /** Optional explicit ID; generated when omitted. */ + id?: string; + taskId?: string | null; + chatSessionId?: string | null; + purpose: CliSessionPurpose; + projectId: string; + adapterId: string; + /** Initial state; defaults to "starting" when omitted. */ + agentState?: CliAgentState; + terminationReason?: CliTerminationReason | null; + nativeSessionId?: string | null; + resumeAttempts?: number; + autonomyPosture?: CliAutonomyPosture | null; + worktreePath?: string | null; +} + +/** Partial updates to a CLI session record. */ +export interface CliSessionUpdateInput { + taskId?: string | null; + chatSessionId?: string | null; + agentState?: CliAgentState; + terminationReason?: CliTerminationReason | null; + nativeSessionId?: string | null; + resumeAttempts?: number; + autonomyPosture?: CliAutonomyPosture | null; + worktreePath?: string | null; +} + +// ── Validation helpers ─────────────────────────────────────────────────── + +/** Narrow an unknown value to a valid CliAgentState. */ +export function isCliAgentState(value: unknown): value is CliAgentState { + return typeof value === "string" && (CLI_AGENT_STATES as readonly string[]).includes(value); +} + +/** Narrow an unknown value to a valid CliTerminationReason. */ +export function isCliTerminationReason(value: unknown): value is CliTerminationReason { + return ( + typeof value === "string" && (CLI_TERMINATION_REASONS as readonly string[]).includes(value) + ); +} + +/** Narrow an unknown value to a valid CliSessionPurpose. */ +export function isCliSessionPurpose(value: unknown): value is CliSessionPurpose { + return typeof value === "string" && (CLI_SESSION_PURPOSES as readonly string[]).includes(value); +} diff --git a/packages/core/src/db.ts b/packages/core/src/db.ts index 6403a186f1..266adee074 100644 --- a/packages/core/src/db.ts +++ b/packages/core/src/db.ts @@ -149,7 +149,7 @@ export function probeFts5(db: DatabaseSync): boolean { // ── Schema Definition ──────────────────────────────────────────────── -const SCHEMA_VERSION = 108; +const SCHEMA_VERSION = 109; export { SCHEMA_VERSION }; @@ -1233,6 +1233,22 @@ export const MIGRATION_ONLY_TABLE_SCHEMAS: Record cliSessionFile: "TEXT", inFlightGeneration: "TEXT", }, + cli_sessions: { + id: "TEXT PRIMARY KEY", + taskId: "TEXT", + chatSessionId: "TEXT", + purpose: "TEXT NOT NULL", + projectId: "TEXT NOT NULL", + adapterId: "TEXT NOT NULL", + agentState: "TEXT NOT NULL DEFAULT 'starting'", + terminationReason: "TEXT", + nativeSessionId: "TEXT", + resumeAttempts: "INTEGER NOT NULL DEFAULT 0", + autonomyPosture: "TEXT", + worktreePath: "TEXT", + createdAt: "TEXT NOT NULL", + updatedAt: "TEXT NOT NULL", + }, chat_messages: { id: "TEXT PRIMARY KEY", sessionId: "TEXT NOT NULL", @@ -4291,6 +4307,43 @@ export class Database { }); } + // Migration 109: Durable CLI agent session records (CLI Agent Executor U1). + // Adds cli_sessions — one row per long-lived CLI agent session (task + // execution, planning, validator, ce, or chat) — so a crashed/restarted + // Fusion instance can reason about, resume, or reap sessions from their + // persisted state (agentState + terminationReason + resumeAttempts + + // nativeSessionId). taskId/chatSessionId are the nullable owning-entity + // references; autonomyPosture is JSON. Additive-only, idempotent + // (table-exists guard); no backfill. + // agentState ∈ starting|ready|busy|waitingOnInput|done|dead|needsAttention. + // terminationReason ∈ completed|userExited|killed|crashed|authFailed|engineDeath. + // purpose ∈ execute|planning|validator|ce|chat. + if (version < 109) { + this.applyMigration(109, () => { + this.db.exec(` + CREATE TABLE IF NOT EXISTS cli_sessions ( + id TEXT PRIMARY KEY, + taskId TEXT, + chatSessionId TEXT, + purpose TEXT NOT NULL, + projectId TEXT NOT NULL, + adapterId TEXT NOT NULL, + agentState TEXT NOT NULL DEFAULT 'starting', + terminationReason TEXT, + nativeSessionId TEXT, + resumeAttempts INTEGER NOT NULL DEFAULT 0, + autonomyPosture TEXT, + worktreePath TEXT, + createdAt TEXT NOT NULL, + updatedAt TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_cli_sessions_taskId ON cli_sessions(taskId); + CREATE INDEX IF NOT EXISTS idx_cli_sessions_chatSessionId ON cli_sessions(chatSessionId); + CREATE INDEX IF NOT EXISTS idx_cli_sessions_project_state ON cli_sessions(projectId, agentState); + `); + }); + } + } /** diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 999eb211cb..1ff337ddf1 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1527,6 +1527,25 @@ export type { } from "./chat-types.js"; export { ChatStore } from "./chat-store.js"; export type { ChatStoreEvents } from "./chat-store.js"; +export { + CLI_AGENT_STATES, + CLI_TERMINATION_REASONS, + CLI_SESSION_PURPOSES, + isCliAgentState, + isCliTerminationReason, + isCliSessionPurpose, +} from "./cli-session-types.js"; +export type { + CliAgentState, + CliTerminationReason, + CliSessionPurpose, + CliAutonomyPosture, + CliSession, + CliSessionCreateInput, + CliSessionUpdateInput, +} from "./cli-session-types.js"; +export { CliSessionStore } from "./cli-session-store.js"; +export type { CliSessionStoreEvents } from "./cli-session-store.js"; export { choosePreferredStoredCredential, extractClaudeCliStoredCredential,