@sema-agent/core
Version:
Stateless, task-oriented AI agent core
406 lines (405 loc) • 20.7 kB
JavaScript
import { ambiguousOriginRepresentation, computeEntryRev, serializeEntryFile } from "./frontmatter.js";
import { screenInboundEntries } from "./data-plane.js";
import { scanMemoryFileName, scanMemoryWrite } from "./scan.js";
import { MAX_MEMORY_BYTES } from "../memory.js";
import { reconcileMemoryEntries } from "./sync.js";
import { MEMORY_ORIGIN_CAUSES } from "./types.js";
function memorySyncPath(scope) {
return `/v1/memory/sync/${encodeURIComponent(scope)}`;
}
function baselineStub(id, rev, scope) {
return { id, rev, scope, slug: `sync-baseline-stub/${id}`, frontmatter: {}, body: "" };
}
const isRecord = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
const isNonEmptyString = (v) => typeof v === "string" && v.length > 0;
function parseMemorySyncResponse(raw, scope, peer) {
const fail = (why) => {
throw new Error(`memory sync: transport response is not a valid sync response (${why})`);
};
if (!isRecord(raw))
fail("body must be a JSON object { applied, conflicts, serverEntries, serverDeletes, cursor }");
const b = raw;
const applied = [];
if (!Array.isArray(b["applied"]))
fail("applied must be an array");
for (const a of b["applied"]) {
if (!isRecord(a) || (a["op"] !== "add" && a["op"] !== "update" && a["op"] !== "delete") || !isNonEmptyString(a["id"])) {
fail("each applied item must be { op: add|update|delete, id }");
}
const rec = a;
applied.push({ op: rec["op"], id: rec["id"], ...(isNonEmptyString(rec["slug"]) ? { slug: rec["slug"] } : {}) });
}
const conflicts = [];
if (!Array.isArray(b["conflicts"]))
fail("conflicts must be an array");
for (const c of b["conflicts"]) {
if (!isRecord(c) || !isNonEmptyString(c["id"]) || typeof c["reason"] !== "string")
fail("each conflict must be { id, reason }");
const rec = c;
conflicts.push({
id: rec["id"],
reason: rec["reason"],
...(isNonEmptyString(rec["baseRev"]) ? { baseRev: rec["baseRev"] } : {}),
...(isNonEmptyString(rec["currentRev"]) ? { currentRev: rec["currentRev"] } : {}),
});
}
const serverEntries = [];
if (!Array.isArray(b["serverEntries"]))
fail("serverEntries must be an array");
for (const raw2 of b["serverEntries"]) {
if (!isRecord(raw2))
fail("each server entry must be an object { id, slug, frontmatter, body, rev, scope }");
const e = raw2;
if (!isNonEmptyString(e["id"]) || !isNonEmptyString(e["slug"]) || typeof e["body"] !== "string" || !isNonEmptyString(e["rev"])) {
fail("each server entry must carry non-empty id/slug/rev and a string body");
}
if (e["scope"] !== scope)
fail(`server entry ${JSON.stringify(e["id"])} has scope ${JSON.stringify(e["scope"])} — must equal the synced scope`);
const fmRaw = e["frontmatter"] ?? {};
if (!isRecord(fmRaw))
fail("entry.frontmatter must be an object");
const fm = fmRaw;
for (const k of ["name", "description", "type"]) {
if (fm[k] !== undefined && typeof fm[k] !== "string")
fail(`entry.frontmatter.${k} must be a string when present`);
}
if (fm["deleted"] !== undefined && typeof fm["deleted"] !== "boolean")
fail("entry.frontmatter.deleted must be a boolean when present");
if (fm["extra"] !== undefined && !(Array.isArray(fm["extra"]) && fm["extra"].every((s) => typeof s === "string"))) {
fail("entry.frontmatter.extra must be an array of strings when present");
}
if (fm["trust"] !== undefined && fm["trust"] !== "untrusted")
fail('entry.frontmatter.trust must be "untrusted" when present');
const provWire = fm["provenance"];
if (provWire !== undefined &&
(!isRecord(provWire) ||
provWire["kind"] !== "repo_file" ||
!isNonEmptyString(provWire["path"]) ||
!isNonEmptyString(provWire["contentHash"]) ||
typeof provWire["ingestedAt"] !== "number" ||
!Number.isFinite(provWire["ingestedAt"]))) {
fail('entry.frontmatter.provenance must be { kind: "repo_file", path, contentHash, ingestedAt } when present');
}
const originWire = fm["origin"];
if (originWire !== undefined &&
(!isRecord(originWire) ||
originWire["taint"] !== "external" ||
typeof originWire["at"] !== "number" ||
!Number.isFinite(originWire["at"]) ||
(originWire["cause"] !== undefined && !(typeof originWire["cause"] === "string" && MEMORY_ORIGIN_CAUSES.includes(originWire["cause"]))))) {
fail('entry.frontmatter.origin must be { taint: "external", cause?, at } with a known cause when present');
}
const distilledWire = fm["distilled"];
if (distilledWire !== undefined &&
(!isRecord(distilledWire) ||
!isNonEmptyString(distilledWire["planId"]) ||
typeof distilledWire["at"] !== "number" ||
!Number.isFinite(distilledWire["at"]) ||
!isNonEmptyString(distilledWire["carrierRev"]) ||
!(Array.isArray(distilledWire["inputs"]) &&
distilledWire["inputs"].length > 0 &&
distilledWire["inputs"].every((row) => isRecord(row) && isNonEmptyString(row["id"]) && isNonEmptyString(row["rev"]) && (row["superseded"] === undefined || row["superseded"] === true))))) {
fail("entry.frontmatter.distilled must be { planId, at, carrierRev, inputs: [{ id, rev, superseded? }, …] } when present");
}
const extraRaw = fm["extra"];
if (Array.isArray(extraRaw) && extraRaw.every((s) => typeof s === "string")) {
const pickedOrigin = originWire !== undefined
? {
taint: "external",
...(originWire["cause"] !== undefined ? { cause: originWire["cause"] } : {}),
at: originWire["at"],
}
: undefined;
if (ambiguousOriginRepresentation({ ...(pickedOrigin !== undefined ? { origin: pickedOrigin } : {}), extra: fm["extra"] })) {
fail("entry.frontmatter carries conflicting or duplicated origin representations (ambiguous marker representation refused)");
}
}
serverEntries.push({
id: e["id"],
slug: e["slug"],
body: e["body"],
rev: e["rev"],
scope,
frontmatter: {
...(fm["name"] !== undefined ? { name: fm["name"] } : {}),
...(fm["description"] !== undefined ? { description: fm["description"] } : {}),
...(fm["type"] !== undefined ? { type: fm["type"] } : {}),
...(fm["deleted"] !== undefined ? { deleted: fm["deleted"] } : {}),
...(provWire !== undefined
? {
provenance: {
kind: "repo_file",
path: provWire["path"],
contentHash: provWire["contentHash"],
ingestedAt: provWire["ingestedAt"],
},
}
: {}),
...(fm["trust"] !== undefined ? { trust: "untrusted" } : {}),
...(originWire !== undefined
? {
origin: {
taint: "external",
...(originWire["cause"] !== undefined ? { cause: originWire["cause"] } : {}),
at: originWire["at"],
},
}
: {}),
...(distilledWire !== undefined
? {
distilled: {
planId: distilledWire["planId"],
at: distilledWire["at"],
carrierRev: distilledWire["carrierRev"],
inputs: distilledWire["inputs"].map((row) => ({
id: row["id"],
rev: row["rev"],
...(row["superseded"] === true ? { superseded: true } : {}),
})),
},
}
: {}),
...(fm["extra"] !== undefined ? { extra: [...fm["extra"]] } : {}),
},
});
}
const serverDeletes = [];
if (!Array.isArray(b["serverDeletes"]))
fail("serverDeletes must be an array");
for (const d of b["serverDeletes"]) {
if (!isRecord(d) || !isNonEmptyString(d["id"]) || !isNonEmptyString(d["baseRev"]))
fail("each server delete must be { id, baseRev }");
const rec = d;
serverDeletes.push({ id: rec["id"], baseRev: rec["baseRev"] });
}
const cur = b["cursor"];
if (!isRecord(cur))
fail("cursor must be an object { peer, baseRevs, updatedAtMs }");
const curRec = cur;
if (curRec["peer"] !== peer)
fail(`cursor.peer ${JSON.stringify(curRec["peer"])} does not match the requested peer ${JSON.stringify(peer)}`);
if (!isRecord(curRec["baseRevs"]))
fail("cursor.baseRevs must be an object mapping id → rev");
const baseRevs = {};
for (const [id, rev] of Object.entries(curRec["baseRevs"])) {
if (!isNonEmptyString(rev))
fail(`cursor.baseRevs[${JSON.stringify(id)}] must be a non-empty rev string`);
baseRevs[id] = rev;
}
const updatedAtMs = curRec["updatedAtMs"];
if (typeof updatedAtMs !== "number" || !Number.isFinite(updatedAtMs))
fail("cursor.updatedAtMs must be a finite number");
const pullTruncatedRaw = b["pullTruncated"];
if (pullTruncatedRaw !== undefined && typeof pullTruncatedRaw !== "boolean")
fail("pullTruncated must be a boolean when present");
return {
applied,
conflicts,
serverEntries,
serverDeletes,
cursor: { peer, baseRevs, updatedAtMs: updatedAtMs },
...(pullTruncatedRaw === true ? { pullTruncated: true } : {}),
};
}
export async function syncMemoryScope(opts) {
const { backend, scope, transport, cursor } = opts;
if (!isNonEmptyString(scope))
throw new Error("memory sync: scope must be a non-empty string");
const peer = opts.peer ?? cursor?.peer ?? "central";
const now = opts.now ?? Date.now;
if (cursor !== undefined && cursor.scope !== scope) {
throw new Error(`memory sync: cursor is for scope ${JSON.stringify(cursor.scope)}, not ${JSON.stringify(scope)}`);
}
if (cursor !== undefined && cursor.peer !== peer) {
throw new Error(`memory sync: cursor is for peer ${JSON.stringify(cursor.peer)}, not ${JSON.stringify(peer)}`);
}
for (const [name, v] of [["maxPushEntries", opts.maxPushEntries], ["maxPullEntries", opts.maxPullEntries], ["maxEntryBytes", opts.maxEntryBytes]]) {
if (v !== undefined && (!Number.isSafeInteger(v) || v < 1)) {
throw new Error(`memory sync: ${name} must be a positive integer (>= 1) when set, got ${String(v)}`);
}
}
const baseRevs = { ...(cursor?.baseRevs ?? {}) };
const headers = (await backend.listHeaders([scope])).filter((h) => h.scope === scope);
const fetched = (await backend.getByIds(headers.map((h) => h.id))).filter((e) => e.scope === scope);
const local = fetched.filter((e) => e.frontmatter.deleted !== true);
const stubs = Object.entries(baseRevs).map(([id, rev]) => baselineStub(id, rev, scope));
const plan = reconcileMemoryEntries(baseRevs, local, stubs);
if (plan.pull.length > 0 || plan.deleteLocal.length > 0 || plan.conflicts.length > 0 || plan.cleared.length > 0) {
throw new Error("memory sync: internal invariant violated — a baseline-stub peer produced pull/deleteLocal/conflict/cleared legs");
}
const pushGateConflicts = [];
const gatedPush = plan.push.filter((e) => {
const findings = [];
const nameFinding = scanMemoryFileName(`${e.slug}.md`);
if (nameFinding !== undefined)
findings.push(nameFinding);
findings.push(...scanMemoryWrite(serializeEntryFile(e), { maxBytes: opts.maxEntryBytes ?? MAX_MEMORY_BYTES }));
if (findings.length === 0)
return true;
pushGateConflicts.push({
side: "local",
id: e.id,
reason: `push_gate: ${findings.map((f) => `${f.code}: ${f.reason}`).join("; ")} — the entry was NOT pushed (it stays local; fix or remove it to stop this report)`,
});
return false;
});
let pushEntries = gatedPush;
let pushTruncated = false;
if (opts.maxPushEntries !== undefined) {
pushEntries = [...gatedPush].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
if (pushEntries.length > opts.maxPushEntries) {
pushEntries = pushEntries.slice(0, opts.maxPushEntries);
pushTruncated = true;
}
}
const request = {
peer,
baseRevs,
entries: pushEntries,
deletes: plan.deleteRemote.map((d) => d.id),
...(opts.maxPullEntries !== undefined ? { pull: { limit: opts.maxPullEntries } } : {}),
};
const resp = parseMemorySyncResponse(await transport(memorySyncPath(scope), request), scope, peer);
const conflicts = [...pushGateConflicts, ...resp.conflicts.map((c) => ({ side: "server", ...c }))];
const rejected = new Set();
const localIds = new Set(local.map((e) => e.id));
const serverEntryIds = new Set(resp.serverEntries.map((e) => e.id));
for (const e of resp.serverEntries) {
const computed = computeEntryRev(e);
if (computed !== e.rev) {
rejected.add(e.id);
conflicts.push({
side: "local",
id: e.id,
reason: `rev_mismatch: server claimed ${JSON.stringify(e.rev)} but the content hashes to ${JSON.stringify(computed)} (rev is core's computeEntryRev content-hash contract)`,
...(baseRevs[e.id] !== undefined ? { baseRev: baseRevs[e.id] } : {}),
});
continue;
}
if (baseRevs[e.id] === undefined && localIds.has(e.id)) {
rejected.add(e.id);
conflicts.push({
side: "local",
id: e.id,
reason: `untracked_local_exists: server offers ${JSON.stringify(e.id)} as a first-round add, but an entry with that id already exists locally (concurrent create the server had not observed) — not overwritten`,
});
}
}
for (const f of screenInboundEntries(resp.serverEntries.filter((e) => !rejected.has(e.id)))) {
rejected.add(f.id);
conflicts.push({
side: "local",
id: f.id,
reason: `inbound_gate: ${f.findings.map((x) => `${x.code}: ${x.reason}`).join("; ")}`,
...(baseRevs[f.id] !== undefined ? { baseRev: baseRevs[f.id] } : {}),
});
}
const deletePatches = [];
for (const d of resp.serverDeletes) {
if (serverEntryIds.has(d.id)) {
rejected.add(d.id);
conflicts.push({
side: "local",
id: d.id,
reason: `server_delete_and_entry: ${JSON.stringify(d.id)} appears in both serverEntries and serverDeletes of one response — the server cannot both push and delete it in one round`,
baseRev: d.baseRev,
});
continue;
}
const base = baseRevs[d.id];
if (base === undefined) {
rejected.add(d.id);
conflicts.push({
side: "local",
id: d.id,
reason: `server_delete_not_in_baseline: server asked to delete ${JSON.stringify(d.id)} but it is absent from our input baseline (never synced here — a delete may not cross onto an id the server has never seen from us)`,
baseRev: d.baseRev,
});
continue;
}
if (base !== d.baseRev) {
rejected.add(d.id);
conflicts.push({
side: "local",
id: d.id,
reason: `server_delete_baserev_mismatch: server delete claims baseRev ${JSON.stringify(d.baseRev)} but our baseline holds ${JSON.stringify(base)} — refused (concurrent local change since the baseline)`,
baseRev: base,
currentRev: d.baseRev,
});
continue;
}
deletePatches.push({ op: "delete", id: d.id, baseRev: base });
}
const patches = [
...resp.serverEntries
.filter((e) => !rejected.has(e.id))
.map((e) => {
const base = baseRevs[e.id];
return base !== undefined
? { op: "update", id: e.id, entry: e, baseRev: base }
: { op: "add", id: e.id, entry: e, guard: "absent" };
}),
...deletePatches,
];
const report = patches.length > 0 ? await backend.applyPatches(patches) : { applied: [], conflicts: [] };
for (const c of report.conflicts) {
conflicts.push({
side: "local",
id: c.id,
reason: c.reason,
...(c.baseRev !== undefined ? { baseRev: c.baseRev } : {}),
...(c.currentRev !== undefined ? { currentRev: c.currentRev } : {}),
});
}
const cursorAdvanced = rejected.size === 0 && report.conflicts.length === 0;
let nextCursor = cursor;
const warnings = [];
if (cursorAdvanced) {
const pulledRevById = new Map(resp.serverEntries.map((e) => [e.id, e.rev]));
const pushedRevById = new Map(request.entries.map((e) => [e.id, e.rev]));
const serverAppliedIds = new Set(resp.applied.filter((a) => a.op !== "delete").map((a) => a.id));
const disputedIds = new Set(resp.conflicts.map((c) => c.id));
const pushAcked = (id, rev) => rev === pushedRevById.get(id) && (serverAppliedIds.has(id) || !disputedIds.has(id));
const nextBaseRevs = {};
for (const [id, rev] of Object.entries(resp.cursor.baseRevs)) {
if (rev === baseRevs[id] || pushAcked(id, rev) || rev === pulledRevById.get(id)) {
nextBaseRevs[id] = rev;
continue;
}
const retained = baseRevs[id];
const conflictedPush = rev === pushedRevById.get(id);
if (retained !== undefined) {
nextBaseRevs[id] = retained;
warnings.push(conflictedPush
? `cursor_prefill_rejected: server cursor advanced ${JSON.stringify(id)} to our pushed rev ${JSON.stringify(rev)} while reporting that push rejected/conflicted — kept the old baseline ${JSON.stringify(retained)} (the local version stays re-pushed / ladder-escalated next round instead of being silently overwritten)`
: `cursor_prefill_rejected: server cursor advanced ${JSON.stringify(id)} to ${JSON.stringify(rev)} without delivering it this round — kept the old baseline ${JSON.stringify(retained)} (the entry stays pullable next round)`);
}
else {
warnings.push(`cursor_prefill_rejected: server cursor introduced ${JSON.stringify(id)} at ${JSON.stringify(rev)} but never delivered it — dropped from the baseline (accepting it would spuriously delete-propagate an entry never seen here)`);
}
}
for (const op of report.applied) {
if (op.op === "delete") {
delete nextBaseRevs[op.id];
}
else {
const landedRev = pulledRevById.get(op.id);
if (landedRev !== undefined)
nextBaseRevs[op.id] = landedRev;
}
}
nextCursor = { scope, peer, baseRevs: nextBaseRevs, updatedAtMs: now() };
}
return {
ok: cursorAdvanced && conflicts.length === 0,
pushed: { entries: request.entries.map((e) => e.id), deletes: [...request.deletes] },
serverApplied: resp.applied,
localApplied: report.applied,
conflicts,
cursorAdvanced,
cursor: nextCursor,
...(pushTruncated ? { pushTruncated: true } : {}),
...(resp.pullTruncated === true ? { pullTruncated: true } : {}),
...(warnings.length > 0 ? { warnings } : {}),
};
}