import { spawn } from "node:child_process"; import { createHash, createHmac, randomBytes, timingSafeEqual } from "node:crypto"; import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs"; import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http"; import { dirname, isAbsolute, join } from "node:path"; import { findKxmRepoRoot } from "./repo-root.ts"; import { loadKxmProject, KxmConfigError, type KxmConfigOptions } from "./project-config.ts"; import { KxmRunEventStore, KxmRuntimeRegistry, projectRuntimeKey, readKxmSupervisorRecord, runtimeError, verifyKxmDriveReceipt, kxmRuntimePaths, type KxmRuntimePaths, type KxmSupervisorRecord, } from "./runtime-store.ts"; import { openReadOnlyDatabase } from "./sqlite.ts"; import { acceptKxmRun, cancelKxmRun, closeKxmRuntimeContext, foldStoredKxmRun, openKxmRuntimeContext, kxmProjectAdmissionLimits, projectKxmRunReadOnly, type KxmRuntimeContext, } from "./runtime-service.ts"; import { createKxmOneShotProducer } from "./oneshot-producer.ts"; import { KxmRunScheduler, createKxmSimulatedProducer, recordDriveReceipt, recoverKxmRun, kxmDrivePollProjection } from "./engine.ts"; import { kxmDriveSession, kxmOpenDriveSessions } from "./runtime-owner.ts"; import { RuntimeHubClient, HubHttpError, type SyncPushResponse } from "./client.ts"; import { CloudTokenError, HubBindingError, loadHubBinding } from "./hub-binding.ts"; import { resolveClientHubAuthToken } from "./hub-env.ts"; import { createLogger } from "./logger.ts"; import type { KxmOutboxRow, KxmOutboxStatus } from "./runtime-store.ts"; /* ------------------------------------------------------------------ * * Token management * ------------------------------------------------------------------ */ export function kxmSupervisorTokenFile(paths: KxmRuntimePaths): string { return join(paths.runtimeDir, "supervisor.token"); } /** Atomically publish the supervisor token (temp file + rename). */ function publishKxmSupervisorToken(paths: KxmRuntimePaths, token: string): void { const file = kxmSupervisorTokenFile(paths); mkdirSync(dirname(file), { recursive: true, mode: 0o700 }); const temp = `${file}.${process.pid}.tmp`; writeFileSync(temp, `${token}\n`, { encoding: "utf8", mode: 0o600 }); try { chmodSync(temp, 0o600); } catch { /* Windows may ignore modes */ } renameSync(temp, file); try { chmodSync(file, 0o600); } catch { /* Windows may ignore modes */ } } export function hashKxmSupervisorToken(token: string): string { return `sha256:${createHash("sha256").update(`kxm-runtime-supervisor\0${token}`, "utf8").digest("hex")}`; } export function readKxmSupervisorToken(paths: KxmRuntimePaths): string | undefined { const file = kxmSupervisorTokenFile(paths); const stat = lstatSync(file, { throwIfNoEntry: false }); if (!stat) return undefined; if (stat.isSymbolicLink() || !stat.isFile()) { throw runtimeError("runtime_path_invalid", file, "supervisor token file must be a regular file, not a link"); } const token = readFileSync(file, "utf8").trim(); return token.length >= 32 ? token : undefined; } /* ------------------------------------------------------------------ * * Supervisor process state + liveness * ------------------------------------------------------------------ */ export interface KxmSupervisorStatus { running: boolean; runtimeId?: string; pid?: number; port?: number; state?: string; heartbeatAt?: string; startedAt?: string; } function processAlive(pid: number): boolean { try { process.kill(pid, 0); return true; } catch (error) { return (error as NodeJS.ErrnoException).code === "EPERM"; } } const HEARTBEAT_STALE_MS = 15_000; const SUPERVISOR_ERROR_MAX_AGE_MS = 30_000; function supervisorErrorFile(paths: KxmRuntimePaths): string { return join(paths.runtimeDir, "supervisor.error"); } function clearSupervisorError(paths: KxmRuntimePaths): void { const file = supervisorErrorFile(paths); if (existsSync(file)) rmSync(file, { force: true }); } function recordSupervisorError(paths: KxmRuntimePaths, message: string): void { try { mkdirSync(paths.runtimeDir, { recursive: true, mode: 0o700 }); writeFileSync(supervisorErrorFile(paths), `${message}\n`, { encoding: "utf8", mode: 0o600 }); } catch { /* best effort */ } } function readRecentSupervisorError(paths: KxmRuntimePaths): string | undefined { const file = supervisorErrorFile(paths); const stat = lstatSync(file, { throwIfNoEntry: false }); if (!stat) return undefined; const ageMs = Date.now() - stat.mtimeMs; if (ageMs > SUPERVISOR_ERROR_MAX_AGE_MS) return undefined; try { return readFileSync(file, "utf8").trim(); } catch { return undefined; } } const SETTLED_RUN_STATUS = new Set(["completed", "failed", "cancelled"]); /** Same liveness rule as `kxmSupervisorStatus`: running, a fresh heartbeat, and a live pid. */ export function kxmSupervisorRecordIsLive(record: KxmSupervisorRecord | undefined): boolean { return supervisorStatusOf(record).running; } function unsettledRunIds(context: KxmRuntimeContext): string[] { const ids: string[] = []; for (const stored of context.eventStore.runsForProject(context.projectId, 10_000)) { let status: string = stored.status; try { status = projectKxmRunReadOnly(context, stored.runId).status; } catch { ids.push(stored.runId); continue; } if (!SETTLED_RUN_STATUS.has(status)) ids.push(stored.runId); } return ids; } function supervisorStatusOf(record: KxmSupervisorRecord | undefined): KxmSupervisorStatus { if (!record) return { running: false }; // Pid-only liveness is not enough: after a crash the pid may be reused by // an unrelated process. A stale heartbeat is authoritative. const heartbeatAgeMs = Date.now() - Date.parse(record.heartbeatAt); const fresh = Number.isFinite(heartbeatAgeMs) && heartbeatAgeMs < HEARTBEAT_STALE_MS; const alive = record.state === "running" && fresh && processAlive(record.pid); return { running: alive, runtimeId: record.runtimeId, pid: record.pid, port: record.port, state: alive ? record.state : "dead", heartbeatAt: record.heartbeatAt, startedAt: record.startedAt, }; } /** * Is the supervisor up? `readOnly` gives the same answer without opening the * registry for writing: the ordinary open takes the write lock and, as the last * connection, checkpoints the WAL on close. `kxm restore` asks this way, dry run * or not, because a project-scoped restore never otherwise touches the registry. */ export function kxmSupervisorStatus(paths: KxmRuntimePaths, options: { readOnly?: boolean } = {}): KxmSupervisorStatus { if (!existsSync(paths.registryDb)) return { running: false }; if (options.readOnly) { const database = openReadOnlyDatabase(paths.registryDb); try { database.exec("PRAGMA busy_timeout = 5000"); return supervisorStatusOf(readKxmSupervisorRecord(database)); } finally { database.close(); } } const registry = new KxmRuntimeRegistry(paths.registryDb); try { return supervisorStatusOf(registry.supervisor()); } finally { registry.close(); } } async function probeSupervisorWithRetry(port: number, expectedRuntimeId: string, token: string): Promise { for (let attempt = 0; attempt < 3; attempt += 1) { if (await probeSupervisor(port, expectedRuntimeId, token)) return true; if (attempt < 2) await new Promise((resolveWait) => setTimeout(resolveWait, 250)); } return false; } async function probeSupervisor(port: number, expectedRuntimeId: string, token: string, timeoutMs = 750): Promise { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), timeoutMs); try { const nonce = randomBytes(16).toString("hex"); const response = await fetch(`http://127.0.0.1:${port}/healthz?nonce=${nonce}`, { signal: controller.signal }); if (!response.ok) return false; const payload = await response.json() as { runtimeId?: string; tokenProof?: string }; // The server must prove it knows the current token: an impostor that // merely binds the stale port cannot answer the keyed challenge. const expectedProof = hashKxmTokenProof(token, nonce); return payload.runtimeId === expectedRuntimeId && payload.tokenProof === expectedProof; } catch { return false; } finally { clearTimeout(timeout); } } export function hashKxmTokenProof(token: string, nonce: string): string { return createHmac("sha256", token).update(`kxm-runtime-token-proof\0${nonce}`, "utf8").digest("hex"); } export interface KxmSupervisorHandle { runtimeId: string; port: number; token: string; started: boolean; } /** * Attach to a live supervisor, or return `undefined`. Never starts one. * * `ensureKxmSupervisor` is for operators: an absent supervisor is a problem to fix, so it * spawns. A poller — the portal tenant read, a status screen — has the opposite contract: * polling must not conjure a daemon, and "nothing is running" is an answer to report, not * a condition to repair. Returns `undefined` for every not-running shape: no claim, a * supervisor registered but dead, a missing token file, or a probe that fails the * token-proof challenge. */ export async function attachKxmSupervisor( options: { stateRoot?: string; env?: NodeJS.ProcessEnv } = {}, ): Promise { const paths = kxmRuntimePaths(options.stateRoot !== undefined ? { stateRoot: options.stateRoot } : { ...(options.env ? { env: options.env } : {}) }); const status = kxmSupervisorStatus(paths); if (!status.running || !status.port || !status.runtimeId) return undefined; const token = readKxmSupervisorToken(paths); if (!token) return undefined; if (!(await probeSupervisor(status.port, status.runtimeId, token))) return undefined; return { runtimeId: status.runtimeId, port: status.port, token, started: false }; } /** Ensure a supervisor is running: reuse a live one, otherwise auto-start. */ export async function ensureKxmSupervisor( options: { stateRoot?: string; env?: NodeJS.ProcessEnv; spawnImpl?: (scriptPath: string, env: NodeJS.ProcessEnv) => number } = {}, ): Promise { const paths = kxmRuntimePaths(options.stateRoot !== undefined ? { stateRoot: options.stateRoot } : { ...(options.env ? { env: options.env } : {}) }); const status = kxmSupervisorStatus(paths); if (status.running && status.port) { for (let attempt = 0; attempt < 3; attempt += 1) { const token = readKxmSupervisorToken(paths); if (token && (await probeSupervisorWithRetry(status.port, status.runtimeId as string, token))) { return { runtimeId: status.runtimeId as string, port: status.port, token, started: false }; } // The token may be mid-publish in the claim/rename window; retry shortly. if (attempt < 2) await new Promise((resolveWait) => setTimeout(resolveWait, 150)); } // A live, fresh supervisor that cannot be probed is not spawnable: // spawning a competitor guarantees a conflict and locks out the state root. throw runtimeError("runtime_supervisor_unreachable", paths.registryDb, `runtime supervisor pid ${status.pid} is registered as running but cannot be probed`); } // Auto-start: claim the singleton, spawn detached, wait for readiness. clearSupervisorError(paths); const scriptPath = join(findKxmRepoRoot(import.meta.url), "scripts", "kxm-runtime-supervisor.mjs"); const spawnImpl = options.spawnImpl ?? ((script: string, env: NodeJS.ProcessEnv): number => { const child = spawn(process.execPath, [script], { detached: true, stdio: "ignore", env, windowsHide: true, }); child.unref(); if (child.pid === undefined) throw runtimeError("runtime_supervisor_spawn_failed", script, "could not spawn the runtime supervisor"); return child.pid; }); const pid = spawnImpl(scriptPath, { ...process.env, KXM_STATE_HOME: paths.stateRoot, }); const deadline = Date.now() + 10_000; while (Date.now() < deadline) { await new Promise((resolveWait) => setTimeout(resolveWait, 100)); const next = kxmSupervisorStatus(paths); if (next.running && next.port) { const token = readKxmSupervisorToken(paths); if (token && (await probeSupervisor(next.port, next.runtimeId as string, token))) { // Entry status had no live supervisor: this call caused a start // (ours or a concurrent winner's), which the probe just confirmed. return { runtimeId: next.runtimeId as string, port: next.port, token, started: true }; } } const errorDetail = readRecentSupervisorError(paths); if (errorDetail) { throw runtimeError("runtime_supervisor_start_failed", scriptPath, `runtime supervisor failed at startup: ${errorDetail}`); } } throw runtimeError("runtime_supervisor_start_failed", scriptPath, `runtime supervisor (pid ${pid}) did not become ready in time`); } /* ------------------------------------------------------------------ * * Supervisor server (runs inside the supervisor process) * ------------------------------------------------------------------ */ interface JsonBody { [key: string]: unknown; } async function readJsonBody(request: IncomingMessage, limit = 64 * 1024): Promise { const chunks: Buffer[] = []; let size = 0; let tooLarge = false; for await (const chunk of request) { const buffer = chunk as Buffer; size += buffer.byteLength; if (size > limit) { // Drain the remaining body so the client sees the 400 response instead // of a reset connection. tooLarge = true; continue; } if (!tooLarge) chunks.push(buffer); } if (tooLarge) throw runtimeError("runtime_request_too_large", request.url ?? "", "request body exceeds the limit"); if (chunks.length === 0) return {}; try { const parsed = JSON.parse(Buffer.concat(chunks).toString("utf8")) as unknown; if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { throw runtimeError("runtime_request_invalid", request.url ?? "", "request body must be a JSON object"); } return parsed as JsonBody; } catch (error) { if (error instanceof KxmConfigError) throw error; throw runtimeError("runtime_request_invalid", request.url ?? "", "request body is not valid JSON"); } } function sendJson(response: ServerResponse, status: number, payload: unknown): void { const body = JSON.stringify(payload); response.writeHead(status, { "content-type": "application/json", "content-length": Buffer.byteLength(body) }); response.end(body); } export const DEFAULT_RUNTIME_STOP_GRACE_MS = 30_000; /** Documented upper bound: 10 minutes, well under Node's 2^31-1 ms timer range. */ export const MAX_RUNTIME_STOP_GRACE_MS = 600_000; export function runtimeStopGraceMs(env: NodeJS.ProcessEnv = process.env): number { const raw = env.KXM_RUNTIME_STOP_GRACE_MS; if (raw === undefined || raw.trim() === "") return DEFAULT_RUNTIME_STOP_GRACE_MS; const parsed = Number(raw.trim()); if ( !Number.isFinite(parsed) || !Number.isInteger(parsed) || parsed < 0 || parsed > MAX_RUNTIME_STOP_GRACE_MS ) { process.stderr.write( `KXM_RUNTIME_STOP_GRACE_MS must be an integer between 0 and ${MAX_RUNTIME_STOP_GRACE_MS} ms (10 minutes); using default ${DEFAULT_RUNTIME_STOP_GRACE_MS}\n`, ); return DEFAULT_RUNTIME_STOP_GRACE_MS; } return parsed; } async function waitForDriveSessions(settled: Promise[], graceMs: number): Promise { if (settled.length === 0) return; let timeout: ReturnType | undefined; try { await Promise.race([ Promise.allSettled(settled), new Promise((resolve) => { timeout = setTimeout(resolve, graceMs); timeout.unref?.(); }), ]); } finally { if (timeout !== undefined) clearTimeout(timeout); } } async function driveSessionStillPending(settled: Promise): Promise { let pending = true; void settled.then( () => { pending = false; }, () => { pending = false; }, ); await Promise.resolve(); return pending; } /* ------------------------------------------------------------------ * * Runtime → hub sync (P5): outbound only * ------------------------------------------------------------------ */ export const DEFAULT_RUNTIME_SYNC_INTERVAL_MS = 10_000; const MIN_RUNTIME_SYNC_INTERVAL_MS = 250; const MAX_RUNTIME_SYNC_INTERVAL_MS = 60_000; const OUTBOX_PUSH_BATCH = 32; /** Hub body ceiling is 256 KiB; leave headroom for the JSON envelope. */ const OUTBOX_PUSH_BATCH_BYTES = 200_000; /** How long the sync tick stops knocking on a hub that keeps failing. */ const RUNTIME_SYNC_MAX_BACKOFF_MS = 5 * 60_000; /** How often the supervisor heartbeats and pushes its outbox. Keep it well * under the hub's presence lease (30 s by default). */ export function runtimeSyncIntervalMs(env: NodeJS.ProcessEnv = process.env): number { const raw = env.KXM_RUNTIME_SYNC_INTERVAL_MS?.trim(); if (!raw) return DEFAULT_RUNTIME_SYNC_INTERVAL_MS; const parsed = Number(raw); if (!Number.isInteger(parsed)) return DEFAULT_RUNTIME_SYNC_INTERVAL_MS; return Math.min(MAX_RUNTIME_SYNC_INTERVAL_MS, Math.max(MIN_RUNTIME_SYNC_INTERVAL_MS, parsed)); } export interface KxmOutboxRefusal { runId: string; sequence: number; code: string; } export interface KxmOutboxSyncResult { /** Rows carried to the hub in this pass, including rows later refused. */ pushed: number; /** Rows the hub accepted or already held — the cursor advanced. */ acked: number; /** Rows the hub refused in a way re-sending cannot change. */ refused: number; /** Per-row refusals, oldest first, carrying the hub's own code. */ refusals: KxmOutboxRefusal[]; /** Rows the hub gave no answer for. Left pending, never acked. */ unconfirmed: number; /** A transport failure ended the pass; the rest of the queue stays pending. */ blocked: boolean; /** Why the pass was blocked, already reduced to an operator-safe line. */ blockedReason?: string; } /** A hub answer that will not change if the same bytes are sent again. */ function isDurableRefusal(outcome: string): boolean { return outcome === "conflict" || outcome === "rejected"; } /** An HTTP refusal that says "this batch is too big", not "try again later". */ function isOversizeRefusal(error: unknown): boolean { return error instanceof HubHttpError && (error.statusCode === 413 || error.code === "sync_batch_too_large" || error.code === "payload_too_large"); } /** * Push every pending outbox row to the bound hub in outbox order. * * Accepted and duplicate rows are acked. A row the hub **durably** refuses — a * sequence already used with other bytes, a project id another hub project * claimed, an event that fails the sync schema — is refused locally too: it * leaves the pending queue with the hub's code recorded, because re-sending the * same bytes can only re-raise the same alert. That is the difference between a * retry and a stampede, and it is what keeps one unreachable row from parking * every row behind it. * * A transport failure marks the pass `blocked` and returns: the rows stay * pending for a real retry, with the failure carried back to the caller so it * can be logged and backed off instead of swallowed. */ export async function syncKxmOutbox( eventStore: KxmRunEventStore, client: RuntimeHubClient, options: { now?: () => string; batchSize?: number } = {}, ): Promise { const now = options.now ?? (() => new Date().toISOString()); const batchSize = Math.max(1, options.batchSize ?? OUTBOX_PUSH_BATCH); const result: KxmOutboxSyncResult = { pushed: 0, acked: 0, refused: 0, refusals: [], unconfirmed: 0, blocked: false }; const refuse = (row: KxmOutboxRow, code: string): void => { if (eventStore.refuseOutbox([{ seq: row.seq, code }], now()) === 0) return; result.refused += 1; result.refusals.push({ runId: row.runId, sequence: row.sequence, code }); }; /** * Deliver one batch. Returns false only for a transient failure, where every * row of the batch stays pending. One result per event, in order, is the * hub's contract, so the answer for a row is found at its index and then * verified against the row it claims to describe. */ const deliver = async (batch: readonly KxmOutboxRow[]): Promise => { const payload: unknown[] = []; const sendable: KxmOutboxRow[] = []; for (const row of batch) { try { payload.push(JSON.parse(row.syncEvent) as unknown); sendable.push(row); } catch { // Written by the transform, so unparseable bytes are local corruption — // durable, visible, and never a reason to stop the queue behind it. refuse(row, "sync_row_unreadable"); } } if (sendable.length === 0) return true; eventStore.markOutboxAttempted(sendable.map((row) => row.seq), now()); let response: SyncPushResponse; try { response = await client.pushSyncEvents(payload); } catch (error) { if (isOversizeRefusal(error)) { if (sendable.length > 1) { // Isolate the offender one row at a time so the batch never becomes // a permanent head-of-line block. for (const row of sendable) { if (!(await deliver([row]))) return false; } return true; } refuse(sendable[0]!, "sync_row_too_large"); return true; } result.blockedReason = syncFailureText(error); return false; } result.pushed += sendable.length; const acked: number[] = []; sendable.forEach((row, index) => { const entry = response.results[index]; const describesRow = entry !== undefined && (entry.runId === undefined || entry.runId === row.runId) && (entry.sequence === undefined || entry.sequence === row.sequence); if (!entry || !describesRow) { result.unconfirmed += 1; return; } if (entry.outcome === "accepted" || entry.outcome === "duplicate") acked.push(row.seq); else if (isDurableRefusal(entry.outcome)) refuse(row, entry.code ?? `sync_${entry.outcome}`); else result.unconfirmed += 1; }); result.acked += eventStore.ackOutbox(acked, now()); return true; }; let afterSeq = 0; for (;;) { const rows = eventStore.pendingOutbox(batchSize, afterSeq); if (rows.length === 0) return result; // Batch by serialized byte size, not just count: the hub rejects requests // over its body ceiling (HTTP 413), and retrying the same oversized batch // would permanently block the queue. Trim to the byte budget and leave the // rest for the next iteration. let byteBudget = OUTBOX_PUSH_BATCH_BYTES; let sendCount = 0; for (const row of rows) { const rowBytes = Buffer.byteLength(row.syncEvent, "utf8") + 64; // JSON overhead if (sendCount > 0 && byteBudget - rowBytes < 0) break; byteBudget -= rowBytes; sendCount += 1; } const batch = rows.slice(0, Math.max(1, sendCount)); afterSeq = batch[batch.length - 1]!.seq; if (!(await deliver(batch))) { result.blocked = true; return result; } } } /** Where this Runtime reports one project, or nothing when no hub is bound. */ function runtimeHubClientFor( context: KxmRuntimeContext, env: NodeJS.ProcessEnv, onBindingWarning?: (message: string) => void, ): RuntimeHubClient | undefined { let recordUrl: string | undefined; try { const loaded = loadHubBinding(env); if (loaded.warning) onBindingWarning?.(loaded.warning); recordUrl = loaded.record?.url; } catch (error) { if (!(error instanceof HubBindingError)) throw error; onBindingWarning?.(`${error.message}; falling back to the local hub so sync can continue`); } const serverUrl = env.KXM_SERVER_URL?.trim() || recordUrl; if (!serverUrl) return undefined; // Use the context's actual projectId — the same identity sync events carry — so the // ops snapshot can join runs to their home Runtime. The npm package name (what an // earlier build sent) never matches project.yaml's prj_* id: presence lands under one // label and runs under the other, and the hub pins the project id to the first label // it ever saw, which refuses every later push. See the supervisor sync gate. const project = context.projectId; const authToken = resolveClientHubAuthToken(env, project); return new RuntimeHubClient({ serverUrl, project, runtimeId: context.homeRuntimeId, ...(authToken ? { authToken } : {}), }); } /* ------------------------------------------------------------------ * * Sync status: what the tick saw, readable without opening SQLite * ------------------------------------------------------------------ */ /** * One project's outbound sync state as the supervisor last observed it. * * `state` is the answer to "is this Runtime talking to the hub?": * `ok` (rows are acking), `no_hub` (nothing bound — keeping rows locally is the * design), `blocked` (a transport or credential failure; retryable, backing * off), or `refusing` (the hub answered durably; an operator has to change * something). `blocked` and `refusing` are the two states that mean run facts * are not reaching the hub. */ export interface KxmProjectSyncStatus { projectId: string; projectRoot: string; homeRuntimeId: string; state: "ok" | "no_hub" | "blocked" | "refusing"; outbox: KxmOutboxStatus; consecutiveFailures: number; /** * False when the counts above are zeros because the store could not be opened * at all — a refused schema is not an empty outbox, and reporting the two the * same way is how a blocked project reads as "nothing to do". */ storeReadable?: boolean; hubUrl?: string; lastCompletedAt?: string; lastPushed?: number; lastAcked?: number; lastRefused?: number; lastUnconfirmed?: number; lastError?: string; nextAttemptAt?: string; } /** Exponential backoff on consecutive stalls, capped so a hub outage heals by itself. */ function syncBackoffMs(consecutiveStalls: number, intervalMs: number): number { if (consecutiveStalls <= 0) return 0; return Math.min(RUNTIME_SYNC_MAX_BACKOFF_MS, intervalMs * 2 ** Math.min(5, consecutiveStalls - 1)); } /** A failure worth showing an operator: bounded, and never the bearer token. */ function syncFailureText(error: unknown): string { const code = error instanceof HubHttpError ? `${error.statusCode} ${error.code ?? "error"}` : undefined; const message = error instanceof Error ? error.message : String(error); return `${code ? `${code}: ` : ""}${message}`.replace(/(kxm_[A-Za-z0-9_]+|[A-Za-z0-9._-]{40,})/g, "[redacted]").slice(0, 300); } function runtimeSupervisorLogFile(paths: KxmRuntimePaths): string { return join(paths.runtimeDir, "logs", "kxm-runtime.jsonl"); } export interface KxmRuntimeSupervisor { server: Server; port: number; runtimeId: string; stop: () => Promise; } export async function startKxmRuntimeSupervisor( options: { stateRoot?: string; port?: number; now?: () => string; env?: NodeJS.ProcessEnv } = {}, ): Promise { const now = options.now ?? (() => new Date().toISOString()); const env = options.env ?? process.env; const paths = kxmRuntimePaths(options.stateRoot !== undefined ? { stateRoot: options.stateRoot } : {}); try { return await startKxmRuntimeSupervisorInner(paths, options.port, now, env); } catch (error) { // Startup failures are recorded so auto-start clients see the real cause // instead of a bare timeout. Conflict is not an error: the winner is // probed and reused instead. if (!(error instanceof KxmConfigError && error.issues.some((issue) => issue.code === "runtime_supervisor_conflict"))) { recordSupervisorError(paths, (error as Error).message); } throw error; } } async function startKxmRuntimeSupervisorInner( paths: KxmRuntimePaths, requestedPortOption: number | undefined, now: () => string, env: NodeJS.ProcessEnv, ): Promise { mkdirSync(paths.runtimeDir, { recursive: true, mode: 0o700 }); mkdirSync(paths.projectsDir, { recursive: true, mode: 0o700 }); // The supervisor is spawned detached with stdio ignored, so this file is its // only durable voice. Without it, a sync path that refuses every row is // indistinguishable from a sync path that never ran. const logger = createLogger({ component: "runtime", path: runtimeSupervisorLogFile(paths), stdout: false, maxBytes: 2 * 1024 * 1024, }); // The token is generated in memory and written only after the supervisor // singleton is owned: a losing supervisor can never overwrite a winner's // token and lock every client out. const token = randomBytes(32).toString("hex"); const tokenHash = hashKxmSupervisorToken(token); const registry = new KxmRuntimeRegistry(paths.registryDb); const runtimeId = `rtm_${createHash("sha256").update(`${paths.stateRoot}\0${process.pid}\0${now()}\0${randomBytes(16).toString("hex")}`, "utf8").digest("hex").slice(0, 24)}`; const requestedPort = requestedPortOption ?? 0; let activeRuntimeId = runtimeId; const contexts = new Map(); // Declared with the contexts rather than with the timer that fills it: the // listener is up before the tick is armed, and `GET /v1/sync/status` must be // answerable in that window rather than reach for a binding that is not there // yet. const syncStatuses = new Map(); const registerSyncCredentials = (context: KxmRuntimeContext): void => { // Register credentials on the store's redactor at context creation — // BEFORE any event can be appended — so the very first outbox row is // already scrubbed. Registering on the sync tick leaves a window where // appended events retain credentials. try { const hubToken = resolveClientHubAuthToken(env, context.projectId); if (hubToken) context.eventStore.syncRedactor.register(hubToken); } catch (error) { // A missing cloud token must not stop the local Runtime from opening. // The sync tick records the same failure as blocked. if (!(error instanceof CloudTokenError)) throw error; } for (const key of Object.keys(env)) { if ((key.startsWith("KXM_") && (key.endsWith("_TOKEN") || key.endsWith("_KEY"))) || key.endsWith("_API_KEY") || key.endsWith("_SECRET")) { const value = env[key]?.trim(); if (value) context.eventStore.syncRedactor.register(value); } } }; const contextFor = (projectRoot: string): KxmRuntimeContext => { if (!isAbsolute(projectRoot)) { throw runtimeError("runtime_request_invalid", "projectRoot", "projectRoot must be an absolute path"); } const key = projectRuntimeKey(projectRoot); const existing = contexts.get(key); if (existing) return existing; const context = openKxmRuntimeContext(projectRoot, { homeRuntimeId: activeRuntimeId, stateRoot: paths.stateRoot, logger }); registerSyncCredentials(context); contexts.set(key, context); return context; }; const requireAuth = (request: IncomingMessage): void => { const header = request.headers.authorization ?? ""; const presented = header.startsWith("Bearer ") ? header.slice("Bearer ".length) : ""; const presentedHash = presented.length > 0 ? Buffer.from(hashKxmSupervisorToken(presented), "utf8") : Buffer.alloc(0); const expectedHash = Buffer.from(tokenHash, "utf8"); if (presentedHash.length !== expectedHash.length || !timingSafeEqual(presentedHash, expectedHash)) { throw runtimeError("runtime_auth_failed", request.url ?? "", "missing or invalid supervisor token"); } }; const server = createServer((request, response) => { void (async () => { try { const url = new URL(request.url ?? "/", "http://127.0.0.1"); if (request.method === "GET" && url.pathname === "/healthz") { const nonce = url.searchParams.get("nonce") ?? ""; sendJson(response, 200, { ok: true, runtimeId: activeRuntimeId, state: "running", // Proof of token knowledge: an impostor that merely binds the // stale port cannot answer the keyed challenge. tokenProof: nonce.length > 0 ? hashKxmTokenProof(token, nonce) : undefined, }); return; } requireAuth(request); if (request.method === "POST" && url.pathname === "/v1/shutdown") { sendJson(response, 200, { ok: true, stopping: true }); setImmediate(() => { void stop(); }); return; } // What the sync loop last saw, per project this Runtime owns. This is the // surface that makes "the supervisor is running clean" mean something: // pending, acked and refused rows, the hub's refusal codes, and when the // next attempt is due. if (request.method === "GET" && url.pathname === "/v1/sync/status") { const projectRoot = url.searchParams.get("projectRoot"); const statuses = [...syncStatuses.values()] .filter((status) => projectRoot === null || status.projectRoot === projectRoot) .sort((left, right) => left.projectId.localeCompare(right.projectId)); sendJson(response, 200, { ok: true, runtimeId: activeRuntimeId, projects: statuses }); return; } // Operator-only revive: a durably refused row stays refused until someone // says the hub-side state has been corrected. The Runtime never decides on // its own that a refusal has become retryable, because the refusal is // usually the hub holding a claim this Runtime cannot see. if (request.method === "POST" && url.pathname === "/v1/sync/retry") { const body = await readJsonBody(request); const projectRoot = typeof body.projectRoot === "string" ? body.projectRoot : ""; const context = contextFor(projectRoot); const key = projectRuntimeKey(context.projectRoot); const retried = context.eventStore.retryRefusedOutbox(); const status = syncStatuses.get(key); // Clear the backoff gate so the rows go out on the next tick, and let // that tick re-derive the state from what the hub actually says. if (status) { const cleared: KxmProjectSyncStatus = { ...status, outbox: context.eventStore.outboxStatus(), consecutiveFailures: 0 }; delete (cleared as Partial).nextAttemptAt; syncStatuses.set(key, cleared); } logger({ event: "runtime_sync_retry", projectId: context.projectId, retried }); sendJson(response, 200, { ok: true, projectId: context.projectId, retried, outbox: context.eventStore.outboxStatus() }); return; } if (request.method === "POST" && url.pathname === "/v1/projects/unregister") { const body = await readJsonBody(request); const projectRoot = typeof body.projectRoot === "string" ? body.projectRoot : ""; const force = body.force === true; if (!projectRoot || !isAbsolute(projectRoot)) { sendJson(response, 400, { ok: false, error: "runtime_request_invalid", message: "projectRoot must be an absolute path" }); return; } if (!force) { const key = projectRuntimeKey(projectRoot); const existing = contexts.get(key); let openRunIds: string[] = []; if (existing) { openRunIds = unsettledRunIds(existing); } else { const registration = registry.projectByRoot(projectRoot); const eventsPath = join(paths.projectsDir, registration?.projectKey ?? projectRuntimeKey(projectRoot), "run-events.db"); if (registration && existsSync(eventsPath)) { const store = new KxmRunEventStore(eventsPath); try { openRunIds = store.runsForProject(registration.projectId, 10_000) .filter((run) => !SETTLED_RUN_STATUS.has(run.status)) .map((run) => run.runId); } finally { store.close(); } } } if (openRunIds.length > 0) { sendJson(response, 409, { ok: false, error: "runtime_project_busy", message: `unsettled run ${openRunIds.join(", ")}`, runIds: openRunIds, }); return; } const laneCount = registry.homeLaneCount(projectRoot); if (laneCount > 0) { sendJson(response, 409, { ok: false, error: "runtime_project_has_lanes", message: `home root still has ${laneCount} lane(s)`, }); return; } } const key = projectRuntimeKey(projectRoot); const existing = contexts.get(key); const unregistered = registry.unregisterProject(projectRoot); if (existing) { contexts.delete(key); syncStatuses.delete(key); closeKxmRuntimeContext(existing); } sendJson(response, 200, { ok: true, unregistered }); return; } if (request.method === "POST" && url.pathname === "/v1/runs") { const body = await readJsonBody(request); const projectRoot = typeof body.projectRoot === "string" ? body.projectRoot : ""; const workflowId = typeof body.workflowId === "string" ? body.workflowId : ""; const prompt = typeof body.prompt === "string" ? body.prompt : ""; if (!projectRoot || !workflowId) { sendJson(response, 400, { ok: false, error: "runtime_request_invalid", message: "projectRoot and workflowId are required" }); return; } const context = contextFor(projectRoot); const bundle = loadKxmProject(projectRoot, {}); const acceptance = acceptKxmRun(context, bundle, { ...(typeof body.commandId === "string" && body.commandId.length > 0 ? { commandId: body.commandId } : {}), workflowId, prompt, }); sendJson(response, 200, { ok: true, idempotent: acceptance.idempotent, run: acceptance.run, event: acceptance.event, }); return; } const runMatch = /^\/v1\/runs\/([A-Za-z0-9_-]+)(?:\/(events|cancel|signal|wait|drive))?$/.exec(url.pathname); if (runMatch) { const runId = runMatch[1] as string; const sub = runMatch[2]; const projectRoot = url.searchParams.get("projectRoot") ?? ""; if (!projectRoot) { sendJson(response, 400, { ok: false, error: "runtime_request_invalid", message: "projectRoot query parameter is required" }); return; } const context = contextFor(projectRoot); const bundle = loadKxmProject(projectRoot, {}); if (request.method === "GET" && !sub) { const projected = projectKxmRunReadOnly(context, runId); const folded = foldStoredKxmRun(context, projected); const drive = kxmDrivePollProjection(context, runId, folded); sendJson(response, 200, { ok: true, run: projected, ...(drive !== undefined ? { drive } : {}) }); return; } if (request.method === "POST" && sub === "drive") { const body = await readJsonBody(request); if (body.mode !== "simulated" && body.mode !== "live") { sendJson(response, 400, { ok: false, error: "runtime_request_invalid", message: "drive mode must be simulated or live" }); return; } const run = context.eventStore.run(runId); if (!run) throw runtimeError("run_unknown", runId, "run not found"); const delayMs = typeof body.delayMs === "number" && body.delayMs > 0 ? body.delayMs : 0; const createProducer = () => body.mode === "live" ? createKxmOneShotProducer({ projectRoot, timeoutMs: kxmProjectAdmissionLimits(bundle).agentStepTimeoutMs, }) : createKxmSimulatedProducer(async () => { if (delayMs > 0) { await new Promise((r) => setTimeout(r, delayMs)); } return { outcome: "passed" }; }); let session: Awaited> | undefined; let earlyError: unknown; try { const scheduler = KxmRunScheduler.for(context, bundle); const opening = scheduler.openDriveSession(runId, { mode: body.mode, allowLimits: false, liveMode: body.mode === "live", createProducer, }); opening.catch((err) => { earlyError = err; }); session = await opening; } catch (err) { earlyError = err; } if (earlyError) { const errStr = String(earlyError); if (errStr.includes("run_handoff_required")) { const handoff = (earlyError as { handoff?: unknown }).handoff; sendJson(response, 409, { ok: false, error: "run_handoff_required", ...(handoff !== undefined ? { handoff } : {}) }); return; } if (errStr.includes("run_busy") || errStr.includes("scheduler_policy_conflict")) { sendJson(response, 409, { ok: false, error: "run_busy", message: `run ${runId} is already admitted or queued` }); return; } throw earlyError; } void session!.settled.catch(() => { // Post-202 drive failures stay on the session; keep the request 202. }); if (response.writableEnded) return; sendJson(response, 202, { ok: true, status: "accepted", runId, driveId: session!.driveId, poll: `/v1/runs/${runId}`, mode: body.mode, }); return; } if (request.method === "GET" && sub === "events") { const after = Number.parseInt(url.searchParams.get("after") ?? "0", 10); const events = context.eventStore.events(runId, Number.isFinite(after) && after >= 0 ? after : 0, 500); sendJson(response, 200, { ok: true, events }); return; } if (request.method === "GET" && sub === "drive") { const run = context.eventStore.run(runId); if (!run) throw runtimeError("run_unknown", runId, "run not found"); const activeSession = kxmDriveSession(context.eventStore.path, runId); const session = activeSession ? { driveId: activeSession.driveId, mode: activeSession.mode, openedAt: activeSession.openedAt, deadlineAt: activeSession.deadlineAt ?? null, } : null; const receipts = context.eventStore.driveReceiptsForRun(runId, 20); sendJson(response, 200, { ok: true, session, receipts }); return; } if (request.method === "POST" && sub === "cancel") { const body = await readJsonBody(request); const result = cancelKxmRun(context, runId, { ...(typeof body.commandId === "string" && body.commandId.length > 0 ? { commandId: body.commandId } : {}), }); sendJson(response, 200, { ok: true, idempotent: result.idempotent, run: result.run, events: result.events }); return; } if (request.method === "POST" && sub === "signal") { const body = await readJsonBody(request); const run = context.eventStore.run(runId); if (!run) throw runtimeError("run_unknown", runId, "run not found"); const state = foldStoredKxmRun(context, run); let unblocked = false; if (state.status === "blocked_uncertain" || state.status === "cancelling" || state.currentStep?.effectState === "blocked_uncertain") { const action = body.action === "cancel" || body.action === "fail" || body.action === "retry" || body.action === "unblock" ? body.action : "unblock"; const rec = recoverKxmRun(context, runId, { action, reason: typeof body.summary === "string" ? body.summary : `signal_${body.signalKey ?? "callback"}`, }); unblocked = rec.unblocked; } sendJson(response, 200, { ok: true, runId, signalKey: body.signalKey, status: body.status, unblocked }); return; } if (request.method === "POST" && sub === "wait") { const body = await readJsonBody(request); const run = context.eventStore.run(runId); if (!run) throw runtimeError("run_unknown", runId, "run not found"); sendJson(response, 200, { ok: true, runId, stageId: body.stageId, signalKey: body.signalKey, waiting: true }); return; } } const driveMatch = /^\/v1\/drives\/([A-Za-z0-9_-]+)$/.exec(url.pathname); if (request.method === "GET" && driveMatch) { const driveId = driveMatch[1] as string; const projectRoot = url.searchParams.get("projectRoot") ?? ""; if (!projectRoot) { sendJson(response, 400, { ok: false, error: "runtime_request_invalid", message: "projectRoot query parameter is required" }); return; } const context = contextFor(projectRoot); let receipt; try { receipt = context.eventStore.driveReceipt(driveId); } catch { sendJson(response, 200, { ok: true, receipt: null, verified: false, divergence: "receipt unreadable" }); return; } if (!receipt) { sendJson(response, 404, { ok: false, error: "drive_receipt_missing", message: "receipt not found" }); return; } const runId = receipt.runId; const stored = context.eventStore.run(runId); if (!stored) { sendJson(response, 200, { ok: true, receipt, verified: false, divergence: "run missing" }); return; } const state = foldStoredKxmRun(context, stored); const events = context.eventStore.events(runId, 0, 1_000_000); const checked = verifyKxmDriveReceipt(receipt, events, state.status, { runId, driveId }); sendJson(response, 200, { ok: true, receipt, verified: checked.verified, ...(checked.divergence !== undefined ? { divergence: checked.divergence } : {}), }); return; } const projectRunsMatch = /^\/v1\/projects\/(prj_[A-Za-z0-9_-]+)\/runs$/.exec(url.pathname); if (request.method === "GET" && projectRunsMatch) { const projectRoot = url.searchParams.get("projectRoot") ?? ""; if (!projectRoot) { sendJson(response, 400, { ok: false, error: "runtime_request_invalid", message: "projectRoot query parameter is required" }); return; } const context = contextFor(projectRoot); const requestedProjectId = projectRunsMatch[1] as string; if (requestedProjectId !== context.projectId) { sendJson(response, 400, { ok: false, error: "runtime_request_invalid", message: `project ${requestedProjectId} is not the bound project ${context.projectId}` }); return; } // The stored `runs` row is a cache, not the state: per-run reads fold the event // log (`projectKxmRunReadOnly`) precisely because the row can be stale. A listing // that returned raw rows would let every consumer — including the portal's // tenant read — present cached status as authoritative. Folding replays each // run's events; workflows are transition-bounded, so this stays cheap at the // default 50-run cap (`limit` may ask for up to 10000). A run that refuses to fold is returned with its cached row plus // `projectionError`, so one corrupt run cannot make the listing lie by omission. const limitText = url.searchParams.get("limit"); const parsedLimit = limitText === null ? 50 : Number(limitText); const limit = Number.isInteger(parsedLimit) && parsedLimit > 0 ? Math.min(parsedLimit, 10_000) : 50; const runs = context.eventStore.runsForProject(requestedProjectId, limit).map((stored) => { try { return projectKxmRunReadOnly(context, stored.runId); } catch (error) { return { ...stored, projectionError: error instanceof KxmConfigError ? (error.issues[0]?.code ?? "runtime_projection_failed") : "runtime_projection_failed", }; } }); sendJson(response, 200, { ok: true, runs }); return; } sendJson(response, 404, { ok: false, error: "runtime_not_found" }); } catch (error) { if (error instanceof KxmConfigError) { const code = error.issues[0]?.code ?? "runtime_error"; const status = code === "runtime_auth_failed" ? 401 : code === "run_unknown" ? 404 : 400; sendJson(response, status, { ok: false, error: code, issues: error.issues }); return; } sendJson(response, 500, { ok: false, error: "runtime_internal" }); } })(); }); const port = await new Promise((resolvePort, rejectPort) => { server.once("error", rejectPort); server.listen(requestedPort, "127.0.0.1", () => { const address = server.address(); if (!address || typeof address !== "object") { rejectPort(new Error("runtime supervisor did not bind")); return; } resolvePort(address.port); }); }); // Claim or take over the supervisor singleton. const claim = registry.claimSupervisor({ runtimeId, pid: process.pid, port, tokenHash, now: now() }); if (!claim.claimed) { const existing = claim.record; // Only a genuinely live supervisor (running, fresh heartbeat, live pid) // conflicts. A cleanly stopped or stale supervisor may be replaced. const heartbeatAgeMs = Date.now() - Date.parse(existing.heartbeatAt); const fresh = Number.isFinite(heartbeatAgeMs) && heartbeatAgeMs < HEARTBEAT_STALE_MS; if (existing.state === "running" && fresh && processAlive(existing.pid)) { registry.close(); server.close(); throw runtimeError("runtime_supervisor_conflict", paths.registryDb, `runtime supervisor is already managed by PID ${existing.pid}`); } try { const takeover = registry.takeoverSupervisor({ runtimeId, pid: process.pid, port, tokenHash, now: now(), observedDeadPid: existing.pid, observedHeartbeatAt: existing.heartbeatAt }); activeRuntimeId = takeover.runtimeId; } catch (error) { registry.close(); server.close(); throw error; } } // Ownership is established: publish the token atomically so concurrent // readers never see a truncated file, and clear any stale startup error. try { publishKxmSupervisorToken(paths, token); clearSupervisorError(paths); } catch (error) { registry.close(); server.close(); throw error; } const heartbeat = setInterval(() => { try { const changes = registry.heartbeat(process.pid, now()); if (changes === 0 && !stopping) { // The singleton was taken over by another process: this supervisor is // superseded and must not linger as an unreachable zombie. void stop().finally(() => process.exit(0)); } } catch { /* registry closed during shutdown */ } }, 1000); heartbeat.unref(); // Outbound only: the supervisor pulls nothing and exposes nothing to the hub. // A tick that finds no bound hub, no credential or an unreachable hub does // not fail a run; outbox rows stay pending and local execution never waits on // it. "Never waits" is not "never says anything": every tick records what it // saw into the sync status this process serves at `GET /v1/sync/status`, and // a transport failure backs off instead of knocking every interval. // // Restart recovery: contexts are only populated on demand (a project request // opens one), so a restarted supervisor would see an empty map and silently // stop syncing every registered project's pending outbox rows. Reopen the // projects this Runtime owns before the first tick, and remember the ones that // will not open — a refused schema and an empty outbox are different answers, // and the status surface has to tell them apart. const reopenRegisteredProjects = (): void => { for (const reg of registry.projectsForRuntime(activeRuntimeId)) { const key = projectRuntimeKey(reg.projectRoot); if (contexts.has(key)) continue; try { contextFor(reg.projectRoot); syncStatuses.delete(key); } catch (error) { // A project whose checkout has moved, was deleted, or carries a store // this build refuses to open. Its outbox rows stay pending and its // presence expires, which the ops snapshot shows as orphaned — but the // reason is reported here, on the Runtime, where the operator looks. const reason = syncFailureText(error); const prior = syncStatuses.get(key); // Retried on every tick so a repaired checkout or a retired store heals // without a restart — which means this line is logged on a change of // state only, never once per tick. if (prior === undefined || prior.lastError !== reason) { logger.warn({ event: "runtime_sync_context_unavailable", projectId: reg.projectId, message: `cannot reopen ${reg.projectRoot}: its outbox rows stay pending`, reason, }); } syncStatuses.set(key, { projectId: reg.projectId, projectRoot: reg.projectRoot, homeRuntimeId: activeRuntimeId, state: "blocked", outbox: { pending: 0, acked: 0, refused: 0, refusals: [] }, storeReadable: false, consecutiveFailures: 1, lastError: reason, }); } } }; reopenRegisteredProjects(); const recordSyncStatus = (context: KxmRuntimeContext, next: KxmProjectSyncStatus): void => { const key = projectRuntimeKey(context.projectRoot); const previous = syncStatuses.get(key); syncStatuses.set(key, next); const changed = previous === undefined || previous.state !== next.state || (next.lastError !== undefined && previous?.lastError !== next.lastError) || JSON.stringify(next.outbox.refusals) !== JSON.stringify(previous?.outbox.refusals ?? []); if (!changed) return; // One line per state change — never one per tick, which is how a stalled // sync turns its own log into noise. `refusing` and `blocked` are the two // states that mean "run facts are not reaching the hub", so they are the // states an operator must be able to see without opening SQLite. const stalledState = next.state === "blocked" || next.state === "refusing"; const entry = { event: stalledState ? "runtime_sync_stalled" : "runtime_sync_state", projectId: next.projectId, runtimeId: next.homeRuntimeId, state: next.state, pending: next.outbox.pending, acked: next.outbox.acked, refused: next.outbox.refused, ...(next.outbox.refusals.length > 0 ? { refusals: next.outbox.refusals } : {}), ...(next.hubUrl ? { hubUrl: next.hubUrl } : {}), ...(next.lastError ? { reason: next.lastError } : {}), ...(next.nextAttemptAt ? { nextAttemptAt: next.nextAttemptAt } : {}), }; if (stalledState) logger.warn(entry); else logger.info(entry); }; let syncing = false; let bindingWarning: string | undefined; const syncTimer = setInterval(() => { if (syncing || stopping) return; syncing = true; void (async () => { // A project that could not open last time gets another attempt: the fix on // disk (retired store, restored backup, moved checkout) is not followed by // a restart, and a blocked state that only clears on restart is its own // kind of silence. reopenRegisteredProjects(); for (const context of [...contexts.values()]) { const key = projectRuntimeKey(context.projectRoot); const prior = syncStatuses.get(key); if (prior?.nextAttemptAt && Date.parse(prior.nextAttemptAt) > Date.parse(now())) continue; const base = { projectId: context.projectId, projectRoot: context.projectRoot, homeRuntimeId: context.homeRuntimeId, outbox: context.eventStore.outboxStatus(), consecutiveFailures: 0, }; try { const client = runtimeHubClientFor(context, env, (message) => { if (bindingWarning === message) return; bindingWarning = message; logger.warn({ event: "hub_binding_fallback", message }); }); if (!client) { // Not a failure: a box with no bound hub simply keeps its rows. recordSyncStatus(context, { ...base, state: "no_hub" }); continue; } await client.heartbeat(); const pass = await syncKxmOutbox(context.eventStore, client, { now }); const outbox = context.eventStore.outboxStatus(); // A refused row is out of the pending queue, so there is nothing left to // stampede over: back off only for failures a later pass could actually // fix. The `refusing` state persists for as long as the store holds // refusals, which is the part an operator has to see. const failures = pass.blocked || pass.unconfirmed > 0 ? (prior?.consecutiveFailures ?? 0) + 1 : 0; const delayMs = syncBackoffMs(failures, runtimeSyncIntervalMs(env)); recordSyncStatus(context, { ...base, hubUrl: client.options.serverUrl, outbox, state: pass.blocked ? "blocked" : (outbox.refused > 0 || pass.unconfirmed > 0) ? "refusing" : "ok", lastCompletedAt: now(), lastPushed: pass.pushed, lastAcked: pass.acked, lastRefused: pass.refused, lastUnconfirmed: pass.unconfirmed, consecutiveFailures: failures, ...(pass.blockedReason !== undefined ? { lastError: pass.blockedReason } : {}), ...(delayMs > 0 ? { nextAttemptAt: new Date(Date.parse(now()) + delayMs).toISOString() } : {}), }); } catch (error) { // The hub is unreachable, unauthenticated or answering garbage. The // rows are still pending, so this is retryable — but it is never // silently retried: the reason is recorded and the tick backs off. const failures = (prior?.consecutiveFailures ?? 0) + 1; const delayMs = syncBackoffMs(failures, runtimeSyncIntervalMs(env)); recordSyncStatus(context, { ...base, state: "blocked", consecutiveFailures: failures, lastError: syncFailureText(error), nextAttemptAt: new Date(Date.parse(now()) + delayMs).toISOString(), }); } } })().finally(() => { syncing = false; }); }, runtimeSyncIntervalMs(env)); syncTimer.unref(); let stopping = false; const stop = async (): Promise => { if (stopping) return; stopping = true; clearInterval(heartbeat); clearInterval(syncTimer); try { registry.markStopping(process.pid, now()); } catch { /* best effort */ } const openSessions = [...contexts.values()].flatMap((context) => ( kxmOpenDriveSessions(context.eventStore.path).map((session) => ({ context, session })) )); for (const { context, session } of openSessions) { try { cancelKxmRun(context, session.runId, { reason: "runtime_shutdown" }); } catch { // Terminal or already cancelling runs still release on session settle. } try { session.controller.abort(); } catch { // Session abort is best-effort after cancel is recorded. } } await waitForDriveSessions(openSessions.map(({ session }) => session.settled), runtimeStopGraceMs()); for (const { context, session } of openSessions) { if (!(await driveSessionStillPending(session.settled))) continue; recordDriveReceipt(context, session, { kind: "unsettled", reason: "runtime_shutdown_grace_expired", producerId: session.producerId, producerClosed: false, }); } for (const context of contexts.values()) closeKxmRuntimeContext(context); contexts.clear(); logger.close(); const closed = new Promise((resolveStop) => server.close(() => resolveStop())); server.closeIdleConnections?.(); await closed; try { registry.markStopped(process.pid, now()); } catch { /* best effort */ } registry.close(); }; process.once("SIGINT", () => { void stop().finally(() => process.exit(0)); }); process.once("SIGTERM", () => { void stop().finally(() => process.exit(0)); }); return { server, port, runtimeId: activeRuntimeId, stop }; } /* ------------------------------------------------------------------ * * Thin client used by the CLI * ------------------------------------------------------------------ */ export async function kxmRuntimeRequest( handle: KxmSupervisorHandle, method: "GET" | "POST", path: string, body?: unknown, ): Promise> { const response = await fetch(`http://127.0.0.1:${handle.port}${path}`, { method, headers: { authorization: `Bearer ${handle.token}`, ...(body !== undefined ? { "content-type": "application/json" } : {}), }, ...(body !== undefined ? { body: JSON.stringify(body) } : {}), }); const payload = await response.json() as Record; if (!response.ok) { const code = typeof payload.error === "string" ? payload.error : "runtime_request_failed"; const message = typeof payload.message === "string" ? payload.message : `runtime request failed with HTTP ${response.status}`; throw runtimeError(code, path, `${message}${handoffSuffix(payload.handoff)}`); } return payload; } const HANDOFF_TEXT_MAX = 200; /** Why the Runtime handed a run off, from a `run_handoff_required` body: the * reason, field and detail, each capped so a refusal stays one readable line. */ function handoffSuffix(handoff: unknown): string { if (!handoff || typeof handoff !== "object" || Array.isArray(handoff)) return ""; const parts: string[] = []; for (const key of ["reason", "field", "detail"] as const) { const value = (handoff as Record)[key]; if (typeof value === "string" && value.length > 0) parts.push(`${key} ${value.slice(0, HANDOFF_TEXT_MAX)}`); } return parts.length > 0 ? ` (handoff ${parts.join("; ")})` : ""; }