@kontextmind/kxm
Version:
KXM local-first multi-agent orchestration and operator dashboard
317 lines (292 loc) • 11.9 kB
text/typescript
import { spawn } from "node:child_process";
import { join, resolve } from "node:path";
import { findKxmRepoRoot } from "../repo-root.ts";
import type { Command } from "commander";
import { redactSecrets } from "../redact.ts";
import { appendTelemetry, inferImprovementTarget, makeTelemetryEvent, telemetryPath } from "../telemetry.ts";
import { gateWorker, workerResult, type Worker, type WorkerOutcome } from "../envelope.ts";
import { canonicalWorkflowEvidenceKey, type WorkflowEvidenceInput } from "../workflow.ts";
import {
HubBindingError,
effectiveHubBindingScope,
hubBindingFile,
loadHubBinding,
} from "../hub-binding.ts";
import { describeHubConnection } from "../hub-identity.ts";
import type { InstallProbe } from "../kxm-install-kind.ts";
export interface CliSpawnResult {
status: number | null;
stdout: string;
stderr: string;
error?: Error;
}
export interface CliIo {
stdout: (text: string) => void;
stderr: (text: string) => void;
fetchImpl?: typeof fetch;
now?: () => number;
sleep?: (ms: number) => Promise<void>;
spawnHub?: (extraEnv: NodeJS.ProcessEnv) => number | Promise<number>;
spawnWorker?: (extraEnv: NodeJS.ProcessEnv) => number | Promise<number>;
installProbe?: Partial<InstallProbe>;
spawnSync?: (command: string, args: readonly string[]) => CliSpawnResult;
}
export interface CliContext {
env: NodeJS.ProcessEnv;
io: CliIo;
cwd: string;
}
export interface GlobalOpts {
json?: boolean;
dryRun?: boolean;
workspace?: string;
}
export interface WorkspaceDirs {
workdir: string;
workspace: string;
config: string;
logs: string;
assets: string;
state: string;
}
export interface Runtime extends CliContext, Required<Pick<GlobalOpts, "json" | "dryRun">> {
workspaceFlag?: string;
dirs: WorkspaceDirs;
serverUrl: string;
boundHubUrl?: string;
fetchImpl: typeof fetch;
}
export function spawnScript(scriptName: string, extraEnv: NodeJS.ProcessEnv = {}): Promise<number> {
return new Promise((resolveExit) => {
const child = spawn(process.execPath, [join(findKxmRepoRoot(import.meta.url), "scripts", scriptName)], {
stdio: "inherit",
env: { ...process.env, ...extraEnv },
});
child.once("error", () => resolveExit(1));
child.once("exit", (code) => resolveExit(code ?? 1));
});
}
export function parseEvidencePairs(values: string[]): WorkflowEvidenceInput {
const evidence = new Map<string, string>();
for (const value of values) {
const separator = value.indexOf("=");
if (separator <= 0 || separator === value.length - 1) {
throw new Error("evidence must use <required-key>=<evidence> syntax");
}
const requirement = canonicalWorkflowEvidenceKey(value.slice(0, separator));
const proof = value.slice(separator + 1).trim();
if (!requirement || !proof) throw new Error("evidence must use <required-key>=<evidence> syntax");
if (evidence.has(requirement)) throw new Error(`duplicate normalized evidence key: ${requirement}`);
evidence.set(requirement, proof);
}
return Object.fromEntries(evidence);
}
export const CLI_RESULT_SCHEMA = "kxm.cli-result.v1";
/** Every CLI payload carries a schema tag; payloads that already declare one
* (worker envelopes) keep theirs. `ok:false` goes to stderr in both modes so
* agents can split results from errors without parsing. */
export function print(io: CliIo, jsonMode: boolean, payload: object, text: string): void {
const tagged = { schema: CLI_RESULT_SCHEMA, ...payload } as Record<string, unknown>;
const safePayload = JSON.stringify(redactCliValue(tagged));
const line = jsonMode ? `${safePayload}\n` : `${redactSecrets(text)}\n`;
if (tagged.ok === false) io.stderr(line);
else io.stdout(line);
}
/** One change a command would have made had `--dry-run` not been given. */
export interface PlannedChange {
action: "write" | "delete" | "move" | "request" | "ssh";
target: string;
}
/** The `--dry-run` answer of a mutating command: the envelope the real run
* prints, marked `dryRun: true`, plus every write, request, or remote step it
* would have taken. Nothing is written by the caller before or after this. */
export function printPlan(
runtime: Runtime,
payload: Record<string, unknown> & { command: string },
planned: readonly PlannedChange[],
text: string,
): void {
print(runtime.io, runtime.json, { ok: true, ...payload, dryRun: true, planned }, [
`dry run: ${text}`,
...(planned.length === 0 ? [" no changes"] : planned.map((change) => ` would ${change.action} ${change.target}`)),
].join("\n"));
}
export const DRY_RUN_UNSUPPORTED = "dry_run_unsupported";
/** For a command that cannot say what it would do without doing some of it:
* refuse under `--dry-run` instead of executing. */
export function refuseDryRun(io: CliIo, jsonMode: boolean, command: string, reason: string): number {
print(io, jsonMode, { ok: false, command, dryRun: true, error: DRY_RUN_UNSUPPORTED, detail: reason }, `kxm ${command} --dry-run refused: ${reason}`);
return 2;
}
export function printWorker(
runtime: Runtime,
worker: Worker,
payload: Record<string, unknown> & { command: string; ok: boolean },
text: string,
outcome?: WorkerOutcome,
): void {
const sessionId = runtime.env.KXM_SESSION_ID?.trim();
const envelope = workerResult(worker, {
...payload,
summary: text,
...(outcome ? { outcome } : {}),
...(sessionId ? { sessionId } : {}),
});
if (!runtime.dryRun) {
try {
const safeEnvelope = redactCliValue(envelope) as typeof envelope;
appendTelemetry(telemetryPath(runtime.dirs.logs), makeTelemetryEvent({
envelope: safeEnvelope,
...(sessionId ? { sessionId } : {}),
host: hostMode(runtime),
target: inferImprovementTarget({
...(worker.project ? { project: worker.project } : {}),
env: runtime.env,
}),
}));
} catch {
// Telemetry must never fail the operator command.
}
}
print(runtime.io, runtime.json, envelope, text);
}
export function hostMode(runtime: Runtime): "local" | "hub" {
try {
if (describeHubConnection(runtime.cwd, runtime.env).scope === "remote") return "hub";
} catch {
// A malformed binding is classified from the URL below.
}
try {
if (effectiveHubBindingScope(runtime.serverUrl, runtime.env) === "remote") return "hub";
} catch {
// A malformed binding is classified from the URL below.
}
try {
const hostname = new URL(runtime.serverUrl).hostname;
if (hostname === "127.0.0.1" || hostname === "localhost" || hostname === "::1") return "local";
} catch {
// Default local when the hub URL is not a URL.
}
return "hub";
}
export function gateOf(runtime: Runtime, name: string): ReturnType<typeof gateWorker> {
const project = runtime.env.KXM_PROJECT?.trim();
return gateWorker({ name, ...(project ? { project } : {}) });
}
export function redactCliValue(value: unknown, field = ""): unknown {
if (typeof value === "string") {
// These fields are public audit digests, not credentials. Preserve
// them only by exact field name and shape; every other 64-hex value keeps
// the conservative generic redaction behavior.
if (
(field === "requestSha256"
|| field === "replySha256"
|| field === "behavioralSha256"
|| field === "workflowDefinitionSha256"
|| field === "verifierConfigSha256"
|| field === "rolePromptSha256"
|| field === "contentSha256"
|| field === "configRevision"
|| field === "baseSha256"
|| field === "localSha256"
|| field === "targetSha256"
|| field === "sourceTemplateRevision"
|| field === "targetTemplateRevision"
|| field === "sourceDigest"
|| field === "decisionDigest"
|| field === "receiptSha256"
|| field === "sha256"
|| field === "valueSha256"
|| field === "baseRevision"
|| field === "candidateRevision"
|| field === "baseValueSha256"
|| field === "candidateValueSha256"
|| field === "workflowHash"
|| field === "promptHash"
|| field === "askSha256")
&& /^(?:sha256:)?[a-f0-9]{64}$/.test(value)
) return value;
return redactSecrets(value);
}
if (Array.isArray(value)) return value.map((candidate) => redactCliValue(candidate));
if (value && typeof value === "object") {
return Object.fromEntries(
Object.entries(value as Record<string, unknown>)
.map(([key, candidate]) => [key, redactCliValue(candidate, key)]),
);
}
return value;
}
export function workspaceDirs(cwd: string, workspaceFlag: string | undefined, env: NodeJS.ProcessEnv): WorkspaceDirs {
const workdir = resolve(env.KXM_WORKDIR?.trim() || cwd);
const workspace = resolve(workdir, workspaceFlag || env.KXM_WORKSPACE_DIR?.trim() || ".kxm");
const derive = workspaceFlag !== undefined;
return {
workdir,
workspace,
config: derive ? join(workspace, "config") : resolve(workdir, env.KXM_CONFIG_DIR?.trim() || join(workspace, "config")),
logs: derive ? join(workspace, "logs") : resolve(workdir, env.KXM_LOGS_DIR?.trim() || join(workspace, "logs")),
assets: derive ? join(workspace, "assets") : resolve(workdir, env.KXM_ASSETS_DIR?.trim() || join(workspace, "assets")),
state: derive ? join(workspace, "state") : resolve(workdir, env.KXM_STATE_DIR?.trim() || join(workspace, "state")),
};
}
export function maskEnvName(name: string): boolean {
return /TOKEN|SECRET|KEY|PASSWORD/i.test(name);
}
export function redactConfiguredValues(text: string, env: NodeJS.ProcessEnv): string {
let safe = text;
for (const [name, value] of Object.entries(env)) {
if (!maskEnvName(name) || !value || value.length < 4) continue;
safe = safe.replaceAll(value, "[redacted]");
}
const trailingNewline = safe.endsWith("\n") ? "\n" : "";
try {
const parsed = JSON.parse(safe) as unknown;
return `${JSON.stringify(redactCliValue(parsed))}${trailingNewline}`;
} catch {
// Human-readable output and diagnostics keep conservative generic
// redaction, including opaque 64-hex values.
}
return redactSecrets(safe);
}
export function processExists(pid: number): boolean {
try { process.kill(pid, 0); return true; } catch (error) { return (error as NodeJS.ErrnoException).code === "EPERM"; }
}
export function runtimeFrom(ctx: CliContext, command: Command): Runtime {
const opts = command.optsWithGlobals() as GlobalOpts;
const envServerUrl = ctx.env.KXM_SERVER_URL?.trim();
let boundHubUrl: string | undefined;
try {
const loaded = loadHubBinding(ctx.env);
if (loaded.warning) ctx.io.stderr(`kxm: ${loaded.warning}\n`);
boundHubUrl = loaded.record?.url;
} catch (error) {
if (error instanceof HubBindingError) {
ctx.io.stderr(`kxm: ignoring malformed hub binding at ${hubBindingFile(ctx.env)}; falling back to the local hub. Run kxm hub bind <url> again\n`);
} else {
throw error;
}
}
const connection = describeHubConnection(ctx.cwd, ctx.env);
return {
...ctx,
json: Boolean(opts.json),
dryRun: Boolean(opts.dryRun),
...(opts.workspace === undefined ? {} : { workspaceFlag: opts.workspace }),
dirs: workspaceDirs(ctx.cwd, opts.workspace, ctx.env),
serverUrl: connection.url || envServerUrl || boundHubUrl || "http://127.0.0.1:7331",
...(boundHubUrl ? { boundHubUrl } : {}),
fetchImpl: ctx.io.fetchImpl ?? fetch,
};
}
export function workspaceEnv(runtime: Runtime): NodeJS.ProcessEnv {
return {
KXM_WORKDIR: runtime.dirs.workdir,
KXM_WORKSPACE_DIR: runtime.dirs.workspace,
KXM_CONFIG_DIR: runtime.dirs.config,
KXM_LOGS_DIR: runtime.dirs.logs,
KXM_ASSETS_DIR: runtime.dirs.assets,
KXM_STATE_DIR: runtime.dirs.state,
KXM_SERVER_URL: runtime.serverUrl,
};
}