## Summary Fusion’s core runtime now treats PostgreSQL as the authoritative metadata store without leaving current CLI, dashboard, desktop, or engine composition roots uncompilable between stack layers. This is the 99-file foundation for the larger cutover: subsequent PRs migrate the remaining consumers, plugins, and operator surfaces. ## Design decisions - Runtime store construction fails closed when an asynchronous PostgreSQL layer is unavailable; SQLite remains readable only at explicit migration and identity-recovery boundaries. - Project ownership is enforced across active, archived, workflow, mission, analytics, and plugin-schema data. - The small set of cross-package files in this layer are compatibility-critical call sites required for a green intermediate commit, not the complete consumer migration. - Schema migration 0008 remains assigned to session-advisor state from current `main`; mission lineage idempotency advances to 0009 so neither invariant can be skipped. ## Validation - All affected package typechecks pass: Core, Engine, Dashboard, CLI, and Desktop. - `pnpm test:gate` passes: 478 tests across the engine gate, PostgreSQL core gate, and CLI workflow shape. - The PR changes exactly 99 files. ## Stack This is the base PR. Engine/dashboard, CLI/desktop/ops, plugins, and docs/release follow as stacked PRs, each below 100 changed files. Related: #2105 <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * PostgreSQL is now the standard runtime backend, with embedded PostgreSQL enabled by default. * Added project-scoped storage for tasks, archives, chat sessions, missions, knowledge pages, and operational data. * Improved archived-task search, filtering, pagination, and restoration. * Added safer plugin schema initialization with validation and project isolation. * Added PostgreSQL-backed workflow, mission, validator, and dashboard capabilities. * **Bug Fixes** * Improved startup timeout cancellation and resource cleanup. * Prevented cross-project data access and phantom reservation cleanup errors. * Ensured archived tasks remain read-only and asynchronous writes complete reliably. * Retired SQLite opt-out settings with clear startup errors. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
224 lines
8.5 KiB
TypeScript
224 lines
8.5 KiB
TypeScript
/**
|
|
* Durable PostgreSQL store for experimental CLI Agent Executor sessions.
|
|
*
|
|
* FNXC:CliAgentPostgres 2026-07-14-12:00:
|
|
* CLI-agent execution must remain available after the PostgreSQL cutover. Keep
|
|
* the runtime-facing API synchronous by hydrating a project-scoped cache before
|
|
* construction, while serializing every mutation through the injected
|
|
* AsyncDataLayer. Callers that cross a durability boundary (PTY launch and
|
|
* runtime shutdown) await flush().
|
|
*/
|
|
import { randomUUID } from "node:crypto";
|
|
import { EventEmitter } from "node:events";
|
|
import { and, desc, eq } from "drizzle-orm";
|
|
import * as schema from "./postgres/schema/index.js";
|
|
import type { AsyncDataLayer } from "./postgres/data-layer.js";
|
|
import { fromJson } from "./db-helpers.js";
|
|
import {
|
|
isCliAgentState,
|
|
isCliSessionPurpose,
|
|
isCliTerminationReason,
|
|
type CliAgentState,
|
|
type CliAutonomyPosture,
|
|
type CliSession,
|
|
type CliSessionCreateInput,
|
|
type CliSessionPurpose,
|
|
type CliSessionUpdateInput,
|
|
type CliTerminationReason,
|
|
} from "./cli-session-types.js";
|
|
|
|
export interface CliSessionStoreEvents {
|
|
"cli-session:created": [session: CliSession];
|
|
"cli-session:updated": [session: CliSession];
|
|
"cli-session:deleted": [sessionId: string];
|
|
}
|
|
|
|
type CliSessionRow = typeof schema.project.cliSessions.$inferSelect;
|
|
|
|
function parsePosture(value: string | null): CliAutonomyPosture | null {
|
|
return fromJson<CliAutonomyPosture>(value) ?? null;
|
|
}
|
|
|
|
function rowToSession(row: CliSessionRow): CliSession {
|
|
return {
|
|
id: row.id,
|
|
taskId: row.taskId,
|
|
chatSessionId: row.chatSessionId,
|
|
purpose: row.purpose as CliSessionPurpose,
|
|
projectId: row.projectId,
|
|
adapterId: row.adapterId,
|
|
agentState: row.agentState as CliAgentState,
|
|
terminationReason: row.terminationReason as CliTerminationReason | null,
|
|
nativeSessionId: row.nativeSessionId,
|
|
resumeAttempts: row.resumeAttempts,
|
|
autonomyPosture: parsePosture(row.autonomyPosture),
|
|
worktreePath: row.worktreePath,
|
|
createdAt: row.createdAt,
|
|
updatedAt: row.updatedAt,
|
|
};
|
|
}
|
|
|
|
export class CliSessionStore extends EventEmitter<CliSessionStoreEvents> {
|
|
private readonly sessions = new Map<string, CliSession>();
|
|
private writeTail: Promise<void> = Promise.resolve();
|
|
private writeError: unknown;
|
|
|
|
private constructor(
|
|
private readonly layer: AsyncDataLayer,
|
|
private readonly projectId: string,
|
|
) {
|
|
super();
|
|
this.setMaxListeners(100);
|
|
}
|
|
|
|
/** Hydrate all project sessions before exposing the synchronous cache API. */
|
|
static async create(layer: AsyncDataLayer, projectId: string): Promise<CliSessionStore> {
|
|
const store = new CliSessionStore(layer, projectId);
|
|
const rows = await layer.db
|
|
.select()
|
|
.from(schema.project.cliSessions)
|
|
.where(eq(schema.project.cliSessions.projectId, projectId))
|
|
.orderBy(desc(schema.project.cliSessions.updatedAt));
|
|
for (const row of rows) store.sessions.set(row.id, rowToSession(row));
|
|
return store;
|
|
}
|
|
|
|
/** Wait until all mutations queued before this call are durable. */
|
|
async flush(): Promise<void> {
|
|
await this.writeTail;
|
|
if (this.writeError !== undefined) throw this.writeError;
|
|
}
|
|
|
|
private enqueue(write: () => Promise<unknown>): void {
|
|
this.writeTail = this.writeTail
|
|
.then(async () => {
|
|
await write();
|
|
})
|
|
.catch((error: unknown) => {
|
|
// Event-driven state transitions cannot await storage directly. Retain
|
|
// the first failure for the next explicit durability boundary without
|
|
// creating an unhandled rejection, and keep later writes ordered.
|
|
this.writeError ??= error;
|
|
});
|
|
}
|
|
|
|
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 && !isCliTerminationReason(value)) {
|
|
throw new Error(`Invalid CLI termination reason: ${JSON.stringify(value)}`);
|
|
}
|
|
}
|
|
|
|
createSession(input: CliSessionCreateInput): CliSession {
|
|
this.assertPurpose(input.purpose);
|
|
const agentState = input.agentState ?? "starting";
|
|
this.assertAgentState(agentState);
|
|
this.assertTerminationReason(input.terminationReason ?? null);
|
|
if (!input.projectId) throw new Error("CLI session requires a projectId");
|
|
if (input.projectId !== this.projectId) throw new Error(`CLI session projectId must be ${this.projectId}`);
|
|
if (!input.adapterId) throw new Error("CLI session requires an adapterId");
|
|
|
|
const now = new Date().toISOString();
|
|
const session: CliSession = {
|
|
id: input.id ?? `cli-${randomUUID().slice(0, 8)}`,
|
|
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: input.resumeAttempts ?? 0,
|
|
autonomyPosture: input.autonomyPosture ?? null,
|
|
worktreePath: input.worktreePath ?? null,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
};
|
|
this.sessions.set(session.id, session);
|
|
this.enqueue(() => this.layer.db.insert(schema.project.cliSessions).values({
|
|
...session,
|
|
autonomyPosture: session.autonomyPosture ? JSON.stringify(session.autonomyPosture) : null,
|
|
}));
|
|
this.emit("cli-session:created", session);
|
|
return session;
|
|
}
|
|
|
|
getSession(id: string): CliSession | undefined {
|
|
return this.sessions.get(id);
|
|
}
|
|
|
|
listSessions(options?: {
|
|
taskId?: string;
|
|
chatSessionId?: string;
|
|
projectId?: string;
|
|
agentState?: CliAgentState;
|
|
purpose?: CliSessionPurpose;
|
|
}): CliSession[] {
|
|
if (options?.agentState !== undefined) this.assertAgentState(options.agentState);
|
|
if (options?.purpose !== undefined) this.assertPurpose(options.purpose);
|
|
return [...this.sessions.values()]
|
|
.filter((session) => options?.taskId === undefined || session.taskId === options.taskId)
|
|
.filter((session) => options?.chatSessionId === undefined || session.chatSessionId === options.chatSessionId)
|
|
.filter((session) => options?.projectId === undefined || session.projectId === options.projectId)
|
|
.filter((session) => options?.agentState === undefined || session.agentState === options.agentState)
|
|
.filter((session) => options?.purpose === undefined || session.purpose === options.purpose)
|
|
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
|
|
}
|
|
|
|
listByTask(taskId: string): CliSession[] {
|
|
return this.listSessions({ taskId });
|
|
}
|
|
|
|
listByChatSession(chatSessionId: string): CliSession[] {
|
|
return this.listSessions({ chatSessionId });
|
|
}
|
|
|
|
updateSession(id: string, input: CliSessionUpdateInput): CliSession | undefined {
|
|
const existing = this.sessions.get(id);
|
|
if (!existing) return undefined;
|
|
if (input.agentState !== undefined) this.assertAgentState(input.agentState);
|
|
if (input.terminationReason !== undefined) this.assertTerminationReason(input.terminationReason);
|
|
const updated: CliSession = { ...existing, ...input, updatedAt: new Date().toISOString() };
|
|
this.sessions.set(id, updated);
|
|
this.enqueue(() => this.layer.db
|
|
.update(schema.project.cliSessions)
|
|
.set({
|
|
taskId: updated.taskId,
|
|
chatSessionId: updated.chatSessionId,
|
|
agentState: updated.agentState,
|
|
terminationReason: updated.terminationReason,
|
|
nativeSessionId: updated.nativeSessionId,
|
|
resumeAttempts: updated.resumeAttempts,
|
|
autonomyPosture: updated.autonomyPosture ? JSON.stringify(updated.autonomyPosture) : null,
|
|
worktreePath: updated.worktreePath,
|
|
updatedAt: updated.updatedAt,
|
|
})
|
|
.where(and(
|
|
eq(schema.project.cliSessions.id, id),
|
|
eq(schema.project.cliSessions.projectId, this.projectId),
|
|
)));
|
|
this.emit("cli-session:updated", updated);
|
|
return updated;
|
|
}
|
|
|
|
deleteSession(id: string): boolean {
|
|
if (!this.sessions.delete(id)) return false;
|
|
this.enqueue(() => this.layer.db
|
|
.delete(schema.project.cliSessions)
|
|
.where(and(
|
|
eq(schema.project.cliSessions.id, id),
|
|
eq(schema.project.cliSessions.projectId, this.projectId),
|
|
)));
|
|
this.emit("cli-session:deleted", id);
|
|
return true;
|
|
}
|
|
}
|