Intelligence
Artifacts
Browse the repository, read documents, and manage the governance folders. Source, runtime, and infrastructure are read-only.
Repository
repositories/aaf-holdings/hq01/lib/sessions/store.ts
4.0 KB
import fs from "node:fs";
import path from "node:path";
import { SESSIONS_ROOT } from "./config";
import type { Session, SessionMetadata } from "./types";
/**
* Filesystem store for sessions — the source of truth for the session manager.
*
* Layout (one directory per session under SESSIONS_ROOT):
* <id>/session.json live, mutable state (status, pid, timestamps…)
* <id>/metadata.json immutable creation record
* <id>/stdout.log captured standard output
* <id>/stderr.log captured standard error
*
* No database, no cross-process locks: writes are atomic (write-temp + rename)
* and reads tolerate partially-initialised or corrupt directories by returning
* null rather than throwing.
*/
/** A session id is a safe slug — guards every path we build from user input. */
const ID_RE = /^[a-z0-9][a-z0-9-]{0,80}$/;
export function isValidId(id: string): boolean {
return ID_RE.test(id);
}
export function sessionDir(id: string): string {
return path.join(SESSIONS_ROOT, id);
}
export function sessionJsonPath(id: string): string {
return path.join(sessionDir(id), "session.json");
}
export function metadataPath(id: string): string {
return path.join(sessionDir(id), "metadata.json");
}
export function stdoutPath(id: string): string {
return path.join(sessionDir(id), "stdout.log");
}
export function stderrPath(id: string): string {
return path.join(sessionDir(id), "stderr.log");
}
export function ensureSessionDir(id: string): void {
fs.mkdirSync(sessionDir(id), { recursive: true });
}
function writeJsonAtomic(file: string, data: unknown): void {
fs.mkdirSync(path.dirname(file), { recursive: true });
const tmp = `${file}.tmp`;
fs.writeFileSync(tmp, JSON.stringify(data, null, 2) + "\n", "utf8");
fs.renameSync(tmp, file);
}
function readJson<T>(file: string): T | null {
try {
return JSON.parse(fs.readFileSync(file, "utf8")) as T;
} catch {
return null;
}
}
export function writeSession(session: Session): void {
writeJsonAtomic(sessionJsonPath(session.id), session);
}
export function writeMetadata(meta: SessionMetadata): void {
writeJsonAtomic(metadataPath(meta.id), meta);
}
export function readSession(id: string): Session | null {
if (!isValidId(id)) return null;
return readJson<Session>(sessionJsonPath(id));
}
export function readMetadata(id: string): SessionMetadata | null {
if (!isValidId(id)) return null;
return readJson<SessionMetadata>(metadataPath(id));
}
/** All session ids that have a session.json, newest directories tolerated. */
export function listSessionIds(): string[] {
let entries: fs.Dirent[];
try {
entries = fs.readdirSync(SESSIONS_ROOT, { withFileTypes: true });
} catch {
return [];
}
return entries
.filter((e) => e.isDirectory() && isValidId(e.name))
.map((e) => e.name)
.filter((id) => fs.existsSync(sessionJsonPath(id)));
}
/** ISO timestamp of the most recent write to a session's stdout log. */
export function logModifiedAt(id: string): string | null {
try {
const stat = fs.statSync(stdoutPath(id));
return stat.mtime.toISOString();
} catch {
return null;
}
}
/**
* Return the trailing `maxBytes` of a file as text. Reads only the tail rather
* than loading the whole (potentially large) log into memory, and drops a
* partial first line so the result starts cleanly.
*/
export function tailFile(file: string, maxBytes: number): string {
let fd: number | null = null;
try {
const stat = fs.statSync(file);
const start = Math.max(0, stat.size - maxBytes);
const length = stat.size - start;
if (length <= 0) return "";
fd = fs.openSync(file, "r");
const buf = Buffer.alloc(length);
fs.readSync(fd, buf, 0, length, start);
let text = buf.toString("utf8");
// If we started mid-file, discard the partial first line.
if (start > 0) {
const nl = text.indexOf("\n");
if (nl >= 0) text = text.slice(nl + 1);
}
return text;
} catch {
return "";
} finally {
if (fd !== null) {
try {
fs.closeSync(fd);
} catch {
/* ignore */
}
}
}
}
root · /srv/aaf