UNPKG

@sema-agent/core

Version:

Stateless, task-oriented AI agent core

256 lines (255 loc) • 9.82 kB
import { existsSync, mkdirSync, readFileSync, rmSync, readdirSync } from "node:fs"; import { join, resolve } from "node:path"; import { randomUUID } from "node:crypto"; import { readMailboxPeerMeta } from "../../core/mailbox-store.js"; import { atomicWriteFile, canonicalStoreKey } from "../file/fs-atomic.js"; import { withCcLock } from "./lockfile.js"; const INBOX_FILE_EXT = ".json"; export function sanitizeCcAgentName(name) { return name.replace(/[^a-zA-Z0-9]/g, "-").toLowerCase(); } const ccLeaseTables = new Map(); function sharedCcLeases(key) { let t = ccLeaseTables.get(key); if (t === undefined) { t = new Map(); ccLeaseTables.set(key, t); } return t; } export function createCcFileMailboxStore(opts) { const inboxDir = join(resolve(opts.teamDir), "inboxes"); const leases = sharedCcLeases(canonicalStoreKey(inboxDir)); const requireDefaultScope = (scope) => { if (scope !== "default") { throw new Error(`CcFileMailboxStore: CC inbox files carry no tenant axis — scope:"default" only (D-1); got ${JSON.stringify(scope)}`); } }; const leaseKey = (handle) => sanitizeCcAgentName(opts.agentNameOf?.(handle) ?? handle); const inboxPath = (handle) => join(inboxDir, `${leaseKey(handle)}${INBOX_FILE_EXT}`); const discloseCorrupt = (path, reason) => { try { opts.onCorruptRead?.({ path, reason }); } catch { } }; const deferredNotes = () => { const pending = []; return { note: (path, reason) => { pending.push({ path, reason }); }, flush: () => { const batch = pending.splice(0, pending.length); for (const n of batch) discloseCorrupt(n.path, n.reason); }, }; }; const entryDefect = (e) => { if (e === null) return "null entry"; if (Array.isArray(e)) return "array entry"; if (typeof e !== "object") return `${typeof e} entry`; return undefined; }; const loadBox = (path, note) => { let raw; try { raw = readFileSync(path, "utf8"); } catch (err) { if (err.code !== "ENOENT") note(path, `read failed (non-ENOENT): ${err.message}`); return []; } let parsed; try { parsed = JSON.parse(raw); } catch { note(path, "unparseable inbox JSON (torn write?)"); return []; } if (!Array.isArray(parsed)) { note(path, "inbox document is not an array"); return []; } const kept = []; let dropped = 0; let firstReason; for (const e of parsed) { const defect = entryDefect(e); if (defect === undefined) { kept.push(e); continue; } dropped += 1; if (firstReason === undefined) firstReason = defect; } if (dropped > 0) note(path, `${dropped} malformed inbox entries skipped (first: ${firstReason})`); return kept; }; const readBackPeerMeta = (raw) => { try { const meta = readMailboxPeerMeta(raw); return meta === undefined ? {} : { peerMeta: meta }; } catch { return {}; } }; const saveBox = (path, box) => { atomicWriteFile(inboxDir, path, JSON.stringify(box, null, 2)); }; const isColderThan = (box, now, maxAgeMs) => { if (box.length === 0) return false; const newest = box.reduce((acc, e) => { const t = typeof e.timestamp === "string" ? Date.parse(e.timestamp) : Number.NaN; return Number.isFinite(t) ? Math.max(acc, t) : Number.POSITIVE_INFINITY; }, 0); return newest !== Number.POSITIVE_INFINITY && now - newest > maxAgeMs; }; const sweepColdBoxes = async (now, maxAgeMs) => { let dropped = 0; for (const f of readdirSync(inboxDir)) { if (!f.endsWith(INBOX_FILE_EXT)) continue; const path = join(inboxDir, f); const boxKey = f.slice(0, -INBOX_FILE_EXT.length); const preNotes = deferredNotes(); if (!isColderThan(loadBox(path, preNotes.note), now, maxAgeMs)) { preNotes.flush(); continue; } const notes = deferredNotes(); const swept = await withCcLock(path, () => { if (!isColderThan(loadBox(path, notes.note), now, maxAgeMs)) return false; leases.delete(boxKey); rmSync(path, { force: true }); return true; }).finally(() => { notes.flush(); }); if (swept) dropped += 1; } return dropped; }; return { append: (scope, handle, msg) => { requireDefaultScope(scope); const peerMeta = readMailboxPeerMeta(msg.peerMeta); mkdirSync(inboxDir, { recursive: true }); const path = inboxPath(handle); const notes = deferredNotes(); return withCcLock(path, () => { const box = loadBox(path, notes.note); box.push({ type: "message", ...(msg.from !== undefined ? { from: msg.from } : {}), text: msg.content, timestamp: new Date(msg.sentAt).toISOString(), msgV: 1, msg_id: randomUUID(), read: false, ...(msg.hopChain !== undefined ? { hopChain: [...msg.hopChain] } : {}), ...(peerMeta !== undefined ? { peerMeta } : {}), }); saveBox(path, box); return box.length; }).finally(() => { notes.flush(); }); }, claimLease: (scope, handle, owner, ttlMs, now) => { requireDefaultScope(scope); const t = now ?? opts.now?.() ?? Date.now(); const key = leaseKey(handle); const path = inboxPath(handle); const held = leases.get(key); if (held !== undefined && held.expiresAt > t && held.owner !== owner) return Promise.resolve(null); const box = loadBox(path, discloseCorrupt); const maxSeq = box.length; const pending = box .map((e, i) => ({ e, seq: i + 1 })) .filter(({ e }) => e.read !== true) .map(({ e, seq }) => ({ seq, ...(typeof e.from === "string" ? { from: e.from } : {}), content: typeof e.text === "string" ? e.text : "", sentAt: typeof e.timestamp === "string" ? Date.parse(e.timestamp) || 0 : 0, ...(Array.isArray(e.hopChain) && e.hopChain.every((h) => typeof h === "string") ? { hopChain: e.hopChain.filter((h) => typeof h === "string") } : {}), ...readBackPeerMeta(e.peerMeta), })); if (pending.length === 0) return Promise.resolve(null); leases.set(key, { owner, expiresAt: t + ttlMs, maxSeq }); return Promise.resolve({ maxSeq, messages: pending }); }, ack: (scope, handle, owner, upToSeq) => { requireDefaultScope(scope); const path = inboxPath(handle); const held = leases.get(leaseKey(handle)); if (held === undefined || held.owner !== owner) return Promise.resolve({ acked: false }); if (!existsSync(path)) return Promise.resolve({ acked: false }); const notes = deferredNotes(); return withCcLock(path, () => { const box = loadBox(path, notes.note); let changed = false; for (let i = 0; i < Math.min(upToSeq, box.length); i++) { if (box[i].read !== true) { box[i].read = true; changed = true; } } if (changed) saveBox(path, box); if (held.maxSeq <= upToSeq) leases.delete(leaseKey(handle)); return { acked: true }; }).finally(() => { notes.flush(); }); }, releaseLease: (scope, handle, owner) => { requireDefaultScope(scope); const key = leaseKey(handle); const held = leases.get(key); if (held !== undefined && held.owner === owner) leases.delete(key); return Promise.resolve(); }, peekCount: (scope, handle) => { requireDefaultScope(scope); return Promise.resolve(loadBox(inboxPath(handle), discloseCorrupt).filter((e) => e.read !== true).length); }, drop: (scope, handle) => { requireDefaultScope(scope); const path = inboxPath(handle); leases.delete(leaseKey(handle)); return withCcLock(path, () => { rmSync(path, { force: true }); }); }, reap: (scope, now, reapOpts) => { requireDefaultScope(scope); const maxAgeMs = reapOpts?.maxAgeMs; if (maxAgeMs === undefined || !existsSync(inboxDir)) return Promise.resolve(0); return sweepColdBoxes(now, maxAgeMs); }, }; }