UNPKG

@kontextmind/kxm

Version:

KXM local-first multi-agent orchestration and operator dashboard

563 lines (531 loc) • 19.2 kB
import { randomUUID } from "node:crypto"; import { existsSync, readFileSync } from "node:fs"; import { join, resolve } from "node:path"; import { createInterface } from "node:readline"; import { parse as parseYaml, stringify as stringifyYaml } from "yaml"; import { DatabaseSync, openReadOnlyDatabase } from "../sqlite.ts"; import { listRoles, getRole, addRole, removeRole, modifyRole, parseRoleFile, rolePurposeForId, type KxmRoleDefinition, } from "../role.ts"; import { discoverKxmProjectRoot, kxmRoleWriteIssues } from "../project-config.ts"; import { findYamlBasename } from "../workforce-names.mjs"; import { ensureKxmSupervisor, kxmRuntimeRequest } from "../runtime-supervisor.ts"; import { projectRuntimeOwnsRun } from "../runtime-store.ts"; import { resumeWorkflowFromRuling, type WorkflowRun } from "../workflow.ts"; import { print, printPlan, type CliIo, type Runtime } from "./types.ts"; export interface PickCandidate { id: string; label?: string | undefined; description?: string | undefined; payload?: any; } export async function resolvePickItem( io: CliIo, title: string, items: PickCandidate[], pickOption?: string | boolean | undefined, env?: NodeJS.ProcessEnv | undefined, ): Promise<PickCandidate | undefined> { if (items.length === 0) return undefined; if (typeof pickOption === "string" && pickOption.trim().length > 0) { const trimmed = pickOption.trim(); const asNum = parseInt(trimmed, 10); if (!isNaN(asNum) && asNum >= 1 && asNum <= items.length) { return items[asNum - 1]; } const matched = items.find((it) => it.id === trimmed || it.id.toLowerCase() === trimmed.toLowerCase()); if (matched) return matched; } const lines: string[] = [`${title}:`]; items.forEach((item, idx) => { const desc = item.description ? ` - ${item.description}` : ""; const label = item.label ? ` [${item.label}]` : ""; lines.push(` ${(idx + 1).toString().padStart(2)}) ${item.id}${label}${desc}`); }); io.stdout(`${lines.join("\n")}\n`); const envSelect = env?.KXM_PICK_SELECT ?? process.env.KXM_PICK_SELECT; if (envSelect) { const asNum = parseInt(envSelect, 10); if (!isNaN(asNum) && asNum >= 1 && asNum <= items.length) { return items[asNum - 1]; } const matched = items.find((it) => it.id === envSelect); if (matched) return matched; } if (!process.stdin.isTTY) { return undefined; } const rl = createInterface({ input: process.stdin, output: process.stdout, }); return new Promise((resolvePrompt) => { rl.question(`Enter selection (1-${items.length}) or ID: `, (answer) => { rl.close(); const ans = answer.trim(); const asNum = parseInt(ans, 10); if (!isNaN(asNum) && asNum >= 1 && asNum <= items.length) { resolvePrompt(items[asNum - 1]); } else { const matched = items.find((it) => it.id === ans); resolvePrompt(matched ?? undefined); } }); }); } export async function cmdRoleList( runtime: Runtime, options: { scope?: "all" | "global" | "local" | undefined }, ): Promise<number> { const roles = listRoles({ scope: options.scope, repoRoot: runtime.cwd, userConfigDir: runtime.env.KXM_USER_CONFIG_DIR, }); if (runtime.json) { print(runtime.io, true, { ok: true, command: "role list", roles }, ""); return 0; } if (roles.length === 0) { runtime.io.stdout("No roles configured.\n"); return 0; } const lines: string[] = ["ROLES:"]; for (const r of roles) { const scopeTag = r.scope === "overridden" ? "[local override]" : `[${r.scope}]`; const modelTag = r.primaryRoute ? `(${r.primaryRoute})` : ""; lines.push(` ${r.id.padEnd(16)} ${scopeTag.padEnd(16)} ${modelTag.padEnd(28)} ${r.description}`); } print(runtime.io, false, {}, `${lines.join("\n")}\n`); return 0; } export async function cmdRoleGet( runtime: Runtime, roleId: string, options: { scope?: "all" | "global" | "local" | undefined }, ): Promise<number> { const result = getRole(roleId, { scope: options.scope, repoRoot: runtime.cwd, userConfigDir: runtime.env.KXM_USER_CONFIG_DIR, }); if (!result) { runtime.io.stderr(`kxm: role '${roleId}' not found\n`); return 1; } print( runtime.io, runtime.json, { ok: true, command: "role get", roleId, scope: result.scope, filePath: result.filePath, role: result.role }, stringifyYaml(result.role), ); return 0; } export async function cmdRoleAdd( runtime: Runtime, roleId: string | undefined, options: { file?: string | undefined; description?: string | undefined; skills?: string | undefined; route?: string[] | undefined; scope?: "global" | "local" | undefined; overwrite?: boolean | undefined; pick?: string | boolean | undefined; }, ): Promise<number> { const scope = options.scope ?? "local"; let base: KxmRoleDefinition | undefined; if (!roleId || options.pick) { const candidates: PickCandidate[] = []; if (scope === "local") { const globalRoles = listRoles({ scope: "global", userConfigDir: runtime.env.KXM_USER_CONFIG_DIR }); for (const gr of globalRoles) { // The listing is a summary; the copy needs the definition, read from the listed file (it may be `.yml`). const definition = parseRoleFile(gr.filePath); if (definition && !candidates.some((c) => c.id === gr.id)) { candidates.push({ id: gr.id, description: gr.description, label: "global", payload: definition }); } } } const picked = await resolvePickItem(runtime.io, `Select a global role to add (${scope})`, candidates, options.pick, runtime.env); if (!picked) { if (!roleId) { runtime.io.stderr("role add failed: missing roleId or pick selection\n"); return 1; } } else { roleId = picked.id; base = picked.payload as KxmRoleDefinition; } } const skills = options.skills ? options.skills.split(",").map((s) => s.trim()).filter(Boolean) : undefined; const routes = (options.route ?? []).filter((route) => route.length > 0); const roster = routes.length > 0 ? routes.map((route) => ({ route })) : undefined; // A constructed or copied role takes --route, and the first id is the primary. // A missing model file is the same refusal as modify --add-route. --file is checked // after its roster is parsed, for both local and global scope. const projectRoot = discoverKxmProjectRoot(runtime.cwd) ?? runtime.cwd; const refuseMissingRoute = (routeId: string): boolean => { if (findYamlBasename(join(projectRoot, ".kxm", "models"), routeId, "route")) return false; runtime.io.stderr(`kxm: route '${routeId}' is not a file under .kxm/models/\n`); return true; }; if (roster && !options.file) { for (const entry of roster) { if (refuseMissingRoute(entry.route)) return 1; } } const purpose = rolePurposeForId(roleId ?? "experiment"); let roleDef: KxmRoleDefinition; if (options.file) { const filePath = resolve(runtime.cwd, options.file); const content = readFileSync(filePath, "utf8"); roleDef = parseYaml(content) as KxmRoleDefinition; roleDef.id = roleId!; roleDef.schema = "kxm.role.v2"; roleDef.purpose ??= rolePurposeForId(roleDef.id); roleDef.permission ??= roleDef.purpose === "writer" ? "edit" : "read-only"; for (const entry of roleDef.roster ?? []) { if (typeof entry?.route === "string" && entry.route.length > 0 && refuseMissingRoute(entry.route)) return 1; } } else if (base) { roleDef = { ...base, schema: "kxm.role.v2", description: options.description || base.description, skills: skills ?? base.skills, roster: roster ?? base.roster, }; } else { roleDef = { schema: "kxm.role.v2", id: roleId!, purpose, permission: purpose === "writer" ? "edit" : "read-only", description: options.description || `Role ${roleId}`, skills: skills ?? [], roster: roster ?? [], }; } // A local role file sits in the project the loader reads, and the loader refuses the // whole project over a writer roster it rejects, so the role is checked before it lands. let repoRoot = runtime.cwd; if (scope === "local") { const projectRoot = discoverKxmProjectRoot(runtime.cwd); if (!projectRoot) { print( runtime.io, runtime.json, { ok: false, command: "role add", error: "project_not_found" }, "role add failed: local roles belong to a KXM project; run kxm init at the repository root, or pass --scope global", ); return 2; } const issues = kxmRoleWriteIssues(projectRoot, roleDef.id, stringifyYaml(roleDef)); if (issues.length > 0) { print( runtime.io, runtime.json, { ok: false, command: "role add", error: "role_invalid", issues }, `role add failed: with this role the project would not load, so nothing was written\n${issues.map((entry) => ` ${entry.file}: ${entry.code}: ${entry.message}`).join("\n")}`, ); return 2; } repoRoot = projectRoot; } try { const res = addRole(roleDef, { scope, repoRoot, userConfigDir: runtime.env.KXM_USER_CONFIG_DIR, overwrite: options.overwrite, dryRun: runtime.dryRun, }); if (runtime.dryRun) { printPlan(runtime, { command: "role add", roleId, ...res }, [{ action: "write", target: res.filePath }], `add role '${roleId}' to ${res.scope}`); return 0; } print( runtime.io, runtime.json, { ok: true, command: "role add", roleId, ...res }, `Added role '${roleId}' to ${res.scope} (${res.filePath})\n`, ); return 0; } catch (err: unknown) { runtime.io.stderr(`role add failed: ${(err as Error).message}\n`); return 1; } } export async function cmdRoleRemove( runtime: Runtime, roleId: string | undefined, options: { scope?: "global" | "local" | undefined; pick?: string | boolean | undefined }, ): Promise<number> { const scope = options.scope ?? "local"; if (!roleId || options.pick) { const roles = listRoles({ scope, repoRoot: runtime.cwd, userConfigDir: runtime.env.KXM_USER_CONFIG_DIR, }); if (roles.length === 0) { runtime.io.stdout(`No roles configured in ${scope} scope to remove.\n`); return 0; } const candidates: PickCandidate[] = roles.map((r) => ({ id: r.id, description: r.description, label: r.scope, })); const picked = await resolvePickItem(runtime.io, `Select a role to remove (${scope})`, candidates, options.pick, runtime.env); if (!picked) { if (!roleId) { runtime.io.stderr("role remove failed: missing roleId or pick selection\n"); return 1; } } else { roleId = picked.id; } } try { const res = removeRole(roleId!, { scope, repoRoot: runtime.cwd, userConfigDir: runtime.env.KXM_USER_CONFIG_DIR, dryRun: runtime.dryRun, }); if (runtime.dryRun) { printPlan(runtime, { command: "role remove", roleId, ...res }, [{ action: "delete", target: res.filePath }], `remove role '${roleId}' from ${res.scope}`); return 0; } print( runtime.io, runtime.json, { ok: true, command: "role remove", roleId, ...res }, `Removed role '${roleId}' from ${res.scope}\n`, ); return 0; } catch (err: unknown) { runtime.io.stderr(`role remove failed: ${(err as Error).message}\n`); return 1; } } export async function cmdRoleModify( runtime: Runtime, roleId: string | undefined, options: { description?: string | undefined; addSkill?: string | undefined; removeSkill?: string | undefined; addRoute?: string | undefined; removeRoute?: string | undefined; scope?: "global" | "local" | undefined; pick?: string | boolean | undefined; }, ): Promise<number> { if (!roleId || options.pick) { const roles = listRoles({ scope: options.scope, repoRoot: runtime.cwd, userConfigDir: runtime.env.KXM_USER_CONFIG_DIR, }); if (roles.length === 0) { runtime.io.stdout("No roles configured to modify.\n"); return 0; } const candidates: PickCandidate[] = roles.map((r) => ({ id: r.id, description: r.description, label: r.scope, })); const picked = await resolvePickItem(runtime.io, "Select a role to modify", candidates, options.pick, runtime.env); if (!picked) { if (!roleId) { runtime.io.stderr("role modify failed: missing roleId or pick selection\n"); return 1; } } else { roleId = picked.id; } } const existing = getRole(roleId!, { scope: options.scope, repoRoot: runtime.cwd, userConfigDir: runtime.env.KXM_USER_CONFIG_DIR, }); if (!existing) { runtime.io.stderr(`kxm: role '${roleId}' not found\n`); return 1; } const role = existing.role; let skills = [...(role.skills ?? [])]; if (options.addSkill && !skills.includes(options.addSkill)) { skills.push(options.addSkill); } if (options.removeSkill) { skills = skills.filter((s) => s !== options.removeSkill); } let roster = [...(role.roster ?? [])]; if (options.addRoute) { const routeId = options.addRoute; const projectRoot = discoverKxmProjectRoot(runtime.cwd) ?? runtime.cwd; if (!findYamlBasename(join(projectRoot, ".kxm", "models"), routeId, "route")) { runtime.io.stderr(`kxm: route '${routeId}' is not a file under .kxm/models/\n`); return 1; } if (!roster.some((entry) => entry.route === routeId)) roster.push({ route: routeId }); } if (options.removeRoute) { roster = roster.filter((entry) => entry.route !== options.removeRoute); } const updates: Partial<KxmRoleDefinition> = { ...(options.description ? { description: options.description } : {}), skills, roster, }; try { const res = modifyRole(roleId!, updates, { scope: options.scope, repoRoot: runtime.cwd, userConfigDir: runtime.env.KXM_USER_CONFIG_DIR, dryRun: runtime.dryRun, }); if (runtime.dryRun) { printPlan(runtime, { command: "role modify", roleId, ...res }, [{ action: "write", target: res.filePath }], `modify role '${roleId}' in ${res.scope}`); return 0; } print( runtime.io, runtime.json, { ok: true, command: "role modify", roleId, ...res }, `Modified role '${roleId}' in ${res.scope}\n`, ); return 0; } catch (err: unknown) { runtime.io.stderr(`role modify failed: ${(err as Error).message}\n`); return 1; } } export async function cmdRoleResume( runtime: Runtime, runId: string, ruling?: string | undefined, ): Promise<number> { if (!runId) { runtime.io.stderr("kxm role resume requires <runId>\n"); return 1; } const effectiveRuling = ruling?.trim() || "operator_ruling: waived and resumed"; // A run this project's Runtime owns; a hub workflow run of the same id shape falls through. const projectRoot = discoverKxmProjectRoot(runtime.cwd); if (projectRoot && projectRuntimeOwnsRun(projectRoot, runId, runtime.env)) { if (runtime.dryRun) { printPlan( runtime, { command: "role resume", runId, ruling: effectiveRuling }, [{ action: "request", target: `POST kxm-runtime /v1/runs/${runId}/signal (audit_escalation unblock)` }], `resume KXM run ${runId}`, ); return 0; } try { const supervisor = await ensureKxmSupervisor({ env: runtime.env }); const posted = await kxmRuntimeRequest( supervisor, "POST", `/v1/runs/${encodeURIComponent(runId)}/signal?projectRoot=${encodeURIComponent(projectRoot)}`, { signalKey: "audit_escalation", status: "passed", summary: effectiveRuling, action: "unblock", }, ); print( runtime.io, runtime.json, { ok: true, command: "role resume", runId, ruling: effectiveRuling, unblocked: posted.unblocked === true }, `Resumed KXM run ${runId} with ruling: ${effectiveRuling}\n`, ); return 0; } catch (error) { const msg = error instanceof Error ? error.message : "resume_failed"; print(runtime.io, runtime.json, { ok: false, command: "role resume", error: "resume_failed", detail: msg }, `resume KXM run failed: ${msg}\n`); return 1; } } // Workflow run: check local state database (.kxm/state/kxm.db) const dbPath = join(runtime.cwd, ".kxm", "state", "kxm.db"); if (existsSync(dbPath)) { try { const database = runtime.dryRun ? openReadOnlyDatabase(dbPath) : new DatabaseSync(dbPath); try { const row = database.prepare("SELECT record FROM workflow_runs WHERE id = ?").get(runId) as { record: string } | undefined; if (!row) { runtime.io.stderr(`kxm: workflow run '${runId}' not found in ${dbPath}\n`); return 1; } const run = JSON.parse(row.record) as WorkflowRun; const now = new Date().toISOString(); const resumeResult = resumeWorkflowFromRuling(run, effectiveRuling, now); if (runtime.dryRun) { printPlan( runtime, { command: "role resume", runId, stageId: resumeResult.stageId, ruling: effectiveRuling, status: resumeResult.run.status }, [{ action: "write", target: `${dbPath} (workflow_runs ${runId}, one workflow_journal decision)` }], `resume workflow run ${runId} (stage: ${resumeResult.stageId})`, ); return 0; } database.prepare("UPDATE workflow_runs SET record = ? WHERE id = ?").run( JSON.stringify(resumeResult.run), runId, ); const journalId = randomUUID(); const journalPayload = { id: journalId, runId, stageId: resumeResult.stageId, category: "decision" as const, area: "workflow" as const, summary: `Role resume ruling: ${effectiveRuling}`, details: { ruling: effectiveRuling }, evidence: {}, createdAt: now, }; database.prepare( "INSERT INTO workflow_journal (id, run_id, category, area, record) VALUES (?, ?, ?, ?, ?)", ).run( journalId, runId, "decision", "workflow", JSON.stringify(journalPayload), ); print( runtime.io, runtime.json, { ok: true, command: "role resume", runId, stageId: resumeResult.stageId, ruling: effectiveRuling, status: resumeResult.run.status }, `Resumed workflow run ${runId} (stage: ${resumeResult.stageId}) with ruling: ${effectiveRuling}\n`, ); return 0; } finally { database.close(); } } catch (err: unknown) { runtime.io.stderr(`role resume failed: ${(err as Error).message}\n`); return 1; } } runtime.io.stderr(`kxm: no active run store or database found for run '${runId}'\n`); return 1; }