import * as fs from "node:fs/promises"; import * as path from "node:path"; import { type } from "@oh-my-pi/omptype"; import { type BtwHistoryRecord, type BtwHistoryTurn, getBtwLatestTurn } from "@oh-my-pi/pi-tui/overlays/btw-history"; import { acquireFileLock, type FileLockHandle, isEnoent, logger, toError } from "@oh-my-pi/pi-utils"; import { writeFileAtomically } from "../utils/atomic-file"; export { type BtwHistoryRecord, type BtwHistoryTurn, getBtwCopyText, getBtwLatestTurn, getBtwRecordInject, getBtwTurns, getBtwTurnKey, } from "@oh-my-pi/pi-tui/overlays/btw-history"; const imageSchema = type({ type: "'image'", data: "string", mimeType: "string" }); const turnFields = { question: "string", "images?": imageSchema.array(), answer: "string", status: "'running' | 'complete' | 'cancelled' | 'error' | 'interrupted'", createdAt: "0 <= number <= 8640000000000000", updatedAt: "0 <= number <= 8640000000000000", "error?": "string", } as const; const turnSchema = type({ ...turnFields, "+": "reject" }); const recordSchema = type({ ...turnFields, id: "string > 0", leafId: "string | null", // `title` lives in the `entry-.title` sidecar: older builds reject unknown record keys. "followUps?": turnSchema.array(), "+": "reject", }); function parseRecord(value: unknown): BtwHistoryRecord { const result = recordSchema(value); if (result instanceof type.errors) { throw new Error(`Invalid BTW history record: ${result.summary}`); } if (!/^[a-zA-Z0-9][a-zA-Z0-9_-]{0,127}$/.test(result.id)) { throw new Error("Invalid BTW history record id"); } return result; } function snapshotRecord(record: BtwHistoryRecord, recover = false): BtwHistoryRecord { const snapshotTurn = (turn: BtwHistoryTurn): BtwHistoryTurn => Object.freeze({ ...turn, status: recover && turn.status === "running" ? "interrupted" : turn.status }); return Object.freeze({ ...record, status: recover && record.status === "running" ? "interrupted" : record.status, ...(record.followUps ? { followUps: Object.freeze(record.followUps.map(snapshotTurn)) } : {}), }); } function recordFileName(id: string): string { // The prefix also prevents Windows device names (CON, NUL, etc.). return `entry-${id}.json`; } /** Older builds only read `.json` names, so the title sidecar never breaks their history. */ function titleFileName(id: string): string { return `entry-${id}.title`; } interface StoredRecord { record: BtwHistoryRecord; revision: string; } async function readRecord(filePath: string): Promise { try { const stat = await fs.lstat(filePath); if (!stat.isFile()) throw new Error("Expected a regular file"); const bytes = await fs.readFile(filePath); const record = parseRecord(JSON.parse(bytes.toString("utf8"))); if (path.basename(filePath) !== recordFileName(record.id)) { throw new Error("Record id does not match its filename"); } return { record, revision: new Bun.CryptoHasher("sha256").update(bytes).digest("hex") }; } catch (error) { if (isEnoent(error)) return undefined; throw new Error(`Failed to read BTW history ${filePath}: ${toError(error).message}`, { cause: error }); } } /** A missing or unreadable title only loses the heading; the topic still opens under its question. */ async function readTitle(filePath: string): Promise { try { const stat = await fs.lstat(filePath); if (!stat.isFile()) throw new Error("Expected a regular file"); return await fs.readFile(filePath, "utf8"); } catch (error) { if (!isEnoent(error)) { logger.warn("Ignoring unreadable BTW history title", { path: filePath, error: toError(error).message }); } return undefined; } } function historyDirectory(artifactsDir: string, scope: string | undefined): string { const root = path.join(artifactsDir, "btw-history"); if (scope === undefined) return root; // Session ids come from session headers; never let one escape the history root. const segment = /^[\w-]+$/.test(scope) ? scope : new Bun.CryptoHasher("sha256").update(scope).digest("hex"); return path.join(root, "sessions", segment); } /** The topic on disk is missing or was rewritten since this store read it; retrying the same write can never succeed. */ export class BtwHistoryConflictError extends Error { constructor(readonly recordId: string) { super(`BTW history conflict for ${recordId}; reopen history before retrying`); this.name = "BtwHistoryConflictError"; } } /** Session-local sidecar storage; never reads or writes the main session journal. */ export class BtwHistoryStore { readonly #directory: string | undefined; readonly #records = new Map(); readonly #revisions = new Map(); readonly #leases = new Map(); #snapshot: readonly BtwHistoryRecord[] = Object.freeze([]); #pending: Promise = Promise.resolve(); #writeError: Error | undefined; constructor(directory: string | undefined) { this.#directory = directory; } /** * Open the BTW history for an artifacts directory. Subagents share their * parent's artifacts directory, so a `scope` (the owning session id) keeps * each focused agent's side conversations apart from main and its siblings. */ static async open(artifactsDir: string | undefined, scope?: string): Promise { const store = new BtwHistoryStore(artifactsDir === undefined ? undefined : historyDirectory(artifactsDir, scope)); if (store.#directory === undefined) return store; let names: string[]; try { names = await fs.readdir(store.#directory); } catch (error) { if (isEnoent(error)) return store; throw error; } // Only titled topics have a sidecar; skip the lookup for the rest. const titleNames = new Set(names.filter(name => name.endsWith(".title"))); for (const name of names.sort()) { if (!name.endsWith(".json")) continue; const stored = await readRecord(path.join(store.#directory, name)); if (!stored) continue; const titleName = titleFileName(stored.record.id); const title = titleNames.has(titleName) ? await readTitle(path.join(store.#directory, titleName)) : undefined; // Recovery is only a view. CAS must compare the original disk bytes, // and the live writer's lease prevents a recovered view stealing a turn. store.#records.set( stored.record.id, snapshotRecord(title === undefined ? stored.record : { ...stored.record, title }, true), ); store.#revisions.set(stored.record.id, stored.revision); } store.#refreshSnapshot(); return store; } getRecords(): readonly BtwHistoryRecord[] { return this.#snapshot; } upsert(record: BtwHistoryRecord): Promise { return this.#upsert(record, false); } /** Retry a failed checkpoint against the original revision, without rebasing onto disk changes. */ retry(record: BtwHistoryRecord): Promise { return this.#upsert(record, true); } async #upsert(record: BtwHistoryRecord, retry: boolean): Promise { if (this.#writeError && !retry) throw this.#writeError; // Capture before yielding: callers may keep mutating their streaming record. const { title, ...fields } = record; const saved = snapshotRecord(parseRecord(fields)); const snapshot = title === undefined ? saved : Object.freeze({ ...saved, title }); if (this.#directory === undefined) { this.#records.set(snapshot.id, snapshot); this.#refreshSnapshot(); return; } const directory = this.#directory; const content = `${JSON.stringify(saved)}\n`; const write = this.#pending.then(async () => { // Reset only at this queue boundary: earlier attempts and their error // handlers must settle before an explicit retry can recover the store. if (retry) this.#writeError = undefined; if (this.#writeError) throw this.#writeError; await fs.mkdir(directory, { recursive: true, mode: 0o700 }); if (process.platform !== "win32") await fs.chmod(directory, 0o700); const filePath = path.join(directory, recordFileName(snapshot.id)); let lease = this.#leases.get(snapshot.id); if (!lease) { lease = await acquireFileLock(filePath); this.#leases.set(snapshot.id, lease); } // Compare while holding the topic lease, including on owner checkpoints. // Missing records and new-id collisions are conflicts, not blind inserts. const stored = await readRecord(filePath); if (stored?.revision !== this.#revisions.get(snapshot.id)) { throw new BtwHistoryConflictError(snapshot.id); } if (!stored) { // The title never changes, so it is written once, before the record is // visible; a stale sidecar from a vanished record must not title a new one. const titlePath = path.join(directory, titleFileName(snapshot.id)); if (title === undefined) await fs.rm(titlePath, { force: true }); else await writeFileAtomically(titlePath, title); } await writeFileAtomically(filePath, content); this.#revisions.set(snapshot.id, new Bun.CryptoHasher("sha256").update(content).digest("hex")); this.#records.set(snapshot.id, snapshot); this.#refreshSnapshot(); if (getBtwLatestTurn(snapshot).status !== "running") { lease.release(); this.#leases.delete(snapshot.id); } }); // Keep the queue handled while retaining the original failure for callers // and every later flush/upsert until an explicit retry. A successful drain must not hide data loss. this.#pending = write.catch(error => { this.#writeError ??= toError(error); // A sticky failure prevents all later terminal writes, so release every // topic this store owns rather than stranding another running lease. for (const lease of this.#leases.values()) lease.release(); this.#leases.clear(); }); await write; } async flush(): Promise { let pending: Promise; do { pending = this.#pending; await pending; } while (pending !== this.#pending); if (this.#writeError) throw this.#writeError; } #refreshSnapshot(): void { this.#snapshot = Object.freeze( [...this.#records.values()].sort( (a, b) => b.createdAt - a.createdAt || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0), ), ); } }