UNPKG

@sema-agent/core

Version:

Stateless, task-oriented AI agent core

303 lines (302 loc) • 12.4 kB
import { existsSync, readdirSync, rmSync, statSync } from "node:fs"; import { join } from "node:path"; import { BaseSessionStorage, StoredSession, SessionError, assertForkTarget, forkRowCells, getEntriesToFork, incarnationCell, uuidv7, validateEntriesForImport, } from "../../internal/harness.js"; import { canonicalStoreKey, AppendLog, atomicWriteFile, ensureDir, readJsonlRecords, sanitizePathComponent } from "./fs-atomic.js"; import { guardCorruptSink } from "./corrupt-read-sink.js"; import { assertAdoptionBootGate } from "./adoption/marker.js"; import { thrownText } from "../../core/thrown-value.js"; const SUFFIX = ".jsonl"; const SESSION_LINE_JUDGE = { accepts: (r) => { const line = r; return line.kind === "meta" || (line.kind === "entry" && line.entry !== null && typeof line.entry === "object"); }, what: "a session line is neither a meta header nor an entry record", }; const sharedSessionStorages = new Map(); const sessionStorageFinalizer = new FinalizationRegistry(({ canonical, log }) => { log.close(); if (sharedSessionStorages.get(canonical)?.deref() === undefined) sharedSessionStorages.delete(canonical); }); function evictSharedSessionStorage(canonical) { const live = sharedSessionStorages.get(canonical)?.deref(); sharedSessionStorages.delete(canonical); if (live !== undefined) { sessionStorageFinalizer.unregister(live); live.closeLog(); } } class FileSessionStorage extends BaseSessionStorage { log; canonicalPath; holders = new Set(); constructor(log, meta, entries, canonicalPath) { super(meta, entries); this.log = log; this.canonicalPath = canonicalPath; } addHolder(holder) { this.holders.add(holder); } removeHolder(holder) { this.holders.delete(holder); return this.holders.size === 0; } closeLog() { this.log.close(); } persistSync(entry) { this.log.append({ kind: "entry", entry }, true); } async appendEntry(entry, opts) { this.assertExpectedLeaf(opts); this.persistSync(entry); this.recordEntry(entry); } async setLeafId(leafId, opts) { this.assertExpectedLeaf(opts); const entry = this.createLeafEntry(leafId); this.persistSync(entry); this.recordEntry(entry); } } export class FileSessionRepo { dir; tmpDir; onCorruptRead; joined = new Map(); constructor(root, opts) { assertAdoptionBootGate(root, "FileSessionRepo"); this.dir = join(root, "sessions"); this.tmpDir = join(root, "tmp"); ensureDir(this.dir); ensureDir(this.tmpDir); this.onCorruptRead = guardCorruptSink(opts?.onCorruptRead); } disclose(path, reason, sessionId) { this.onCorruptRead?.({ kind: "file", path, reason, ...(sessionId !== undefined ? { sessionId } : {}) }); } pathFor(id) { return join(this.dir, `${sanitizePathComponent(id)}${SUFFIX}`); } read(id) { const path = this.pathFor(id); if (!existsSync(path)) { throw new SessionError("not_found", `Session not found: ${id}`); } let tally; const lines = readJsonlRecords(path, (info) => { if (info.kind === "replay") tally = info; this.onCorruptRead?.({ ...info, sessionId: id }); }, SESSION_LINE_JUDGE); let meta; const entries = []; for (const line of lines) { if (line.kind === "meta") meta = line; else entries.push(line.entry); } if (meta === undefined) { throw new SessionError("corrupt_session", `Session ${id} is unreadable: its log has no meta header (${tally?.dropped ?? 0} of ${tally?.total ?? lines.length} records skipped as corrupt)`); } const { createdAt, forkedFrom, placement, incarnation, forkedFromIncarnation } = meta; for (const [key, token] of [["incarnation", incarnation], ["forkedFromIncarnation", forkedFromIncarnation]]) { if (token !== undefined && typeof token !== "string") this.disclose(path, `session header carries a non-string ${key} (${typeof token}) — read as an UNKNOWN occupant`, id); } return { meta: { id, createdAt, ...(forkedFrom !== undefined ? { forkedFrom } : {}), ...(placement !== undefined ? { placement } : {}), ...(typeof incarnation === "string" ? { incarnation } : {}), ...(typeof forkedFromIncarnation === "string" ? { forkedFromIncarnation } : {}), }, entries, }; } storage(meta, entries) { const { id } = meta; const canonical = canonicalStoreKey(this.pathFor(id)); const live = sharedSessionStorages.get(canonical)?.deref(); if (live !== undefined) { live.addHolder(this); this.joined.set(canonical, new WeakRef(live)); return live; } const log = new AppendLog(this.pathFor(id)); const created = new FileSessionStorage(log, meta, entries, canonical); sharedSessionStorages.set(canonical, new WeakRef(created)); created.addHolder(this); this.joined.set(canonical, new WeakRef(created)); sessionStorageFinalizer.register(created, { canonical, log }, created); return created; } async dispose() { for (const [canonical, ref] of this.joined) { const mine = ref.deref(); if (mine === undefined) continue; if (sharedSessionStorages.get(canonical)?.deref() !== mine) continue; if (mine.removeHolder(this)) evictSharedSessionStorage(canonical); } this.joined.clear(); } async create(options = {}) { const id = options.id ?? uuidv7(); const createdAt = new Date().toISOString(); const path = this.pathFor(id); if (!existsSync(path)) { evictSharedSessionStorage(canonicalStoreKey(path)); const placement = options.placement !== undefined ? { ...options.placement, placedAt: options.placement.placedAt ?? Date.now() } : undefined; const meta = { id, createdAt, ...(placement !== undefined ? { placement } : {}), ...incarnationCell(undefined) }; atomicWriteFile(this.tmpDir, path, `${JSON.stringify({ kind: "meta", ...meta })}\n`); return new StoredSession(this.storage(meta, [])); } const { meta, entries } = this.read(id); return new StoredSession(this.storage({ ...meta, createdAt: meta.createdAt || createdAt }, entries)); } async open(metadata) { const { meta, entries } = this.read(metadata.id); return new StoredSession(this.storage(meta, entries)); } async list() { let names; try { names = readdirSync(this.dir); } catch (err) { if (err.code !== "ENOENT") { this.disclose(this.dir, `session directory unreadable (${err.code ?? "unknown"}) — listed as empty`); } return []; } const out = []; for (const name of names) { if (!name.endsWith(SUFFIX)) continue; const id = name.slice(0, -SUFFIX.length); try { const { createdAt, forkedFrom, placement } = this.read(id).meta; if (placement !== undefined) continue; out.push({ id, createdAt, ...(forkedFrom !== undefined ? { forkedFrom } : {}) }); } catch (err) { if (!(err instanceof SessionError && err.code === "not_found")) { this.disclose(this.pathFor(id), `session log unreadable (${thrownText(err)}) — skipped from the listing`, id); } } } return out.sort((a, b) => a.createdAt.localeCompare(b.createdAt)); } async listPlaced(kind, opts) { let names; try { names = readdirSync(this.dir); } catch (err) { if (err.code !== "ENOENT") { this.disclose(this.dir, `session directory unreadable (${err.code ?? "unknown"}) — placed listing degraded to empty`); } return []; } const now = Date.now(); const out = []; for (const name of names) { if (!name.endsWith(SUFFIX)) continue; const id = name.slice(0, -SUFFIX.length); let placement; try { placement = this.read(id).meta.placement; } catch (err) { if (!(err instanceof SessionError && err.code === "not_found")) { this.disclose(this.pathFor(id), `session log unreadable (${thrownText(err)}) — skipped from the placed listing`, id); } continue; } if (placement === undefined || placement.kind !== kind) continue; if (opts?.scope !== undefined && placement.scope !== opts.scope) continue; if (opts?.olderThanMs !== undefined) { let ageAnchor = placement.placedAt; try { ageAnchor = statSync(this.pathFor(id)).mtimeMs; } catch { } if (now - ageAnchor <= opts.olderThanMs) continue; } out.push(placement.scope !== undefined && placement.handle !== undefined ? { sessionId: id, placedAt: placement.placedAt, scope: placement.scope, handle: placement.handle } : { sessionId: id, placedAt: placement.placedAt, tupleIncomplete: true }); } return out; } priorHeader(id) { try { return this.read(id).meta; } catch (err) { return err instanceof SessionError && err.code === "not_found" ? undefined : {}; } } async placementOf(sessionId) { try { return this.read(sessionId).meta.placement; } catch { return undefined; } } async delete(metadata) { evictSharedSessionStorage(canonicalStoreKey(this.pathFor(metadata.id))); try { rmSync(this.pathFor(metadata.id), { force: true }); } catch (err) { throw new SessionError("storage", `session ${metadata.id} could not be deleted: ${thrownText(err)}`, err instanceof Error ? err : undefined); } } async fork(sourceMetadata, options = {}) { assertForkTarget(sourceMetadata.id, options.id); const source = await this.open(sourceMetadata); const forked = await getEntriesToFork(source.getStorage(), options); const id = options.id ?? uuidv7(); const createdAt = new Date().toISOString(); const path = this.pathFor(id); const meta = { id, createdAt, ...forkRowCells(await source.getMetadata()) }; const lines = [JSON.stringify({ kind: "meta", ...meta })]; for (const entry of forked) { lines.push(JSON.stringify({ kind: "entry", entry })); } evictSharedSessionStorage(canonicalStoreKey(path)); atomicWriteFile(this.tmpDir, path, `${lines.join("\n")}\n`); return new StoredSession(this.storage(meta, [...forked])); } async exportEntries(sessionId) { return this.read(sessionId).entries; } async importEntries(sessionId, owner, entries, options) { void owner; const validated = validateEntriesForImport(entries, options); const prior = this.priorHeader(sessionId); evictSharedSessionStorage(canonicalStoreKey(this.pathFor(sessionId))); const createdAt = new Date().toISOString(); const lines = [JSON.stringify({ kind: "meta", id: sessionId, createdAt, ...incarnationCell(prior) })]; for (const entry of validated) { lines.push(JSON.stringify({ kind: "entry", entry })); } atomicWriteFile(this.tmpDir, this.pathFor(sessionId), `${lines.join("\n")}\n`); } }