UNPKG

@kontextmind/kxm

Version:

KXM local-first multi-agent orchestration and operator dashboard

275 lines (259 loc) • 10.7 kB
/** * Surface registry: who owns which part of a KXM terminal surface. * * A panel is published, not centralised. Each owner (project, roster, routes, * gates, roles, workflow, config) contributes its own sections and performs its * own writes; the registry only routes requests and re-reads what changed. That * keeps one rule true everywhere in KXM: the panel never becomes a second * source of truth for configuration, and never writes a file by itself. * * There is no local echo. `invoke()` calls the owner and then re-reads its * sections, so a rejected write comes back as the old value plus the owner's * reason on `statusText`. A value the panel shows is a value that is on disk. * * Owners are addressed by name. A handler for an unregistered source, an * unknown section, or an unknown action is refused rather than guessed, and a * handler that throws never aborts the panel: the failure becomes a notice. */ import { KXM_TUI_LIMITS, assertKxmTuiSurface, validateKxmTuiSurface, type KxmTuiInvocation, type KxmTuiIssue, type KxmTuiSectionView, } from "../types/surface.ts"; export interface KxmTuiActionInput { readonly sectionId: string; readonly fieldId: string; readonly action: string; readonly value?: string; /** Owner-scoped state the handler needs to resolve the current surface. */ readonly state?: unknown; } export interface KxmTuiContribution<TState = unknown> { /** Addressable owner id, e.g. `kxm/roster`. It is audited, never display-only. */ readonly source: string; /** * Read at publish time, not cached: an owner may have just written a file, * refreshed a catalog, or lost authentication, and the panel must show that. */ readonly listSections: (state?: TState) => readonly KxmTuiSectionView[]; readonly handlers: Readonly<Record<string, (input: KxmTuiActionInput) => void | Promise<void>>>; /** Turn an owner failure into a legible line. Defaults to `error.message`. */ readonly describeError?: (error: unknown, action: string) => string; /** * Whether a write must be re-read through `listSections` after the handler * resolves. Always true here; declared so an owner cannot promise otherwise. */ readonly republishAfterWrite?: true; } export interface KxmTuiRegistryEvent { readonly reason: "publish" | "invoke" | "refused" | "failed"; readonly source?: string; readonly message?: string; } export interface KxmTuiInvokeResult { readonly ok: boolean; /** Why the request was refused or failed, already fit for one line. */ readonly message?: string; /** Contract violations that made the owner's published surface unusable. */ readonly issues?: readonly KxmTuiIssue[]; } export interface KxmTuiRegistry { /** Register an owner. Re-registering the same source replaces it. */ register(contribution: KxmTuiContribution): void; unregister(source: string): void; /** The merged surface, ordered by `order` then source, safe to render. */ getSections(): readonly KxmTuiSectionView[]; /** Sections as published, including any that failed validation. */ getIssues(): readonly KxmTuiIssue[]; invoke(invocation: KxmTuiInvocation): Promise<KxmTuiInvokeResult>; subscribe(listener: (event: KxmTuiRegistryEvent) => void): () => void; /** Monotonic token identifying the current session of this registry. */ getGeneration(): number; /** A source is only present when it registered; used to gate tabs. */ hasSource(source: string): boolean; listSources(): string[]; dispose(): void; } function describe(error: unknown): string { if (error instanceof Error && error.message) return error.message.split(/\r?\n/u)[0]!.slice(0, KXM_TUI_LIMITS.detailMax); return String(error).split(/\r?\n/u)[0]!.slice(0, KXM_TUI_LIMITS.detailMax); } function normalizeSource(source: string): string { if (!/^[A-Za-z0-9@][A-Za-z0-9@/._:-]*$/u.test(source) || source.length > KXM_TUI_LIMITS.sourceMax) { throw new Error(`invalid kxm tui surface source: ${source.slice(0, 64)}`); } return source; } /** * Create a registry. * * `onInternalError` receives faults that cannot be shown as a field notice (a * malformed published surface, a listener that threw). It must never rethrow * into the panel: a broken owner dims a section, it does not crash the UI. */ export function createKxmTuiRegistry(options: { onInternalError?: (error: unknown, context: string) => void; } = {}): KxmTuiRegistry { const contributions = new Map<string, KxmTuiContribution>(); const notices = new Map<string, string>(); const listeners = new Set<(event: KxmTuiRegistryEvent) => void>(); /** Per-owner cache so one repaint does not re-read every file on disk. */ const published = new Map<string, readonly KxmTuiSectionView[]>(); const issues = new Map<string, readonly KxmTuiIssue[]>(); let generation = 0; let disposed = false; let epoch = 0; const report = (error: unknown, context: string): void => { if (options.onInternalError) options.onInternalError(error, context); }; const emit = (event: KxmTuiRegistryEvent): void => { for (const listener of Array.from(listeners)) { try { listener(event); } catch (error) { report(error, "listener"); } } }; function readOwner(source: string, force: boolean): readonly KxmTuiSectionView[] { if (!force) { const cached = published.get(source); if (cached) return cached; } const contribution = contributions.get(source); if (!contribution) { published.set(source, []); return []; } let sections: readonly KxmTuiSectionView[]; try { const raw = contribution.listSections(); const ownerIssues = validateKxmTuiSurface(raw); if (ownerIssues.length > 0) { issues.set(source, ownerIssues); // Keep the surface drawable anyway: the panel is where someone repairs a // bad file, so a broken section still has to render with its notice. sections = raw .filter((section): section is KxmTuiSectionView => Boolean(section) && typeof section === "object") .map((section) => ({ ...section, notice: section.notice ?? `published surface was refused: ${ownerIssues[0]!.code} at ${ownerIssues[0]!.path}`, noticeLevel: "error" as const, })); } else { issues.delete(source); sections = raw; } } catch (error) { issues.set(source, [{ path: source, code: "list_sections_failed", message: describe(error) }]); report(error, `listSections:${source}`); sections = [{ source, id: "error", title: source, order: 900, notice: describe(error), noticeLevel: "error", fields: [] }]; } const pending = notices.get(source); const withNotice = pending !== undefined && sections.length > 0 ? sections.map((section, index) => (index === 0 ? { ...section, notice: pending, noticeLevel: "error" as const } : section)) : sections; published.set(source, withNotice); return withNotice; } function merged(): readonly KxmTuiSectionView[] { const all: KxmTuiSectionView[] = []; for (const source of contributions.keys()) all.push(...readOwner(source, false)); return all.slice().sort((a, b) => a.order - b.order || a.source.localeCompare(b.source) || a.title.localeCompare(b.title)); } return { register(contribution) { if (disposed) throw new Error("kxm tui registry is disposed"); const source = normalizeSource(contribution.source); notices.delete(source); published.delete(source); issues.delete(source); contributions.set(source, { ...contribution, source }); emit({ reason: "publish", source }); }, unregister(source) { contributions.delete(source); published.delete(source); issues.delete(source); notices.delete(source); emit({ reason: "publish", source }); }, getSections() { return merged(); }, getIssues() { return [...issues.values()].flat(); }, async invoke(invocation) { if (disposed) return { ok: false, message: "kxm tui registry is disposed" }; const contribution = contributions.get(invocation.source); if (!contribution) { emit({ reason: "refused", source: invocation.source, message: "unknown owner" }); return { ok: false, message: `${invocation.source} does not own a surface here` }; } const handler = contribution.handlers[invocation.action]; if (!handler) { const message = `${invocation.source} does not accept "${invocation.action}"`; emit({ reason: "refused", source: invocation.source, message }); return { ok: false, message }; } const currentEpoch = epoch; try { await handler({ sectionId: invocation.sectionId, fieldId: invocation.fieldId, action: invocation.action, ...(invocation.value === undefined ? {} : { value: invocation.value }), }); } catch (error) { const failure = contribution.describeError?.(error, invocation.action) ?? describe(error); notices.set(invocation.source, failure); emit({ reason: "failed", source: invocation.source, message: failure }); // Republish so the panel shows the on-disk value plus the reason. readOwner(invocation.source, true); generation += 1; return { ok: false, message: failure }; } if (currentEpoch !== epoch) { // A stale reply after a reset must not resurrect an old surface. readOwner(invocation.source, true); return { ok: false, message: "stale write result was discarded" }; } notices.delete(invocation.source); readOwner(invocation.source, true); generation += 1; emit({ reason: "publish", source: invocation.source }); return { ok: true }; }, subscribe(listener) { listeners.add(listener); return () => listeners.delete(listener); }, getGeneration() { return generation; }, hasSource(source) { return contributions.has(source); }, listSources() { return [...contributions.keys()]; }, dispose() { if (disposed) return; disposed = true; epoch += 1; listeners.clear(); contributions.clear(); published.clear(); issues.clear(); notices.clear(); }, }; } /** Validate a contributed surface up front, for owners that publish constants. */ export function checkedKxmTuiSections(sections: readonly KxmTuiSectionView[]): KxmTuiSectionView[] { return assertKxmTuiSurface(sections); }