Intelligence

Artifacts

Browse the repository, read documents, and manage the governance folders. Source, runtime, and infrastructure are read-only.

Repository
README.md
CONSTITUTION_COMPLIANCE_AUDIT_V1.mdREADME.md
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