feat(core): add cli_sessions table and CliSessionStore (U1)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
211
packages/core/src/__tests__/cli-session-store.test.ts
Normal file
211
packages/core/src/__tests__/cli-session-store.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 }>;
|
||||
|
||||
@@ -91,6 +91,6 @@ describe("goals schema", () => {
|
||||
});
|
||||
|
||||
it("reports schema version 101", () => {
|
||||
expect(db.getSchemaVersion()).toBe(108);
|
||||
expect(db.getSchemaVersion()).toBe(109);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -584,7 +584,7 @@ describe("Run Audit", () => {
|
||||
});
|
||||
|
||||
it("schema version is bumped to 40", () => {
|
||||
expect(db.getSchemaVersion()).toBe(108);
|
||||
expect(db.getSchemaVersion()).toBe(109);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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(
|
||||
|
||||
336
packages/core/src/cli-session-store.ts
Normal file
336
packages/core/src/cli-session-store.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
196
packages/core/src/cli-session-types.ts
Normal file
196
packages/core/src/cli-session-types.ts
Normal 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);
|
||||
}
|
||||
@@ -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);
|
||||
`);
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user