Merge U1: cli_sessions schema (v109), types, and CliSessionStore

This commit is contained in:
gsxdsm
2026-06-04 22:53:34 -07:00
14 changed files with 919 additions and 41 deletions

View File

@@ -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);
});
});

View File

@@ -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();
});
});

View File

@@ -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 }>;

View File

@@ -91,6 +91,6 @@ describe("goals schema", () => {
});
it("reports schema version 101", () => {
expect(db.getSchemaVersion()).toBe(108);
expect(db.getSchemaVersion()).toBe(109);
});
});

View File

@@ -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

View File

@@ -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 () => {

View File

@@ -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", () => {

View File

@@ -584,7 +584,7 @@ describe("Run Audit", () => {
});
it("schema version is bumped to 40", () => {
expect(db.getSchemaVersion()).toBe(108);
expect(db.getSchemaVersion()).toBe(109);
});
});
});

View File

@@ -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 () => {

View File

@@ -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(

View File

@@ -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<CliSessionStoreEvents> {
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<CliAutonomyPosture>(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;
}
}

View File

@@ -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);
}

View File

@@ -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<string, Record<string, string>
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);
`);
});
}
}
/**

View File

@@ -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,