UNPKG

@kontextmind/kxm

Version:

KXM local-first multi-agent orchestration and operator dashboard

295 lines (276 loc) • 12.6 kB
#!/usr/bin/env node import { spawn, spawnSync } from "node:child_process"; import { randomBytes } from "node:crypto"; import { closeSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs"; import { homedir } from "node:os"; import { join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; const server = fileURLToPath(new URL("../plugins/kxm/dist/server.js", import.meta.url)); const workdir = resolve(process.env.KXM_WORKDIR?.trim() || process.cwd()); const workspaceDir = resolve(workdir, process.env.KXM_WORKSPACE_DIR?.trim() || ".kxm"); const stateDir = resolve(workdir, process.env.KXM_STATE_DIR?.trim() || join(workspaceDir, "state")); mkdirSync(stateDir, { recursive: true }); const pidPath = join(stateDir, "hub.pid"); const startedAt = new Date().toISOString(); const controlFile = "hub.stop"; const controlPath = join(stateDir, controlFile); // --- Persistent hub credentials (kxm.hub-env.v1) ------------------------- // Precedence: KXM_AUTH_TOKEN/KXM_PROJECT_TOKENS env, then the persisted // user-state file, then a generated admin token that is persisted so later // restarts, workers, and dashboards reuse the same credential. function resolveUserStateRoot() { const explicit = process.env.KXM_STATE_HOME?.trim(); if (explicit) return resolve(explicit); const home = homedir(); if (process.platform === "win32") return resolve(process.env.LOCALAPPDATA?.trim() || join(home, "AppData", "Local"), "KXM"); if (process.platform === "darwin") return resolve(home, "Library", "Application Support", "KXM"); return resolve(process.env.XDG_STATE_HOME?.trim() || join(home, ".local", "state"), "kxm"); } function processExists(pid) { try { process.kill(pid, 0); return true; } catch (error) { return error?.code === "EPERM"; } } /** * Live hub claim, if any. Read-only: returns the pid of a well-formed * `hub.pid` claim whose wrapper is still alive, otherwise null. * * Claims that are missing, malformed, dead, or stale return null so the * atomic `claimPidFile()` below keeps sole authority over those cases — it is * the real single-instance guard and fails closed on them. This exists only so * a start that is certainly going to be refused can refuse *before* it * persists credentials. */ function liveHubClaimPid() { let existing; try { existing = JSON.parse(readFileSync(pidPath, "utf8")); } catch { return null; } if (existing?.version === 1 && existing.role === "hub" && Number.isInteger(existing.pid) && processExists(existing.pid)) { return existing.pid; } return null; } function parseHubEnvFile() { const file = join(resolveUserStateRoot(), "hub-env.json"); let raw; try { raw = readFileSync(file, "utf8"); } catch (error) { if (error?.code === "ENOENT") return { file, record: undefined }; throw new Error(`KXM hub could not read the persisted hub env at ${file}: ${error.message}`); } let parsed; try { parsed = JSON.parse(raw); } catch (error) { throw new Error(`KXM hub env file is malformed at ${file}; fix or remove it (it holds only KXM_AUTH_TOKEN/KXM_PROJECT_TOKENS values): ${error.message}`); } if (!parsed || typeof parsed !== "object" || Array.isArray(parsed) || parsed.schema !== "kxm.hub-env.v1") { throw new Error(`KXM hub env file at ${file} does not use schema kxm.hub-env.v1`); } return { file, record: parsed }; } function generateAdminToken() { return `kxm_admin_${randomBytes(24).toString("base64url")}`; } function persistHubEnv(file, record) { mkdirSync(resolve(file, ".."), { recursive: true }); const temporary = join(resolve(file, ".."), `.hub-env-${process.pid}.tmp`); writeFileSync(temporary, `${JSON.stringify(record, undefined, 2)}\n`, { encoding: "utf8", mode: 0o600 }); try { renameSync(temporary, file); } catch (error) { rmSync(temporary, { force: true }); throw error; } } function parseProjectTokensEnv(raw) { const trimmed = raw?.trim(); if (!trimmed) return undefined; let parsed; try { parsed = JSON.parse(trimmed); } catch { throw new Error("KXM_PROJECT_TOKENS must be a JSON object of project names to tokens"); } if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { throw new Error("KXM_PROJECT_TOKENS must be a JSON object of project names to tokens"); } for (const [project, token] of Object.entries(parsed)) { if (!project.trim() || typeof token !== "string" || !token.trim()) { throw new Error("KXM_PROJECT_TOKENS must contain non-empty project names and token strings"); } } return parsed; } /** Resolve credentials, persisting generated/env values into the user-state file. */ function resolveCredentials() { const { file, record } = parseHubEnvFile(); const envToken = process.env.KXM_AUTH_TOKEN?.trim(); const envProjectTokens = parseProjectTokensEnv(process.env.KXM_PROJECT_TOKENS); let authToken = envToken || record?.authToken; let authTokenSource = envToken ? "env" : record?.authToken ? "file" : "none"; if (!authToken) { authToken = generateAdminToken(); authTokenSource = "generated"; } const projectTokens = envProjectTokens || record?.projectTokens; const projectTokensSource = envProjectTokens ? "env" : record?.projectTokens ? "file" : "none"; const envChanged = (authTokenSource === "env" && record?.authToken !== authToken) || (projectTokensSource === "env" && JSON.stringify(record?.projectTokens ?? undefined) !== JSON.stringify(projectTokens ?? undefined)); if (authTokenSource === "generated" || envChanged) { persistHubEnv(file, { schema: "kxm.hub-env.v1", createdAt: record?.createdAt || new Date().toISOString(), ...(authToken ? { authToken } : {}), ...(projectTokens ? { projectTokens } : {}), }); } return { authToken, authTokenSource, projectTokens, projectTokensSource, file }; } // Pre-flight: refuse a start that a live hub already owns before // resolveCredentials() can generate and persist an admin token that the // running hub never issued. `kxm hub start` used to mint the credential at // module load and only then report "already managed by PID <n>", leaving a // hub-env.json whose token no process trusts. try { const livePid = liveHubClaimPid(); if (livePid) { process.stderr.write(`kxm hub: KXM hub is already managed by PID ${livePid}. Run kxm hub stop before starting another hub.\n`); process.exit(1); } } catch { /* the atomic claim below still guards startup */ } let credentials; try { credentials = resolveCredentials(); } catch (error) { process.stderr.write(`kxm hub: ${error.message}\n`); process.exit(1); } const operatorSetProjectTokens = Boolean(process.env.KXM_PROJECT_TOKENS?.trim()); const launchEnv = { ...process.env }; if (credentials.authToken) launchEnv.KXM_AUTH_TOKEN = credentials.authToken; if (credentials.projectTokens) launchEnv.KXM_PROJECT_TOKENS = JSON.stringify(credentials.projectTokens); // The operator's KXM_PROJECT_TOKENS replaces config. A map copied from // hub-env.json does not, so the server can prefer hub.projects references // and keep the resolved tokens out of the credential file. if (operatorSetProjectTokens) launchEnv.KXM_PROJECT_TOKENS_EXPLICIT = "1"; if (credentials.authTokenSource !== "env") { process.stdout.write(`kxm hub: using ${credentials.authTokenSource === "generated" ? "newly generated" : "persisted"} KXM_AUTH_TOKEN from ${credentials.file}\n`); } function terminateTree(pid) { // Best-effort cleanup of an orphaned hub server whose wrapper died // without reclaiming (e.g. SIGKILL). The wrapper could not be stopped // gracefully in that state, so the server is asked to shut down first and // forced after a short grace period. if (!Number.isInteger(pid) || pid <= 0) return; try { process.kill(pid, "SIGTERM"); } catch { /* already gone */ } const deadline = Date.now() + 3000; while (Date.now() < deadline) { if (!processExists(pid)) return; const wait = Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 100); if (wait !== "timed-out") return; } try { process.kill(pid, "SIGKILL"); } catch { /* already gone */ } } function claimPidFile() { const record = { version: 1, pid: process.pid, role: "hub", startedAt, controlFile }; for (let attempt = 0; attempt < 3; attempt += 1) { let descriptor; try { descriptor = openSync(pidPath, "wx", 0o600); writeFileSync(descriptor, `${JSON.stringify(record)}\n`, "utf8"); closeSync(descriptor); descriptor = undefined; return; } catch (error) { if (descriptor !== undefined) closeSync(descriptor); if (error?.code !== "EEXIST") throw error; let existing; try { existing = JSON.parse(readFileSync(pidPath, "utf8")); } catch (existingError) { if (existingError?.code === "ENOENT") continue; throw new Error( `KXM hub PID claim is invalid at ${pidPath}; remove it only after verifying no hub process is running`, { cause: existingError }, ); } if (existing?.version === 1 && existing.role === "hub" && Number.isInteger(existing.pid) && processExists(existing.pid)) { throw new Error(`KXM hub is already managed by PID ${existing.pid}`); } // A well-formed claim whose wrapper is dead is safe to reclaim. Keep // malformed claims fail-closed because they may belong to another tool. if (existing?.version === 1 && existing.role === "hub" && Number.isInteger(existing.pid) && !processExists(existing.pid)) { // The wrapper is gone. An orphaned server child recorded by the dead // wrapper is cleaned up before reclaiming so a SIGKILLed wrapper does // not leave a port-holding server behind. if (Number.isInteger(existing.serverPid) && existing.serverPid !== existing.pid && processExists(existing.serverPid)) { process.stderr.write(`kxm hub: cleaning up orphaned hub server PID ${existing.serverPid} from a dead wrapper\n`); terminateTree(existing.serverPid); } rmSync(pidPath, { force: true }); continue; } throw new Error( `KXM hub PID claim is stale at ${pidPath}; remove it only after verifying no hub process is running`, ); } } throw new Error("could not claim the KXM hub PID file"); } try { claimPidFile(); } catch (error) { process.stderr.write(`kxm hub: ${error.message}. Run kxm hub stop before starting another hub.\n`); process.exit(1); } rmSync(controlPath, { force: true }); function cleanupPid() { try { const record = JSON.parse(readFileSync(pidPath, "utf8")); if (record.pid === process.pid) rmSync(pidPath, { force: true }); } catch { /* replaced */ } } process.once("exit", cleanupPid); const child = spawn(process.execPath, [ "--disable-warning=ExperimentalWarning", server, ...process.argv.slice(2), ], { stdio: ["inherit", "inherit", "inherit", "ipc"], env: launchEnv, }); // Record the server child PID inside the claim so a later wrapper can clean // up an orphaned server if this wrapper is killed without running cleanupPid. if (child.pid) { try { writeFileSync(pidPath, `${JSON.stringify({ version: 1, pid: process.pid, serverPid: child.pid, role: "hub", startedAt, controlFile })}\n`, "utf8"); } catch { /* claim already written; serverPid tracking is best-effort */ } } let stopping = false; child.once("error", (error) => { process.stderr.write(`Failed to start KXM hub: ${error.message}\n`); process.exitCode = 1; }); child.once("exit", (code, signal) => { process.exitCode = stopping ? 0 : (code ?? (signal ? 1 : 1)); }); function requestShutdown(signal) { if (stopping) return; stopping = true; if (!child.connected) { child.kill("SIGTERM"); return; } child.send({ type: "shutdown", signal }); const force = setTimeout(() => { if (child.exitCode !== null || child.signalCode !== null) return; if (process.platform === "win32" && child.pid) spawnSync("taskkill.exe", ["/pid", String(child.pid), "/t", "/f"], { windowsHide: true, stdio: "ignore" }); else child.kill("SIGKILL"); }, 10_000); force.unref(); } for (const signal of ["SIGINT", "SIGTERM"]) process.once(signal, () => requestShutdown(signal)); const controlTimer = setInterval(() => { try { const request = JSON.parse(readFileSync(controlPath, "utf8")); if (request.startedAt !== startedAt) return; rmSync(controlPath, { force: true }); requestShutdown("operator"); } catch { /* no request */ } }, 250); controlTimer.unref();