feat: add background AI sessions with persistent storage and SSE streaming

Introduces ai_sessions table (schema v9), AiSessionStore, and background
session support for mission interviews, planning, and subtask breakdown.
Adds BackgroundTasksIndicator component, SSE-based progress streaming,
and dashboard API routes for session management.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-04-04 13:42:02 -07:00
parent 749f317121
commit dd0b53c358
23 changed files with 1128 additions and 68 deletions

View File

@@ -69,6 +69,7 @@ describe("Database", () => {
expect(tableNames).toContain("milestones");
expect(tableNames).toContain("slices");
expect(tableNames).toContain("mission_features");
expect(tableNames).toContain("ai_sessions");
});
it("creates all expected indexes", () => {
@@ -83,10 +84,12 @@ describe("Database", () => {
expect(indexNames).toContain("idxArchivedTasksId");
expect(indexNames).toContain("idxAgentHeartbeatsAgentId");
expect(indexNames).toContain("idxAgentHeartbeatsRunId");
expect(indexNames).toContain("idxAiSessionsStatus");
expect(indexNames).toContain("idxAiSessionsType");
});
it("seeds schema version", () => {
expect(db.getSchemaVersion()).toBe(8);
expect(db.getSchemaVersion()).toBe(9);
});
it("seeds lastModified", () => {
@@ -109,7 +112,7 @@ describe("Database", () => {
it("is idempotent - calling init() twice does not fail", () => {
expect(() => db.init()).not.toThrow();
expect(db.getSchemaVersion()).toBe(8);
expect(db.getSchemaVersion()).toBe(9);
});
it("does not overwrite existing config on re-init", () => {
@@ -716,7 +719,7 @@ describe("schema migrations", () => {
db.init();
// Verify version bumped to 5 (includes v1→v2, v2→v3, v3→v4, and v4→v5 migrations)
expect(db.getSchemaVersion()).toBe(8);
expect(db.getSchemaVersion()).toBe(9);
// Verify new columns exist and existing data is intact
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
@@ -741,11 +744,11 @@ describe("schema migrations", () => {
const db = new Database(kbDir);
db.init();
expect(db.getSchemaVersion()).toBe(8);
expect(db.getSchemaVersion()).toBe(9);
// Re-init should not fail
db.init();
expect(db.getSchemaVersion()).toBe(8);
expect(db.getSchemaVersion()).toBe(9);
db.close();
});
@@ -840,7 +843,7 @@ describe("schema migrations", () => {
db.init();
// Verify version bumped to 5
expect(db.getSchemaVersion()).toBe(8);
expect(db.getSchemaVersion()).toBe(9);
// Verify new columns exist and existing data is intact
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
@@ -1050,7 +1053,7 @@ describe("createDatabase factory", () => {
const db = createDatabase(kbDir);
db.init();
expect(db.getSchemaVersion()).toBe(8);
expect(db.getSchemaVersion()).toBe(9);
expect(db.getLastModified()).toBeGreaterThan(0);
db.close();

View File

@@ -59,7 +59,7 @@ export function fromJson<T>(json: string | null | undefined): T | undefined {
// ── Schema Definition ────────────────────────────────────────────────
const SCHEMA_VERSION = 8;
const SCHEMA_VERSION = 9;
function normalizeTaskComments(
steeringComments: SteeringComment[] | undefined,
@@ -424,8 +424,32 @@ export class Database {
});
}
if (version < 9) {
this.applyMigration(9, () => {
this.db.exec(`
CREATE TABLE IF NOT EXISTS ai_sessions (
id TEXT PRIMARY KEY,
type TEXT NOT NULL,
status TEXT NOT NULL,
title TEXT NOT NULL,
inputPayload TEXT NOT NULL,
conversationHistory TEXT DEFAULT '[]',
currentQuestion TEXT,
result TEXT,
thinkingOutput TEXT DEFAULT '',
error TEXT,
projectId TEXT,
createdAt TEXT NOT NULL,
updatedAt TEXT NOT NULL
)
`);
this.db.exec(`CREATE INDEX IF NOT EXISTS idxAiSessionsStatus ON ai_sessions(status)`);
this.db.exec(`CREATE INDEX IF NOT EXISTS idxAiSessionsType ON ai_sessions(type)`);
});
}
// Future migrations go here:
// if (version < 9) { this.applyMigration(9, () => { ... }); }
// if (version < 10) { this.applyMigration(10, () => { ... }); }
}
/**

View File

@@ -2753,6 +2753,11 @@ ${stepsSection}`;
return this.tasksDir;
}
/** Expose the shared Database instance for co-located stores (e.g. AiSessionStore). */
getDatabase(): Database {
return this.db;
}
private generateSpecifiedPrompt(task: Task): string {
const deps =
task.dependencies.length > 0