Add ACP-backed role sessions

This commit is contained in:
zenord
2026-08-16 10:08:03 +08:00
parent d59c4a654f
commit a80fe68d57
34 changed files with 1683 additions and 887 deletions
+150
View File
@@ -0,0 +1,150 @@
import fs from "node:fs";
import path from "node:path";
export interface ChatState {
selectedRole: string;
createdAt: number;
updatedAt: number;
}
export interface SessionBinding {
chatKey: string;
roleId: string;
backendId: string;
nativeSessionId: string;
workspace: string;
roleFingerprint: string;
createdAt: number;
updatedAt: number;
}
interface StoreData {
version: 1;
chats: Record<string, ChatState>;
bindings: Record<string, SessionBinding>;
}
const EMPTY: StoreData = { version: 1, chats: {}, bindings: {} };
export class DurableSessionStore {
private data: StoreData = structuredClone(EMPTY);
private queue: Promise<void> = Promise.resolve();
private lockFd?: fs.promises.FileHandle;
private closed = false;
constructor(readonly file: string) {}
async open(): Promise<void> {
await fs.promises.mkdir(path.dirname(this.file), { recursive: true });
const lockFile = `${this.file}.lock`;
try {
this.lockFd = await acquireLock(lockFile);
await this.lockFd.writeFile(`${process.pid}\n`);
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "EEXIST") throw new Error(`Session state is locked by another gori-agent instance: ${lockFile}`);
throw error;
}
try {
const raw = await fs.promises.readFile(this.file, "utf8");
const parsed = JSON.parse(raw) as StoreData;
if (parsed.version !== 1 || !parsed.chats || !parsed.bindings) throw new Error("unsupported or malformed state data");
this.data = parsed;
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
await this.releaseLock();
throw new Error(`Cannot read session state '${this.file}'; original file was preserved: ${error instanceof Error ? error.message : String(error)}`);
}
}
}
getSelectedRole(chatKey: string, defaultRole: string): string {
return this.data.chats[chatKey]?.selectedRole || defaultRole;
}
async setSelectedRole(chatKey: string, roleId: string): Promise<void> {
const now = Date.now();
const current = this.data.chats[chatKey];
this.data.chats[chatKey] = { selectedRole: roleId, createdAt: current?.createdAt || now, updatedAt: now };
await this.persist();
}
getBinding(chatKey: string, roleId: string): SessionBinding | undefined {
const value = this.data.bindings[bindingKey(chatKey, roleId)];
return value ? structuredClone(value) : undefined;
}
async setBinding(binding: SessionBinding): Promise<void> {
this.data.bindings[bindingKey(binding.chatKey, binding.roleId)] = structuredClone(binding);
await this.persist();
}
async touchBinding(chatKey: string, roleId: string): Promise<void> {
const binding = this.data.bindings[bindingKey(chatKey, roleId)];
if (!binding) return;
binding.updatedAt = Date.now();
await this.persist();
}
async deleteBinding(chatKey: string, roleId: string): Promise<SessionBinding | undefined> {
const key = bindingKey(chatKey, roleId);
const existing = this.data.bindings[key];
delete this.data.bindings[key];
if (existing) await this.persist();
return existing;
}
stats(): { chats: number; bindings: number } {
return { chats: Object.keys(this.data.chats).length, bindings: Object.keys(this.data.bindings).length };
}
async flush(): Promise<void> { await this.queue; }
async close(): Promise<void> {
if (this.closed) return;
this.closed = true;
await this.flush();
await this.releaseLock();
}
private persist(): Promise<void> {
if (this.closed) return Promise.reject(new Error("Session store is closed"));
const snapshot = JSON.stringify(this.data, null, 2) + "\n";
this.queue = this.queue.then(() => atomicWrite(this.file, snapshot));
return this.queue;
}
private async releaseLock(): Promise<void> {
if (!this.lockFd) return;
await this.lockFd.close();
this.lockFd = undefined;
await fs.promises.unlink(`${this.file}.lock`).catch((error: NodeJS.ErrnoException) => {
if (error.code !== "ENOENT") throw error;
});
}
}
export function chatKeyFor(platform: string, chatId: string): string { return `${platform}:${chatId}`; }
export function bindingKey(chatKey: string, roleId: string): string { return `${chatKey}\u0000${roleId}`; }
async function acquireLock(lockFile: string): Promise<fs.promises.FileHandle> {
return fs.promises.open(lockFile, "wx", 0o600);
}
async function atomicWrite(file: string, content: string): Promise<void> {
const temp = `${file}.${process.pid}.${Date.now()}.tmp`;
const handle = await fs.promises.open(temp, "wx", 0o600);
try {
await handle.writeFile(content, "utf8");
await handle.sync();
} finally {
await handle.close();
}
try {
await fs.promises.rename(temp, file);
const dir = await fs.promises.open(path.dirname(file), "r");
try { await dir.sync(); } finally { await dir.close(); }
} catch (error) {
await fs.promises.unlink(temp).catch(() => undefined);
throw error;
}
}