feat: add v0.1 runtime skeleton
This commit is contained in:
@@ -0,0 +1,32 @@
|
||||
import type { JsonRecord, JsonValue } from "../common/types.js";
|
||||
import { AgentRunError } from "../common/errors.js";
|
||||
|
||||
export class ManagerClient {
|
||||
constructor(readonly baseUrl: string) {}
|
||||
|
||||
async get(path: string): Promise<JsonValue> {
|
||||
return this.request("GET", path);
|
||||
}
|
||||
|
||||
async post(path: string, body: JsonValue): Promise<JsonValue> {
|
||||
return this.request("POST", path, body);
|
||||
}
|
||||
|
||||
async patch(path: string, body: JsonValue): Promise<JsonValue> {
|
||||
return this.request("PATCH", path, body);
|
||||
}
|
||||
|
||||
private async request(method: string, path: string, body?: JsonValue): Promise<JsonValue> {
|
||||
const init: RequestInit = { method };
|
||||
if (body !== undefined) {
|
||||
init.headers = { "content-type": "application/json" };
|
||||
init.body = JSON.stringify(body);
|
||||
}
|
||||
const response = await fetch(new URL(path, this.baseUrl), init);
|
||||
const text = await response.text();
|
||||
if (text.trim().length === 0) throw new AgentRunError("infra-failed", `manager returned empty response for ${method} ${path}`, { httpStatus: 502 });
|
||||
const envelope = JSON.parse(text) as JsonRecord;
|
||||
if (envelope.ok !== true) throw new AgentRunError(typeof envelope.failureKind === "string" ? envelope.failureKind as never : "infra-failed", typeof envelope.message === "string" ? envelope.message : `manager request failed: ${method} ${path}`, { httpStatus: response.status, details: envelope });
|
||||
return envelope.data ?? null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { startManagerServer } from "./server.js";
|
||||
|
||||
const port = Number(process.env.PORT ?? process.env.AGENTRUN_MGR_PORT ?? "8080");
|
||||
const host = process.env.HOST ?? "0.0.0.0";
|
||||
const started = await startManagerServer({ port, host });
|
||||
console.log(JSON.stringify({ ok: true, serviceId: "agentrun-mgr", baseUrl: started.baseUrl }));
|
||||
@@ -0,0 +1,116 @@
|
||||
import type { Server } from "node:http";
|
||||
import { createServer } from "node:http";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import type { AgentRunStore } from "./store.js";
|
||||
import { MemoryAgentRunStore } from "./store.js";
|
||||
import { AgentRunError, errorToJson } from "../common/errors.js";
|
||||
import { asRecord, validateCreateCommand, validateCreateRun } from "../common/validation.js";
|
||||
import type { ApiErrorBody, ApiOkBody, JsonRecord, JsonValue, RunEvent } from "../common/types.js";
|
||||
|
||||
export interface ManagerServerOptions {
|
||||
store?: AgentRunStore;
|
||||
port?: number;
|
||||
host?: string;
|
||||
sourceCommit?: string;
|
||||
}
|
||||
|
||||
export interface StartedManagerServer {
|
||||
server: Server;
|
||||
baseUrl: string;
|
||||
store: AgentRunStore;
|
||||
}
|
||||
|
||||
export async function startManagerServer(options: ManagerServerOptions = {}): Promise<StartedManagerServer> {
|
||||
const store = options.store ?? new MemoryAgentRunStore();
|
||||
const sourceCommit = options.sourceCommit ?? process.env.AGENTRUN_SOURCE_COMMIT ?? "unknown";
|
||||
const server = createServer(async (req, res) => {
|
||||
const traceId = `trc_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
|
||||
try {
|
||||
const method = req.method ?? "GET";
|
||||
const url = new URL(req.url ?? "/", "http://agentrun.local");
|
||||
const data = await route({ method, url, body: await readBody(req), store, sourceCommit });
|
||||
writeJson(res, 200, { ok: true, data, traceId });
|
||||
} catch (error) {
|
||||
const agentError = normalizeError(error);
|
||||
writeJson(res, agentError.httpStatus, { ok: false, failureKind: agentError.failureKind, message: agentError.message, traceId, error: errorToJson(error) });
|
||||
}
|
||||
});
|
||||
await new Promise<void>((resolve) => server.listen(options.port ?? 0, options.host ?? "127.0.0.1", resolve));
|
||||
const address = server.address() as AddressInfo;
|
||||
return { server, baseUrl: `http://${address.address}:${address.port}`, store };
|
||||
}
|
||||
|
||||
async function readBody(req: import("node:http").IncomingMessage): Promise<unknown> {
|
||||
if (req.method === "GET" || req.method === "HEAD") return null;
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of req) chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
||||
const text = Buffer.concat(chunks).toString("utf8").trim();
|
||||
if (text.length === 0) return null;
|
||||
return JSON.parse(text) as unknown;
|
||||
}
|
||||
|
||||
async function route({ method, url, body, store, sourceCommit }: { method: string; url: URL; body: unknown; store: AgentRunStore; sourceCommit: string }): Promise<JsonValue> {
|
||||
const path = url.pathname;
|
||||
if (method === "GET" && (path === "/health" || path === "/health/live" || path === "/health/readiness")) {
|
||||
return { serviceId: "agentrun-mgr", live: true, ready: true, database: { adapter: "memory-self-test", migrationReady: true }, sourceCommit, secretRefs: { valuesPrinted: false } };
|
||||
}
|
||||
if (method === "GET" && path === "/api/v1/backends") return { items: store.backends() };
|
||||
if (method === "POST" && path === "/api/v1/runs") return store.createRun(validateCreateRun(body)) as unknown as JsonValue;
|
||||
const runMatch = path.match(/^\/api\/v1\/runs\/([^/]+)$/u);
|
||||
if (method === "GET" && runMatch) return store.getRun(runMatch[1] ?? "") as unknown as JsonValue;
|
||||
const eventMatch = path.match(/^\/api\/v1\/runs\/([^/]+)\/events$/u);
|
||||
if (method === "GET" && eventMatch) {
|
||||
const afterSeq = integerQuery(url, "afterSeq", 0);
|
||||
const limit = integerQuery(url, "limit", 100);
|
||||
return { items: store.listEvents(eventMatch[1] ?? "", afterSeq, limit) as unknown as JsonValue };
|
||||
}
|
||||
const commandCreateMatch = path.match(/^\/api\/v1\/runs\/([^/]+)\/commands$/u);
|
||||
if (method === "POST" && commandCreateMatch) return store.createCommand(commandCreateMatch[1] ?? "", validateCreateCommand(body)) as unknown as JsonValue;
|
||||
if (method === "GET" && commandCreateMatch) return { items: store.listCommands(commandCreateMatch[1] ?? "", integerQuery(url, "afterSeq", 0), integerQuery(url, "limit", 20)) as unknown as JsonValue };
|
||||
const commandShowMatch = path.match(/^\/api\/v1\/runs\/([^/]+)\/commands\/([^/]+)$/u);
|
||||
if (method === "GET" && commandShowMatch) return store.getCommand(commandShowMatch[2] ?? "") as unknown as JsonValue;
|
||||
if (method === "POST" && path === "/api/v1/runners/register") return store.registerRunner(asRecord(body ?? {}, "runner")) as unknown as JsonValue;
|
||||
const claimMatch = path.match(/^\/api\/v1\/runs\/([^/]+)\/claim$/u);
|
||||
if (method === "POST" && claimMatch) {
|
||||
const record = asRecord(body, "claim");
|
||||
const runnerId = typeof record.runnerId === "string" ? record.runnerId : "";
|
||||
if (runnerId.length === 0) throw new AgentRunError("schema-invalid", "runnerId is required", { httpStatus: 400 });
|
||||
return store.claimRun(claimMatch[1] ?? "", runnerId, numberField(record, "leaseMs", 60_000)) as unknown as JsonValue;
|
||||
}
|
||||
const eventsAppendMatch = path.match(/^\/api\/v1\/runs\/([^/]+)\/events$/u);
|
||||
if (method === "POST" && eventsAppendMatch) {
|
||||
const record = asRecord(body, "event");
|
||||
const type = typeof record.type === "string" ? record.type as RunEvent["type"] : "backend_status";
|
||||
return store.appendEvent(eventsAppendMatch[1] ?? "", type, asRecord(record.payload ?? {}, "event.payload")) as unknown as JsonValue;
|
||||
}
|
||||
const statusMatch = path.match(/^\/api\/v1\/runs\/([^/]+)\/status$/u);
|
||||
if (method === "PATCH" && statusMatch) {
|
||||
const record = asRecord(body, "status");
|
||||
const terminalStatus = record.terminalStatus === "completed" || record.terminalStatus === "failed" || record.terminalStatus === "blocked" || record.terminalStatus === "cancelled" ? record.terminalStatus : "failed";
|
||||
return store.finishRun(statusMatch[1] ?? "", { terminalStatus, failureKind: typeof record.failureKind === "string" ? record.failureKind as never : null, failureMessage: typeof record.failureMessage === "string" ? record.failureMessage : null }) as unknown as JsonValue;
|
||||
}
|
||||
const ackMatch = path.match(/^\/api\/v1\/commands\/([^/]+)\/ack$/u);
|
||||
if (method === "POST" && ackMatch) return store.ackCommand(ackMatch[1] ?? "") as unknown as JsonValue;
|
||||
throw new AgentRunError("schema-invalid", `unsupported route ${method} ${path}`, { httpStatus: 404 });
|
||||
}
|
||||
|
||||
function integerQuery(url: URL, key: string, fallback: number): number {
|
||||
const value = Number(url.searchParams.get(key));
|
||||
return Number.isInteger(value) && value >= 0 ? value : fallback;
|
||||
}
|
||||
|
||||
function numberField(record: JsonRecord, key: string, fallback: number): number {
|
||||
const value = record[key];
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : fallback;
|
||||
}
|
||||
|
||||
function normalizeError(error: unknown): AgentRunError {
|
||||
if (error instanceof AgentRunError) return error;
|
||||
return new AgentRunError("infra-failed", error instanceof Error ? error.message : String(error), { httpStatus: 500 });
|
||||
}
|
||||
|
||||
function writeJson(res: import("node:http").ServerResponse, statusCode: number, body: ApiOkBody | ApiErrorBody): void {
|
||||
const text = `${JSON.stringify(body)}\n`;
|
||||
res.writeHead(statusCode, { "content-type": "application/json; charset=utf-8", "content-length": Buffer.byteLength(text) });
|
||||
res.end(text);
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
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";
|
||||
|
||||
export interface AgentRunStore {
|
||||
createRun(input: CreateRunInput): RunRecord;
|
||||
getRun(runId: string): RunRecord;
|
||||
listEvents(runId: string, afterSeq: number, limit: number): RunEvent[];
|
||||
createCommand(runId: string, input: CreateCommandInput): CommandRecord;
|
||||
getCommand(commandId: string): CommandRecord;
|
||||
listCommands(runId: string, afterSeq: number, limit: number): CommandRecord[];
|
||||
registerRunner(input: Partial<RunnerRecord>): RunnerRecord;
|
||||
claimRun(runId: string, runnerId: string, leaseMs: number): RunRecord;
|
||||
heartbeat(runId: string, runnerId: string, leaseMs: number): RunRecord;
|
||||
ackCommand(commandId: string): CommandRecord;
|
||||
appendEvent(runId: string, type: RunEvent["type"], payload: JsonRecord): RunEvent;
|
||||
finishRun(runId: string, result: Pick<BackendTurnResult, "terminalStatus" | "failureKind" | "failureMessage">): RunRecord;
|
||||
backends(): JsonRecord[];
|
||||
}
|
||||
|
||||
export class MemoryAgentRunStore implements AgentRunStore {
|
||||
private readonly runs = new Map<string, RunRecord>();
|
||||
private readonly commands = new Map<string, CommandRecord>();
|
||||
private readonly eventsByRun = new Map<string, RunEvent[]>();
|
||||
private readonly runners = new Map<string, RunnerRecord>();
|
||||
|
||||
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>): 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;
|
||||
}
|
||||
|
||||
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<BackendTurnResult, "terminalStatus" | "failureKind" | "failureMessage">): 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 [{ profile: "codex" satisfies BackendProfile, protocol: "codex-app-server-jsonrpc-stdio", transport: "stdio", command: "codex app-server --listen stdio://", status: "registered" }];
|
||||
}
|
||||
|
||||
private updateRun(runId: string, patch: Partial<RunRecord>): RunRecord {
|
||||
const run = this.getRun(runId);
|
||||
const next = { ...run, ...patch, updatedAt: nowIso() };
|
||||
this.runs.set(runId, next);
|
||||
return next;
|
||||
}
|
||||
}
|
||||
|
||||
function statusFromTerminal(terminalStatus: TerminalStatus): RunRecord["status"] {
|
||||
if (terminalStatus === "completed") return "completed";
|
||||
if (terminalStatus === "cancelled") return "cancelled";
|
||||
if (terminalStatus === "blocked") return "blocked";
|
||||
return "failed";
|
||||
}
|
||||
Reference in New Issue
Block a user