import type { BackendProfile, BackendTurnResult, CommandRecord, CreateCommandInput, CreateRunInput, FailureKind, JsonRecord, RunEvent, RunnerRecord, RunRecord, TerminalStatus } from "../common/types.js"; import { AgentRunError } from "../common/errors.js"; import { newId, nowIso, stableHash } from "../common/validation.js"; import { redactJson } from "../common/redaction.js"; import { backendCapabilities } from "../common/backend-profiles.js"; export type MaybePromise = T | Promise; export interface StoreHealth extends JsonRecord { adapter: "memory-self-test" | "postgres"; ready: boolean; reachable: boolean; migrationReady: boolean; migrationId: string | null; failureKind: FailureKind | null; message: string | null; credentialValuesPrinted: false; } export interface AgentRunStore { health(): MaybePromise; createRun(input: CreateRunInput): MaybePromise; getRun(runId: string): MaybePromise; listEvents(runId: string, afterSeq: number, limit: number): MaybePromise; createCommand(runId: string, input: CreateCommandInput): MaybePromise; getCommand(commandId: string): MaybePromise; listCommands(runId: string, afterSeq: number, limit: number): MaybePromise; registerRunner(input: Partial): MaybePromise; claimRun(runId: string, runnerId: string, leaseMs: number): MaybePromise; heartbeat(runId: string, runnerId: string, leaseMs: number): MaybePromise; ackCommand(commandId: string): MaybePromise; finishCommand(commandId: string, result: Pick): MaybePromise; appendEvent(runId: string, type: RunEvent["type"], payload: JsonRecord): MaybePromise; finishRun(runId: string, result: Pick): MaybePromise; backends(): MaybePromise; close?(): MaybePromise; } export async function openAgentRunStoreFromEnv(env: NodeJS.ProcessEnv = process.env): Promise { const databaseUrl = env.DATABASE_URL?.trim(); if (databaseUrl) { const { createPostgresAgentRunStore } = await import("./postgres-store.js"); return createPostgresAgentRunStore({ connectionString: databaseUrl }); } const storeMode = env.AGENTRUN_STORE ?? env.AGENTRUN_MGR_STORE; if (storeMode === "memory") return new MemoryAgentRunStore(); throw new AgentRunError("infra-failed", "DATABASE_URL is required for agentrun-mgr live runtime; set AGENTRUN_STORE=memory only for explicit self-test/dev mode", { httpStatus: 503, details: { adapter: "postgres", databaseUrl: "missing", memoryFallback: "disabled" } }); } export class MemoryAgentRunStore implements AgentRunStore { private readonly runs = new Map(); private readonly commands = new Map(); private readonly eventsByRun = new Map(); private readonly runners = new Map(); health(): StoreHealth { return { adapter: "memory-self-test", ready: true, reachable: true, migrationReady: true, migrationId: "memory-self-test", failureKind: null, message: null, credentialValuesPrinted: false }; } createRun(input: CreateRunInput): RunRecord { const at = nowIso(); const run: RunRecord = { ...input, id: newId("run"), status: "pending", terminalStatus: null, failureKind: null, failureMessage: null, createdAt: at, updatedAt: at, claimedBy: null, leaseExpiresAt: null }; this.runs.set(run.id, run); this.eventsByRun.set(run.id, []); this.appendEvent(run.id, "backend_status", { phase: "run-created", backendProfile: run.backendProfile }); return run; } getRun(runId: string): RunRecord { const run = this.runs.get(runId); if (!run) throw new AgentRunError("schema-invalid", `run ${runId} was not found`, { httpStatus: 404 }); return run; } listEvents(runId: string, afterSeq: number, limit: number): RunEvent[] { this.getRun(runId); return (this.eventsByRun.get(runId) ?? []).filter((event) => event.seq > afterSeq).slice(0, Math.max(1, Math.min(limit, 500))); } createCommand(runId: string, input: CreateCommandInput): CommandRecord { this.getRun(runId); const payloadHash = stableHash(input.payload); if (input.idempotencyKey) { const existing = Array.from(this.commands.values()).find((command) => command.runId === runId && command.idempotencyKey === input.idempotencyKey); if (existing) { if (existing.payloadHash !== payloadHash) throw new AgentRunError("schema-invalid", "idempotency key reused with different payload", { httpStatus: 409 }); return existing; } } const at = nowIso(); const seq = Array.from(this.commands.values()).filter((command) => command.runId === runId).length + 1; const command: CommandRecord = { ...input, id: newId("cmd"), runId, seq, state: "pending", payloadHash, createdAt: at, updatedAt: at, acknowledgedAt: null }; this.commands.set(command.id, command); this.appendEvent(runId, "backend_status", { phase: "command-created", commandId: command.id, commandType: command.type }); return command; } getCommand(commandId: string): CommandRecord { const command = this.commands.get(commandId); if (!command) throw new AgentRunError("schema-invalid", `command ${commandId} was not found`, { httpStatus: 404 }); return command; } listCommands(runId: string, afterSeq: number, limit: number): CommandRecord[] { this.getRun(runId); return Array.from(this.commands.values()).filter((command) => command.runId === runId && command.seq > afterSeq).slice(0, Math.max(1, Math.min(limit, 100))); } registerRunner(input: Partial): RunnerRecord { const at = nowIso(); const runner: RunnerRecord = { id: input.id ?? newId("runner"), registeredAt: at, heartbeatAt: at, ...input }; this.runners.set(runner.id, runner); return runner; } claimRun(runId: string, runnerId: string, leaseMs: number): RunRecord { const run = this.getRun(runId); if (run.claimedBy && run.claimedBy !== runnerId && run.status !== "completed" && run.status !== "failed" && run.status !== "cancelled") throw new AgentRunError("runner-lease-conflict", `run ${runId} is already claimed`, { httpStatus: 409 }); const next = this.updateRun(runId, { status: "claimed", claimedBy: runnerId, leaseExpiresAt: new Date(Date.now() + leaseMs).toISOString() }); this.appendEvent(runId, "backend_status", { phase: "run-claimed", runnerId }); return next; } heartbeat(runId: string, runnerId: string, leaseMs: number): RunRecord { const run = this.getRun(runId); if (run.claimedBy !== runnerId) throw new AgentRunError("runner-lease-conflict", `run ${runId} is not claimed by ${runnerId}`, { httpStatus: 409 }); return this.updateRun(runId, { leaseExpiresAt: new Date(Date.now() + leaseMs).toISOString() }); } ackCommand(commandId: string): CommandRecord { const command = this.getCommand(commandId); const next = { ...command, state: "acknowledged" as const, acknowledgedAt: nowIso(), updatedAt: nowIso() }; this.commands.set(commandId, next); return next; } finishCommand(commandId: string, result: Pick): CommandRecord { const command = this.getCommand(commandId); const next = { ...command, state: commandStateFromTerminal(result.terminalStatus), updatedAt: nowIso() }; this.commands.set(commandId, next); this.appendEvent(command.runId, "backend_status", { phase: "command-terminal", commandId, state: next.state, terminalStatus: result.terminalStatus, failureKind: result.failureKind }); return next; } appendEvent(runId: string, type: RunEvent["type"], payload: JsonRecord): RunEvent { this.getRun(runId); const events = this.eventsByRun.get(runId) ?? []; const event: RunEvent = { id: newId("evt"), runId, seq: events.length + 1, type, payload: redactJson(payload), createdAt: nowIso() }; events.push(event); this.eventsByRun.set(runId, events); return event; } finishRun(runId: string, result: Pick): RunRecord { const status = statusFromTerminal(result.terminalStatus); const next = this.updateRun(runId, { status, terminalStatus: result.terminalStatus, failureKind: result.failureKind, failureMessage: result.failureMessage }); this.appendEvent(runId, "terminal_status", { terminalStatus: result.terminalStatus, failureKind: result.failureKind, message: result.failureMessage }); return next; } backends(): JsonRecord[] { return backendCapabilities(); } private updateRun(runId: string, patch: Partial): RunRecord { const run = this.getRun(runId); const next = { ...run, ...patch, updatedAt: nowIso() }; this.runs.set(runId, next); return next; } } export function statusFromTerminal(terminalStatus: TerminalStatus): RunRecord["status"] { if (terminalStatus === "completed") return "completed"; if (terminalStatus === "cancelled") return "cancelled"; if (terminalStatus === "blocked") return "blocked"; return "failed"; } export function commandStateFromTerminal(terminalStatus: TerminalStatus): CommandRecord["state"] { if (terminalStatus === "completed") return "completed"; if (terminalStatus === "cancelled") return "cancelled"; return "failed"; }