@kontextmind/kxm
Version:
KXM local-first multi-agent orchestration and operator dashboard
238 lines (215 loc) • 10.5 kB
text/typescript
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);
}
}