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