UNPKG

@sema-agent/core

Version:

Stateless, task-oriented AI agent core

98 lines (97 loc) • 3.95 kB
import { terminalProjection } from "./runner/terminal-projection.js"; export const MAX_JOURNAL_RESULT_BYTES = 5 * 1024 * 1024; export function oversizeJournalResult(serialized) { return Buffer.byteLength(serialized, "utf8") > MAX_JOURNAL_RESULT_BYTES; } export function callKeyOrdinal(callKey) { const i = callKey.indexOf(":"); const n = Number.parseInt(i >= 0 ? callKey.slice(0, i) : callKey, 10); return Number.isFinite(n) ? n : 0; } export const JOURNAL_OVERSIZE_ERROR_CODE = "workflow.journal_oversize"; export class WorkflowJournalOversizeError extends Error { callKey; bytes; cap; code = JOURNAL_OVERSIZE_ERROR_CODE; constructor(callKey, bytes, cap = MAX_JOURNAL_RESULT_BYTES) { super(`workflow journal: entry "${callKey}" is ${bytes} bytes, over the ${cap}-byte per-entry cap — refused, nothing journaled`); this.callKey = callKey; this.bytes = bytes; this.cap = cap; this.name = "WorkflowJournalOversizeError"; } } export function assertJournalEntryFits(entry) { const serialized = JSON.stringify(entry.parked !== undefined ? entry.parked : entry.result); if (oversizeJournalResult(serialized)) throw new WorkflowJournalOversizeError(entry.callKey, Buffer.byteLength(serialized, "utf8")); } export function journalOversizeTombstone(result, bytes) { const message = `[journal] result omitted: ${bytes} bytes exceeds the ${MAX_JOURNAL_RESULT_BYTES}-byte per-entry journal cap; ` + `the agent's real terminal status was "${terminalProjection(result.terminal).status}". A resume re-runs this agent (and everything after it) live.`; return { taskId: result.taskId, sessionId: result.sessionId, terminal: { kind: "failed", code: JOURNAL_OVERSIZE_ERROR_CODE, message }, result: message, stats: result.stats, }; } function snapshot(entry) { try { return structuredClone(entry); } catch { return JSON.parse(JSON.stringify(entry)); } } export const IN_MEMORY_RESUME_CLAIM_TTL_MS = 60 * 60 * 1000; export class InMemoryWorkflowJournalStore { runs = new Map(); resumeClaims = new Map(); claimsForScope(scope) { let m = this.resumeClaims.get(scope); if (m === undefined) { m = new Map(); this.resumeClaims.set(scope, m); } return m; } async resumeClaim(input) { const table = this.claimsForScope(input.scope); const now = Date.now(); const existing = table.get(input.sourceRunId); if (existing !== undefined && existing.expiresAt > now && existing.holder !== input.newRunId) { return { granted: false, holder: existing.holder }; } table.set(input.sourceRunId, { holder: input.newRunId, expiresAt: now + IN_MEMORY_RESUME_CLAIM_TTL_MS }); return { granted: true }; } async releaseResumeClaim(input) { const table = this.resumeClaims.get(input.scope); if (table === undefined) return; const existing = table.get(input.sourceRunId); if (existing !== undefined && existing.holder === input.newRunId) table.delete(input.sourceRunId); } async load(runId, scope) { const rec = this.runs.get(runId); if (!rec || rec.scope !== scope) return []; return [...rec.byOrdinal.entries()].sort((a, b) => a[0] - b[0]).map(([, e]) => snapshot(e)); } async append(runId, scope, entry) { assertJournalEntryFits(entry); let rec = this.runs.get(runId); if (!rec) { rec = { scope, byOrdinal: new Map() }; this.runs.set(runId, rec); } else if (rec.scope !== scope) { throw new Error(`workflow journal: runId ${runId} already recorded under a different scope`); } rec.byOrdinal.set(callKeyOrdinal(entry.callKey), snapshot(entry)); } }