@sema-agent/core
Version:
Stateless, task-oriented AI agent core
303 lines (302 loc) • 12.4 kB
JavaScript
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`);
}
}