UNPKG

@kontextmind/kxm

Version:

KXM local-first multi-agent orchestration and operator dashboard

238 lines (215 loc) • 10.5 kB
import { spawn } from "node:child_process"; import { closeSync, mkdirSync, openSync, readFileSync } from "node:fs"; import { join, resolve } from "node:path"; import { probeHubHealth, readHubBinding } from "./hub-binding.ts"; import { describeHubConnection } from "./hub-identity.ts"; import { resolveHubCredentials } from "./hub-env.ts"; import { findKxmRepoRoot } from "./repo-root.ts"; import { redactSecrets } from "./redact.ts"; /** Background hub auto-start for harness extensions (Pi TUI, one-shot CLIs). * * On extension load: reuse a healthy bound hub, then a live local `hub.pid` * claim, and only then spawn the detached `kxm-hub.mjs` wrapper. Credentials * are resolved before launch, so a first run generates the admin token and * persists it (`0600`) under the user state root, exactly as * `kxm hub start` does. The single-hub-per-user invariants (exclusive PID * claim, reclaimed dead wrappers) come from the wrapper itself. */ export const HUB_AUTOSTART_LOG_NAME = "hub-autostart.log"; const CLAIM_POLL_MS = 50; const DEFAULT_CLAIM_TIMEOUT_MS = 3000; export type HubAutoStartMode = "off" | "background"; export type EnsureHubResult = | { status: "disabled" } | { status: "bound-healthy"; url: string } | { status: "url-healthy"; url: string } | { status: "claim-alive"; pid: number } | { status: "started"; pid: number; logPath: string; authToken: string | undefined; authTokenSource: string } | { status: "failed"; reason: string; logPath: string }; export interface HubSpawnOptions { cwd: string; env: NodeJS.ProcessEnv; stdio: ["ignore", number, number]; detached: boolean; windowsHide: boolean; } export interface HubSpawned { pid?: number | undefined; once(event: "exit", listener: (code: number | null, signal: NodeJS.Signals | null) => void): unknown; } export type HubSpawner = (command: string, args: readonly string[], options: HubSpawnOptions) => HubSpawned; export interface EnsureHubOptions { config: { hub?: { autoStart?: unknown } } | undefined; cwd: string; env?: NodeJS.ProcessEnv | undefined; fetchImpl?: typeof fetch | undefined; spawner?: HubSpawner | undefined; processExists?: ((pid: number) => boolean) | undefined; scriptPath?: string | undefined; sleep?: ((ms: number) => Promise<void>) | undefined; now?: (() => number) | undefined; claimTimeoutMs?: number | undefined; } /** Resolve the configured auto-start mode; unknown values fail closed to the * default so a typo never silently disables (or re-enables) hub startup. */ export function hubAutoStartMode(config: { hub?: { autoStart?: unknown } } | undefined): HubAutoStartMode { const raw = config?.hub?.autoStart; return raw === "off" || raw === "background" ? raw : "background"; } function defaultProcessExists(pid: number): boolean { try { process.kill(pid, 0); return true; } catch (error) { return (error as NodeJS.ErrnoException).code === "EPERM"; } } export interface HubClaimSnapshot { pid: number; serverPid: number | undefined; } /** Read the workspace hub PID claim. A missing or malformed claim is not * live; the wrapper fails closed on malformed claims when it starts, so the * extension must not try to clean claims up itself. */ export function readLiveHubClaim(stateDir: string, processExists: (pid: number) => boolean = defaultProcessExists): HubClaimSnapshot | undefined { let parsed: unknown; try { parsed = JSON.parse(readFileSync(join(stateDir, "hub.pid"), "utf8")); } catch { return undefined; } if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return undefined; const record = parsed as { version?: unknown; role?: unknown; pid?: unknown; serverPid?: unknown }; if (record.version !== 1 || record.role !== "hub" || !Number.isInteger(record.pid)) return undefined; const pid = record.pid as number; if (pid <= 0 || !processExists(pid)) return undefined; return { pid, ...(Number.isInteger(record.serverPid) && (record.serverPid as number) > 0 ? { serverPid: record.serverPid as number } : { serverPid: undefined }), }; } /** The hub wrapper script at the KXM repo/package root. The extension and * the CLI both resolve the same path so auto-start launches the identical * supervision wrapper as `kxm hub start`. */ export function defaultHubWrapperScriptPath(moduleUrl: string = import.meta.url): string { return join(findKxmRepoRoot(moduleUrl), "scripts", "kxm-hub.mjs"); } function defaultHubSpawner(command: string, args: readonly string[], options: HubSpawnOptions): HubSpawned { const child = spawn(command, [...args], options); child.unref(); return child; } function logTail(logPath: string, maxChars = 2000): string { try { return redactSecrets(readFileSync(logPath, "utf8").trim().slice(-maxChars)); } catch { return ""; } } /** Ensure a hub is available, starting a detached one only when no healthy * bound hub and no live local claim exists. Never throws for ordinary * failures; structured results let callers notify without blocking load. */ export async function ensureHubRunning(options: EnsureHubOptions): Promise<EnsureHubResult> { const env = options.env ?? process.env; if (hubAutoStartMode(options.config) !== "background") return { status: "disabled" }; // 1. A bound hub that answers health is authoritative — reuse it. const binding = readHubBinding(env); const fetchImpl = options.fetchImpl ?? fetch; if (binding) { const { health } = await probeHubHealth(binding.url, fetchImpl); if (health === "on") return { status: "bound-healthy", url: binding.url }; // A cloud binding names a remote hub. A dead forward must not start a local hub // that would then authenticate with this machine's hub-env token. if (binding.cloud) return { status: "failed", reason: "cloud_hub_unreachable", logPath: "" }; } // A cloud endpoint in config is the same rule when no cloud binding is on disk: // a dead forward must not start a local hub. if (!binding?.cloud) { try { const connection = describeHubConnection(options.cwd, env); if (connection.mode === "cloud") { const { health } = await probeHubHealth(connection.url, fetchImpl); if (health === "on") return { status: "bound-healthy", url: connection.url }; return { status: "failed", reason: "cloud_hub_unreachable", logPath: "" }; } } catch (error) { return { status: "failed", reason: error instanceof Error ? error.message : "hub_config_invalid", logPath: "", }; } } // 2. A live local claim means a hub (started any way) already owns this // workspace state — do not start a second one. const workdir = resolve(env.KXM_WORKDIR?.trim() || options.cwd); const workspaceDir = resolve(workdir, env.KXM_WORKSPACE_DIR?.trim() || ".kxm"); const stateDir = resolve(workdir, env.KXM_STATE_DIR?.trim() || join(workspaceDir, "state")); const logsDir = resolve(workdir, env.KXM_LOGS_DIR?.trim() || join(workspaceDir, "logs")); const processExists = options.processExists ?? defaultProcessExists; const live = readLiveHubClaim(stateDir, processExists); if (live) return { status: "claim-alive", pid: live.pid }; // 3. A hub already serving the configured URL (for example one launched // directly by a harness, without a claim or binding) is reused too: the // client will connect there regardless, so spawning a duplicate would // only fail to bind the same port. const serverUrl = (env.KXM_SERVER_URL?.trim() || "http://127.0.0.1:7331").replace(/\/+$/, ""); if (serverUrl !== binding?.url) { const { health } = await probeHubHealth(serverUrl, fetchImpl); if (health === "on") return { status: "url-healthy", url: serverUrl }; } // 4. Resolve credentials before launch so the key exists by the time the // wrapper starts: env wins, then the persisted user-state file, then a // generated token persisted with 0600 for later restarts and workers. const credentials = resolveHubCredentials({ env }); const logPath = join(logsDir, HUB_AUTOSTART_LOG_NAME); mkdirSync(logsDir, { recursive: true }); const logFd = openSync(logPath, "a"); let child: HubSpawned; try { child = (options.spawner ?? defaultHubSpawner)(process.execPath, [options.scriptPath ?? defaultHubWrapperScriptPath()], { cwd: workdir, env: { ...env, KXM_WORKDIR: workdir, KXM_WORKSPACE_DIR: workspaceDir, KXM_STATE_DIR: stateDir, KXM_LOGS_DIR: logsDir, ...(credentials.authToken ? { KXM_AUTH_TOKEN: credentials.authToken } : {}), }, stdio: ["ignore", logFd, logFd], detached: process.platform !== "win32", windowsHide: true, }); } finally { closeSync(logFd); } // 4. Verify the wrapper claimed the hub. A simultaneous loader may have won // the exclusive claim first; that is a live hub, not a failure. const sleep = options.sleep ?? ((ms: number) => new Promise<void>((resolveSleep) => setTimeout(resolveSleep, ms))); const now = options.now ?? Date.now; const timeoutMs = options.claimTimeoutMs ?? DEFAULT_CLAIM_TIMEOUT_MS; let earlyExit: { code: number | null; signal: NodeJS.Signals | null } | undefined; child.once("exit", (code, signal) => { earlyExit = { code, signal }; }); const deadline = now() + timeoutMs; for (;;) { if (earlyExit) { // A simultaneous loader may have won the exclusive claim first; that // is a live hub, not a failure. const raced = readLiveHubClaim(stateDir, processExists); if (raced) return { status: "claim-alive", pid: raced.pid }; const detail = logTail(logPath); const reason = `hub wrapper exited before claiming the hub (code ${earlyExit.code ?? "null"}${earlyExit.signal ? `, signal ${earlyExit.signal}` : ""})${detail ? `: ${detail}` : ""}`; return { status: "failed", reason, logPath }; } const claimed = readLiveHubClaim(stateDir, processExists); if (claimed) return { status: "started", pid: claimed.pid, logPath, authToken: credentials.authToken, authTokenSource: credentials.authTokenSource }; if (now() >= deadline) { return child.pid ? { status: "started", pid: child.pid, logPath, authToken: credentials.authToken, authTokenSource: credentials.authTokenSource } : { status: "failed", reason: `hub wrapper did not claim the hub within ${timeoutMs} ms`, logPath }; } await sleep(CLAIM_POLL_MS); } }