import fs from "node:fs"; import fsp from "node:fs/promises"; import path from "node:path"; const MAX_BUFFER_BYTES = 2 * 1024 * 1024; export class SessionStore { constructor(directory) { this.directory = directory; this.sessions = new Map(); } async init() { await fsp.mkdir(this.directory, { recursive: true, mode: 0o700 }); const files = await fsp.readdir(this.directory); for (const file of files.filter((name) => name.endsWith(".json"))) { try { const record = JSON.parse(await fsp.readFile(path.join(this.directory, file), "utf8")); if (record.status === "running") { record.status = "interrupted"; record.endedAt = new Date().toISOString(); } this.sessions.set(record.id, { ...record, archived: true, clients: new Set(), buffer: "" }); } catch { // A partial metadata file must not prevent the runner from starting. } } } add(record) { const session = { ...record, clients: new Set(), buffer: "", errorBuffer: "", archived: false }; this.sessions.set(session.id, session); this.persist(session); return session; } get(id) { return this.sessions.get(id); } publicRecord(session) { const { clients, pty, childProcess, buffer, errorBuffer, logStream, environment, archived, ...record } = session; return { ...record, connectedClients: clients?.size || 0, archived: Boolean(archived) }; } list() { return [...this.sessions.values()] .map((session) => this.publicRecord(session)) .sort((a, b) => b.createdAt.localeCompare(a.createdAt)); } append(session, data) { session.buffer += data; if (Buffer.byteLength(session.buffer) > MAX_BUFFER_BYTES) { session.buffer = session.buffer.slice(-MAX_BUFFER_BYTES); } if (!session.logStream) { session.logStream = fs.createWriteStream(path.join(this.directory, `${session.id}.log`), { flags: "a", mode: 0o600, }); } session.logStream.write(data); } async readOutput(session) { if (session.buffer) return session.buffer; try { return await fsp.readFile(path.join(this.directory, `${session.id}.log`), "utf8"); } catch { return ""; } } finish(session, status, exitCode = null, signal = null) { session.status = status; session.exitCode = exitCode; session.signal = signal; session.endedAt = new Date().toISOString(); session.logStream?.end(); session.logStream = undefined; this.persist(session); } persist(session) { const target = path.join(this.directory, `${session.id}.json`); const temp = `${target}.tmp`; const body = `${JSON.stringify(this.publicRecord(session), null, 2)}\n`; fsp.writeFile(temp, body, { mode: 0o600 }) .then(() => fsp.rename(temp, target)) .catch(() => {}); } }