import crypto from "node:crypto"; import fs from "node:fs"; import path from "node:path"; export interface StoreIdentity { botId: string; platform: string; } export interface WorkerProcessGroup { pgid: number; token: string; } export type ProposalStatus = | "proposed" | "queued" | "working" | "pending" | "finished"; export interface ProposalPendingAttachment { path: string; mimeType?: string; } export interface ProposalPending { summary: string; question?: string; workspaceDirty?: boolean; receivedAt: number; attachments?: ProposalPendingAttachment[]; droppedAttachments?: string[]; } export type ProposalFinishKind = "done" | "cancelled"; export interface Proposal { id: string; title: string; goal: string; steps: string[]; ownerChatKey: string; requesterUserId: string; status: ProposalStatus; workerNativeSessionId?: string; workerProcessGroup?: WorkerProcessGroup; pending?: ProposalPending; finishKind?: ProposalFinishKind; finishNote?: string; lastWorkerSummary?: string; createdAt: number; confirmedAt?: number; startedAt?: number; updatedAt: number; finishedAt?: number; } export interface CreateProposalInput { title: string; goal: string; steps: string[]; ownerChatKey: string; requesterUserId: string; } export interface ProposalPatch { title?: string; goal?: string; steps?: string[]; status?: ProposalStatus; workerNativeSessionId?: string; workerProcessGroup?: WorkerProcessGroup; pending?: ProposalPending; finishKind?: ProposalFinishKind; finishNote?: string; lastWorkerSummary?: string; confirmedAt?: number; startedAt?: number; finishedAt?: number; } interface StoreData { version: 2; botId: string; platform: string; proposals: Record; } export class ProposalStore { private data: StoreData; private queue: Promise = Promise.resolve(); private lockFd?: fs.promises.FileHandle; private closed = false; constructor(readonly file: string, readonly identity: StoreIdentity) { this.data = emptyData(identity); } async open(): Promise { const directory = path.dirname(this.file); await fs.promises.mkdir(directory, { recursive: true, mode: 0o700 }); const directoryStat = await fs.promises.lstat(directory); if (!directoryStat.isDirectory() || directoryStat.isSymbolicLink()) throw new Error(`Refusing unsafe state directory: ${directory}`); const directoryMode = directoryStat.mode & 0o777; if (directoryMode !== 0o700) throw new Error(`State directory mode must be 0700, got 0${directoryMode.toString(8)}`); if (typeof process.getuid === "function" && directoryStat.uid !== process.getuid()) throw new Error("State directory is not owned by the current user"); const lockFile = `${this.file}.lock`; try { this.lockFd = await acquireLock(lockFile); } catch (error) { if ((error as NodeJS.ErrnoException).code !== "EEXIST" || !await removeStaleLock(lockFile)) { if ((error as NodeJS.ErrnoException).code === "EEXIST") throw new Error(`Proposal state is locked by another gori-agent instance: ${lockFile}`); throw error; } this.lockFd = await acquireLock(lockFile).catch((retryError) => { if ((retryError as NodeJS.ErrnoException).code === "EEXIST") throw new Error(`Proposal state is locked by another gori-agent instance: ${lockFile}`); throw retryError; }); } try { await this.lockFd.writeFile(`${process.pid}\n`); await this.lockFd.sync(); } catch (error) { await this.releaseLock(); throw error; } try { const raw = await fs.promises.readFile(this.file, "utf8"); const value = JSON.parse(raw) as unknown; if (!isRecord(value) || typeof value.botId !== "string" || typeof value.platform !== "string") { throw new Error("unsupported proposal state; expected a versioned store file"); } if (value.botId !== this.identity.botId || value.platform !== this.identity.platform) { throw new Error(`proposal state identity mismatch: expected bot '${this.identity.botId}' platform '${this.identity.platform}'`); } if (value.version === 1) { await this.backupFile(raw); this.data = migrateV1(value); await this.persist(); } else { this.data = parseStoreData(value); } } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") { await this.releaseLock(); throw new Error(`Cannot read proposal state '${this.file}'; original file was preserved: ${error instanceof Error ? error.message : String(error)}`); } } } get(id: string): Proposal | undefined { const value = this.data.proposals[id]; return value ? structuredClone(value) : undefined; } list(filter?: { status?: ProposalStatus }): Proposal[] { const proposals = Object.values(this.data.proposals) .filter((proposal) => !filter?.status || proposal.status === filter.status) .sort((a, b) => (a.confirmedAt ?? a.createdAt) - (b.confirmedAt ?? b.createdAt)); return structuredClone(proposals); } async create(input: CreateProposalInput): Promise { if (typeof input.title !== "string" || input.title.length === 0) throw new Error("proposal title must be a non-empty string"); if (typeof input.goal !== "string" || input.goal.length === 0) throw new Error("proposal goal must be a non-empty string"); if (!Array.isArray(input.steps) || input.steps.some((step) => typeof step !== "string" || step.length === 0)) { throw new Error("proposal steps must be an array of non-empty strings"); } if (typeof input.ownerChatKey !== "string" || input.ownerChatKey.length === 0) throw new Error("proposal ownerChatKey must be a non-empty string"); if (typeof input.requesterUserId !== "string" || input.requesterUserId.length === 0) throw new Error("proposal requesterUserId must be a non-empty string"); const now = Date.now(); const proposal: Proposal = { id: crypto.randomUUID(), title: input.title, goal: input.goal, steps: [...input.steps], ownerChatKey: input.ownerChatKey, requesterUserId: input.requesterUserId, status: "proposed", createdAt: now, updatedAt: now }; this.data.proposals[proposal.id] = proposal; await this.persist(); return structuredClone(proposal); } async update(id: string, patch: ProposalPatch): Promise { const proposal = this.data.proposals[id]; if (!proposal) throw new Error(`unknown proposal '${id}'`); const candidate: Proposal = { ...structuredClone(proposal), ...structuredClone(patch), id, updatedAt: Date.now() }; for (const key of Object.keys(candidate) as (keyof Proposal)[]) { if (candidate[key] === undefined) delete candidate[key]; } validateProposal(candidate, id); this.data.proposals[id] = candidate; await this.persist(); return structuredClone(candidate); } stats(): { proposals: number } { return { proposals: Object.keys(this.data.proposals).length }; } async flush(): Promise { await this.queue; } async close(): Promise { if (this.closed) return; this.closed = true; try { await this.flush(); } finally { await this.releaseLock(); } } private persist(): Promise { if (this.closed) return Promise.reject(new Error("Proposal store is closed")); const snapshot = JSON.stringify(this.data, null, 2) + "\n"; const operation = this.queue.then(() => atomicWrite(this.file, snapshot)); this.queue = operation.catch(() => undefined); return operation; } private async backupFile(raw: string): Promise { const stamp = new Date().toISOString().replace(/[:.]/g, "-"); const backup = `${this.file}.v1-${stamp}.bak`; let handle: fs.promises.FileHandle | undefined; try { handle = await fs.promises.open(backup, "wx", 0o600); await handle.writeFile(raw, "utf8"); await handle.sync(); } finally { await handle?.close().catch(() => undefined); } } private async releaseLock(): Promise { 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; }); } } function emptyData(identity: StoreIdentity): StoreData { return { version: 2, botId: identity.botId, platform: identity.platform, proposals: {} }; } const STATUSES: readonly ProposalStatus[] = ["proposed", "queued", "working", "pending", "finished"]; const FINISH_KINDS: readonly ProposalFinishKind[] = ["done", "cancelled"]; function parseStoreData(value: unknown): StoreData { if (!isRecord(value) || value.version !== 2 || typeof value.botId !== "string" || typeof value.platform !== "string" || !isRecord(value.proposals)) { throw new Error("unsupported proposal state version; expected version 2"); } for (const [id, proposal] of Object.entries(value.proposals)) { if (!isRecord(proposal) || proposal.id !== id) throw new Error(`invalid proposal '${id}'`); validateProposal(proposal as unknown as Proposal, id); } return value as unknown as StoreData; } interface V1Pending { kind?: string; summary?: string; question?: string; } // v1 → v2 migration: pending becomes a single "waiting for the user" state with no success/failure // distinction; terminal v1 statuses collapse into finished with a finishKind. function migrateV1(value: Record): StoreData { if (!isRecord(value.proposals)) throw new Error("unsupported proposal state version; expected version 2"); const proposals: Record = {}; for (const [id, raw] of Object.entries(value.proposals)) { if (!isRecord(raw) || raw.id !== id) throw new Error(`invalid proposal '${id}'`); const source = structuredClone(raw) as unknown as Omit & { status: string; pending?: V1Pending }; const pending = isRecord(source.pending) ? source.pending as V1Pending : undefined; const migrated: Proposal = { ...source, pending: undefined, finishKind: undefined, finishNote: undefined } as Proposal; if (source.status === "awaiting_user_confirmation") { migrated.status = "pending"; migrated.pending = { summary: typeof pending?.summary === "string" ? pending.summary : (source.lastWorkerSummary || "worker reported a result before migration"), receivedAt: typeof source.updatedAt === "number" ? source.updatedAt : Date.now() }; if (pending?.kind === "failure") migrated.pending.workspaceDirty = true; if (pending?.kind === "step" && typeof pending.question === "string") migrated.pending.question = pending.question; } else if (source.status === "completed") { migrated.status = "finished"; migrated.finishKind = "done"; migrated.finishedAt = typeof source.finishedAt === "number" ? source.finishedAt : Date.now(); } else if (source.status === "failed") { migrated.status = "finished"; migrated.finishKind = "done"; migrated.finishNote = pending?.summary || source.lastWorkerSummary || "failed before migration"; migrated.finishedAt = typeof source.finishedAt === "number" ? source.finishedAt : Date.now(); } else if (source.status === "cancelled") { migrated.status = "finished"; migrated.finishKind = "cancelled"; migrated.finishedAt = typeof source.finishedAt === "number" ? source.finishedAt : Date.now(); } else if (source.status === "proposed" || source.status === "queued" || source.status === "working") { migrated.status = source.status; } else { throw new Error(`invalid proposal '${id}': status`); } validateProposal(migrated, id); proposals[id] = migrated; } return { version: 2, botId: value.botId as string, platform: value.platform as string, proposals }; } function validateProposal(proposal: Proposal, id: string): void { const invalid = (reason: string): never => { throw new Error(`invalid proposal '${id}': ${reason}`); }; if (typeof proposal.title !== "string" || proposal.title.length === 0) invalid("title"); if (typeof proposal.goal !== "string" || proposal.goal.length === 0) invalid("goal"); if (!Array.isArray(proposal.steps) || proposal.steps.some((step) => typeof step !== "string" || step.length === 0)) invalid("steps"); if (typeof proposal.ownerChatKey !== "string" || proposal.ownerChatKey.length === 0) invalid("ownerChatKey"); if (typeof proposal.requesterUserId !== "string" || proposal.requesterUserId.length === 0) invalid("requesterUserId"); if (!STATUSES.includes(proposal.status)) invalid("status"); if (proposal.workerNativeSessionId !== undefined && typeof proposal.workerNativeSessionId !== "string") invalid("workerNativeSessionId"); if (proposal.workerProcessGroup !== undefined) { const group = proposal.workerProcessGroup; if (!isRecord(group) || !Number.isSafeInteger(group.pgid) || group.pgid <= 1 || typeof group.token !== "string" || group.token.length === 0) { invalid("workerProcessGroup"); } } if (proposal.pending !== undefined) { const pending = proposal.pending; if (!isRecord(pending) || typeof pending.summary !== "string" || (pending.question !== undefined && typeof pending.question !== "string") || (pending.workspaceDirty !== undefined && typeof pending.workspaceDirty !== "boolean") || typeof pending.receivedAt !== "number") { invalid("pending"); } if (pending.attachments !== undefined && (!Array.isArray(pending.attachments) || pending.attachments.length > 3 || pending.attachments.some((attachment) => !isRecord(attachment) || typeof attachment.path !== "string" || attachment.path.length === 0 || (attachment.mimeType !== undefined && typeof attachment.mimeType !== "string")))) { invalid("pending.attachments"); } if (pending.droppedAttachments !== undefined && (!Array.isArray(pending.droppedAttachments) || pending.droppedAttachments.some((entry) => typeof entry !== "string"))) { invalid("pending.droppedAttachments"); } } if (proposal.finishKind !== undefined && !FINISH_KINDS.includes(proposal.finishKind)) invalid("finishKind"); if (proposal.finishNote !== undefined && typeof proposal.finishNote !== "string") invalid("finishNote"); if (proposal.lastWorkerSummary !== undefined && typeof proposal.lastWorkerSummary !== "string") invalid("lastWorkerSummary"); if (typeof proposal.createdAt !== "number") invalid("createdAt"); if (typeof proposal.updatedAt !== "number") invalid("updatedAt"); if (proposal.confirmedAt !== undefined && typeof proposal.confirmedAt !== "number") invalid("confirmedAt"); if (proposal.startedAt !== undefined && typeof proposal.startedAt !== "number") invalid("startedAt"); if (proposal.finishedAt !== undefined && typeof proposal.finishedAt !== "number") invalid("finishedAt"); } function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } async function acquireLock(lockFile: string): Promise { return fs.promises.open(lockFile, "wx", 0o600); } async function removeStaleLock(lockFile: string): Promise { let handle: fs.promises.FileHandle | undefined; try { handle = await fs.promises.open(lockFile, "r"); const pid = Number((await handle.readFile("utf8")).trim()); if (!Number.isSafeInteger(pid) || pid <= 1 || processExists(pid)) return false; const opened = await handle.stat(); const current = await fs.promises.lstat(lockFile); if (opened.dev !== current.dev || opened.ino !== current.ino) return false; await fs.promises.unlink(lockFile); return true; } catch { return false; } finally { await handle?.close().catch(() => undefined); } } function processExists(pid: number): boolean { try { process.kill(pid, 0); return true; } catch (error) { return (error as NodeJS.ErrnoException).code === "EPERM"; } } async function atomicWrite(file: string, content: string): Promise { const temp = `${file}.${process.pid}.${Date.now()}.tmp`; let handle: fs.promises.FileHandle | undefined; try { handle = await fs.promises.open(temp, "wx", 0o600); await handle.writeFile(content, "utf8"); await handle.sync(); await handle.close(); handle = undefined; await fs.promises.rename(temp, file); await fs.promises.chmod(file, 0o600); const dir = await fs.promises.open(path.dirname(file), "r"); try { await dir.sync(); } finally { await dir.close(); } } catch (error) { if (handle) await handle.close().catch(() => undefined); await fs.promises.unlink(temp).catch(() => undefined); throw error; } }