// Runs and conversations are addressed by readable names: a machine name // (nested machines use "/", e.g. "chatbot/triage") plus a run id or session // name. Every write is an upsert. The disk store keeps JSON files under one // directory; a cloud volume can implement the same interface later. import { mkdir, readdir, readFile, rename, writeFile } from "node:fs/promises"; import path from "node:path"; import type { Message, Run } from "./types.ts"; export type Session = { machine: string; name: string; messages: Message[]; updatedAt: string; }; export interface Store { putRun(run: Run): Promise; getRun(machine: string, id: string): Promise; /** Newest first. */ listRuns(machine: string, options?: { limit?: number }): Promise; putSession(session: Session): Promise; getSession(machine: string, name: string): Promise; } const SEGMENT = /^[a-z0-9][a-z0-9._-]*$/; /** Validate a machine name, session name, or run id and return its path segments. */ export function nameSegments(name: string, what = "name"): string[] { const segments = name.split("/"); for (const segment of segments) { if (!SEGMENT.test(segment)) { throw new Error(`Invalid ${what} "${name}": use lowercase letters, digits, ".", "_" and "-", with "/" between nested names.`); } } return segments; } /** JSON files under `root`. Files are private to the user: runs hold prompts and outputs. */ export function diskStore(root: string): Store { const runDir = (machine: string) => path.join(root, "runs", ...nameSegments(machine, "machine name")); const sessionFile = (machine: string, name: string) => path.join(root, "sessions", ...nameSegments(machine, "machine name"), `${nameSegments(name, "session name").join("--")}.json`); return { async putRun(run) { nameSegments(run.id, "run id"); await writeJson(path.join(runDir(run.machine), `${run.id}.json`), run); }, async getRun(machine, id) { nameSegments(id, "run id"); return readJson(path.join(runDir(machine), `${id}.json`)); }, async listRuns(machine, { limit = 50 } = {}) { let files: string[]; try { files = await readdir(runDir(machine)); } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return []; throw error; } const newest = files.filter((f) => f.endsWith(".json")).sort().reverse().slice(0, limit); const runs = await Promise.all(newest.map((f) => readJson(path.join(runDir(machine), f)))); return runs.filter((r): r is Run => r !== undefined); }, async putSession(session) { await writeJson(sessionFile(session.machine, session.name), session); }, async getSession(machine, name) { return readJson(sessionFile(machine, name)); }, }; } let lastRunMs = 0; /** * Sortable, readable run id: UTC timestamp plus a short random suffix. Ids * from one process strictly increase, even for runs started in the same * millisecond, so sorting ids sorts runs by start. */ export function newRunId(now = new Date()): string { const ms = Math.max(now.getTime(), lastRunMs + 1); lastRunMs = ms; const stamp = new Date(ms).toISOString().replace(/[:.]/g, "-").replace("T", "t").replace("Z", "z"); return `${stamp}-${Math.random().toString(36).slice(2, 6)}`; } async function writeJson(file: string, value: unknown): Promise { await mkdir(path.dirname(file), { recursive: true, mode: 0o700 }); const tmp = `${file}.${process.pid}.tmp`; await writeFile(tmp, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 }); await rename(tmp, file); } async function readJson(file: string): Promise { try { return JSON.parse(await readFile(file, "utf8")) as T; } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined; throw error; } }