@kontextmind/kxm
Version:
KXM local-first multi-agent orchestration and operator dashboard
185 lines (171 loc) • 6.82 kB
text/typescript
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { nowIso } from "./protocol.ts";
import { agentWorker, gateWorker, type Worker } from "./envelope.ts";
export const SESSION_SCHEMA = "kxm.session.v1" as const;
/**
* Roster/mix configuration is invalid: unknown worker names, malformed roster
* files, duplicate names, or kind/driver mismatches. Thrown fail-closed;
* callers (the CLI) are expected to map this to a usage exit, never to
* silently degrade into a default worker.
*/
export class SessionConfigError extends Error {
readonly code = "session_config_invalid" as const;
constructor(message: string) {
super(message);
this.name = "SessionConfigError";
}
}
export interface SessionRecord {
schema: typeof SESSION_SCHEMA;
id: string;
host: "local" | "hub";
mode: "mix" | "workflow";
workflowId?: string;
workers: Worker[];
createdAt: string;
assetDir: string;
}
export function workflowAssetDirs(assetsDir: string, workflowId: string): string[] {
const root = join(assetsDir, "workflows", workflowId);
return [root, join(root, "inputs"), join(root, "outputs"), join(root, "generated")];
}
export function sessionAssetDirs(assetsDir: string, sessionId: string): string[] {
const root = join(assetsDir, "sessions", sessionId);
return [root, join(root, "inputs"), join(root, "outputs")];
}
export function standardAssetDirs(assetsDir: string): string[] {
return [
join(assetsDir, "retrospectives"),
join(assetsDir, "workflows"),
join(assetsDir, "sessions"),
join(assetsDir, "improvements"),
join(assetsDir, "generated"),
];
}
export function rosterNames(configDir: string): string[] {
return [...loadRosterMap(configDir).values()].map((row) => String(row.name));
}
export function loadNamedWorkers(configDir: string, names: string[], project?: string): Worker[] {
const byName = loadRosterMap(configDir);
return names.map((name) => {
const trimmed = name.trim();
const row = byName.get(trimmed.toLowerCase());
if (!row) {
// Fail closed: an unknown mix name is a configuration error, not a
// reason to mint a code gate with that name.
throw new SessionConfigError(
`unknown worker name in session roster: ${trimmed} (not present in agents.json or gates.json)`,
);
}
return workerFromRosterRow(trimmed, row, project);
});
}
function workerFromRosterRow(name: string, row: Record<string, unknown>, project?: string): Worker {
const kind = row.kind;
const driver = row.driver;
if (kind !== undefined && kind !== "agent" && kind !== "gate") {
throw new SessionConfigError(`roster entry ${name} has invalid kind: ${JSON.stringify(kind)}`);
}
if (driver !== undefined && driver !== "ai" && driver !== "code") {
throw new SessionConfigError(`roster entry ${name} has invalid driver: ${JSON.stringify(driver)}`);
}
if ((kind === "agent" && driver === "code") || (kind === "gate" && driver === "ai")) {
throw new SessionConfigError(
`roster entry ${name} has kind/driver mismatch: kind=${String(kind)} driver=${String(driver)}`,
);
}
if (kind === "gate" || driver === "code") {
return gateWorker({
name,
...(project ? { project } : {}),
...(typeof row.purpose === "string" ? { purpose: row.purpose } : {}),
});
}
return agentWorker({
name,
...(project ? { project } : {}),
...(typeof row.purpose === "string" ? { purpose: row.purpose } : {}),
...(typeof row.model === "string" ? { model: row.model } : {}),
...(typeof row.thinking === "string" ? { thinking: row.thinking } : {}),
});
}
function loadRosterMap(configDir: string): Map<string, Record<string, unknown>> {
const byName = new Map<string, Record<string, unknown>>();
for (const [file, key] of [["agents.json", "agents"], ["gates.json", "gates"]] as const) {
const path = join(configDir, file);
for (const row of loadRoster(path, key)) {
const name = String(row.name).trim();
const lowered = name.toLowerCase();
if (byName.has(lowered)) {
throw new SessionConfigError(`duplicate worker name ${name} across roster files (${file} and an earlier roster)`);
}
// Validate every configured row, not only names selected by one mix. A
// partial selection must not make a malformed sibling roster entry safe.
void workerFromRosterRow(name, row);
byName.set(lowered, row);
}
}
return byName;
}
function loadRoster(path: string, key: "agents" | "gates"): Array<Record<string, unknown>> {
// A missing roster file means "no workers of this kind configured"; a
// present-but-malformed file is a configuration error and must fail closed.
if (!existsSync(path)) return [];
let parsed: unknown;
try {
parsed = JSON.parse(readFileSync(path, "utf8"));
} catch (error) {
throw new SessionConfigError(
`${key} roster at ${path} is not valid JSON: ${error instanceof Error ? error.message : String(error)}`,
);
}
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
throw new SessionConfigError(`${key} roster at ${path} must be a JSON object`);
}
const list = (parsed as Record<string, unknown>)[key];
if (!Array.isArray(list)) {
throw new SessionConfigError(`${key} roster at ${path} must contain a "${key}" array`);
}
return list.map((row, index) => {
if (!row || typeof row !== "object" || Array.isArray(row)) {
throw new SessionConfigError(`${key} roster entry ${index} at ${path} must be an object`);
}
const record = row as Record<string, unknown>;
if (typeof record.name !== "string" || !record.name.trim()) {
throw new SessionConfigError(`${key} roster entry ${index} at ${path} must have a non-empty name`);
}
return record;
});
}
export function createSession(input: {
id: string;
host: "local" | "hub";
mode: "mix" | "workflow";
workflowId?: string;
workers: Worker[];
assetsDir: string;
}): SessionRecord {
const assetDir = input.mode === "workflow" && input.workflowId
? join("assets", "workflows", input.workflowId)
: join("assets", "sessions", input.id);
return {
schema: SESSION_SCHEMA,
id: input.id,
host: input.host,
mode: input.mode,
workers: input.workers,
createdAt: nowIso(),
assetDir,
...(input.workflowId ? { workflowId: input.workflowId } : {}),
};
}
export function writeSession(assetsDir: string, session: SessionRecord, dryRun = false): string {
const dir = join(assetsDir, "sessions", session.id);
const path = join(dir, "session.json");
if (!dryRun) {
mkdirSync(dir, { recursive: true });
writeFileSync(path, `${JSON.stringify(session, null, 2)}\n`, { encoding: "utf8" });
}
return path;
}