UNPKG

@kontextmind/kxm

Version:

KXM local-first multi-agent orchestration and operator dashboard

379 lines (353 loc) • 16.7 kB
import { CloudTokenError, hubBindingScope, type HubBindingScope } from "./hub-binding.ts"; export const TENANT_STATUS_SCHEMA = "kxm.tenant-status.v1" as const; /** * One upstream of the tenant view, with its own reachability. A source that could not be * read is reported as `unavailable` with a stable reason — never silently filled from the * other source, because the two sources answer different questions: the hub holds * *metadata* (agent roster, message queue, its own workflow runs), the Runtime holds the * *authoritative* run state on the box that owns the run. Blurring them is how a portal * ends up rendering one as the other. */ export type TenantSourceState = "ok" | "unavailable"; export interface TenantSource<T> { state: TenantSourceState; /** When this source was read (or attempted), so a consumer can age the data itself. */ observedAt: string; /** Stable machine code (`hub_unreachable`, `hub_timeout`, `runtime_supervisor_not_running`, …). */ reason?: string; value?: T; } /** Hub-side roster entry: presence and liveness, per the hub's own observation. */ export interface TenantHubAgent { id: string; name: string; online: boolean; } export interface TenantHubStage { id: string; status: string; attempts?: number | undefined; } /** * A run as the hub records it. The hub mints its own run ids at workflow start; this is a * hub workflow run — metadata about work, not the Runtime's state for it. */ export interface TenantHubRun { id: string; status: string; definitionId: string; currentStage?: string | undefined; targetAgentName?: string | undefined; updatedAt?: string | undefined; progress?: { done: number; total: number }; stages?: TenantHubStage[]; source: "hub-projection"; } export interface TenantHubPlan { id: string; runId: string; summary: string; createdAt: string; stageId?: string | undefined; severity?: string | undefined; } export interface TenantHubValue { project: string; fetchedAt: string; agents: TenantHubAgent[]; openMessageTotal: number; runTotal: number; runs: TenantHubRun[]; plans: TenantHubPlan[]; } /** A run as the Runtime that owns it records it. This is the authoritative state. */ export interface TenantRuntimeRun { runId: string; status: string; homeRuntimeId: string; workflowId?: string | undefined; createdAt?: string | undefined; updatedAt?: string | undefined; /** Set when the stored row exists but the event-log fold refused; `status` is then the * cached row, and a consumer must not present it as folded state. */ projectionError?: string | undefined; /** `runtime-cached` exactly when `projectionError` is set: the row is the cache, not the * folded state, and neither the label nor the cross-check may treat it as authoritative. */ source: "runtime-authoritative" | "runtime-cached"; } export interface TenantRuntimeValue { runs: TenantRuntimeRun[]; } export interface TenantRunDiscrepancy { runId: string; hubStatus: string; runtimeStatus: string; } /** * Cross-check between the two run populations. Hub runs and Runtime runs mint their ids * independently (`newId("run")` on each side), so an id appearing in both means a hub run * that this box drove — and only then is a status comparison meaningful. With no overlap * the comparison is **unverified, not agreed**: "no matching ids" says the populations did * not intersect, nothing more, and reporting agreement there would fabricate a green light * out of an empty set. */ export interface TenantRunComparison { state: "compared" | "unverified" | "unavailable"; reason?: string; matched?: number; /** Shared ids whose Runtime row failed to fold: present in both stores, but the * authoritative side could not be read, so those runs are unverified rather than agreed. */ unverifiedFoldRuns?: number; discrepancies?: TenantRunDiscrepancy[]; } export interface TenantStatusPayload { schema: typeof TENANT_STATUS_SCHEMA; project: string; generatedAt: string; hubUrl: string; /** Reuses the `kxm hub bind` classification: `loopback` and `remote` ship different rules. */ bindingScope: "loopback" | "remote"; hub: TenantSource<TenantHubValue>; runtime: TenantSource<TenantRuntimeValue>; runComparison: TenantRunComparison; /** True when any source is unavailable. Assembling a payload is not the same as seeing. */ degraded: boolean; } export interface TenantStatusRuntimeReader { listRuns(): Promise<TenantRuntimeRun[]>; } const DEFAULT_HUB_TIMEOUT_MS = 5_000; function hubSourceReason(status: number): string { if (status === 401 || status === 403) return "hub_unauthorized"; if (status === 404) return "hub_not_found"; return `hub_http_${status}`; } function asString(value: unknown): string | undefined { return typeof value === "string" && value.length > 0 ? value : undefined; } function isObject(value: unknown): value is Record<string, unknown> { return typeof value === "object" && value !== null && !Array.isArray(value); } /** * Compose the tenant view from the two authorities that actually hold the data. * * Both sources are read **concurrently and independently**: a hub that hangs to its * deadline cannot delay or suppress the Runtime half of the view, and a Runtime that is * not running cannot take the hub metadata down with it. The hub is read over HTTP * (`/v1/ops/snapshot`, admin credential); the Runtime is read through an injected reader * so this function stays pure and testable — the CLI passes a reader that attaches to the * live supervisor, and a test passes whatever it likes. */ export async function assembleTenantStatus(input: { project: string; hubUrl: string; /** Resolves the admin credential. May throw on a malformed persisted record; that * failure belongs to the hub source alone and never aborts the Runtime read. */ resolveAdminToken: () => string | undefined; /** When set, this is the scope already classified for `hubUrl` (cloud forwards included). */ bindingScope?: HubBindingScope | undefined; fetchImpl: typeof fetch; runtime: TenantStatusRuntimeReader; hubTimeoutMs?: number | undefined; now?: () => Date; }): Promise<TenantStatusPayload> { const now = input.now ?? ((): Date => new Date()); const base = input.hubUrl.replace(/\/$/, ""); const at = (): string => now().toISOString(); const readHub = async (): Promise<TenantSource<TenantHubValue>> => { const attemptedAt = at(); let token: string | undefined; try { token = input.resolveAdminToken(); } catch (error) { // A malformed hub-env record is a configuration failure with a specific repair. // It belongs to this source; the Runtime read proceeds regardless. // A cloud binding that cannot produce a token is not that failure: the local // hub-env token was not consulted. if (error instanceof CloudTokenError) { return { state: "unavailable", observedAt: attemptedAt, reason: error.code }; } void error; return { state: "unavailable", observedAt: attemptedAt, reason: "hub_credential_unreadable" }; } try { const response = await input.fetchImpl(`${base}/v1/ops/snapshot?project=${encodeURIComponent(input.project)}`, { headers: token ? { authorization: `Bearer ${token}` } : {}, signal: AbortSignal.timeout(input.hubTimeoutMs ?? DEFAULT_HUB_TIMEOUT_MS), }); if (!response.ok) { return { state: "unavailable", observedAt: attemptedAt, reason: hubSourceReason(response.status) }; } let body: unknown; try { body = await response.json(); } catch (error) { // A deadline can expire mid-body; that is a timeout, not malformed content, and // classifying it as `hub_response_invalid` would hide the one fact that matters. const name = error instanceof Error ? error.name : undefined; if (name === "TimeoutError" || name === "AbortError") { return { state: "unavailable", observedAt: attemptedAt, reason: "hub_timeout" }; } // Reachable but not a JSON document: a different failure than a dead socket. return { state: "unavailable", observedAt: attemptedAt, reason: "hub_response_invalid" }; } if (!isObject(body) || typeof body.project !== "string" || typeof body.fetchedAt !== "string" || !Array.isArray(body.agents) || !Array.isArray(body.runs) || !Array.isArray(body.plans ?? []) || typeof body.openMessageTotal !== "number" || typeof body.runTotal !== "number") { // A 200 with an unusable body must not become a healthy empty snapshot. return { state: "unavailable", observedAt: attemptedAt, reason: "hub_response_invalid" }; } if (body.project !== input.project) { return { state: "unavailable", observedAt: attemptedAt, reason: "hub_project_mismatch" }; } const runRows = body.runs.filter(isObject); const value: TenantHubValue = { project: body.project, fetchedAt: body.fetchedAt, agents: body.agents.filter(isObject).map((agent) => ({ id: asString(agent.id) ?? "", name: asString(agent.name) ?? "", online: agent.online === true, })), openMessageTotal: body.openMessageTotal, runTotal: body.runTotal, runs: runRows.map((run) => { const stages = Array.isArray(run.stages) ? run.stages.filter(isObject).map((stage) => ({ id: asString(stage.id) ?? "", status: asString(stage.status) ?? "unknown", ...(typeof stage.attempts === "number" ? { attempts: stage.attempts } : {}), })) : undefined; const done = stages?.filter((stage) => stage.status === "passed" || stage.status === "failed" || stage.status === "warning").length; return { id: asString(run.id) ?? "", status: asString(run.status) ?? "unknown", definitionId: asString(run.definitionId) ?? "", ...(asString(run.currentStage) ? { currentStage: asString(run.currentStage) } : {}), ...(asString(run.targetAgentName) ? { targetAgentName: asString(run.targetAgentName) } : {}), ...(asString(run.updatedAt) ? { updatedAt: asString(run.updatedAt) } : {}), ...(stages !== undefined ? { progress: { done: done ?? 0, total: stages.length }, stages } : {}), source: "hub-projection" as const, }; }), plans: (Array.isArray(body.plans) ? body.plans : []).filter(isObject).map((plan) => ({ id: asString(plan.id) ?? "", runId: asString(plan.runId) ?? "", summary: asString(plan.summary) ?? "", createdAt: asString(plan.createdAt) ?? "", ...(asString(plan.stageId) ? { stageId: asString(plan.stageId) } : {}), ...(asString(plan.severity) ? { severity: asString(plan.severity) } : {}), })), }; return { state: "ok", observedAt: attemptedAt, value }; } catch (error) { const name = error instanceof Error ? error.name : undefined; if (name === "TimeoutError" || name === "AbortError") { return { state: "unavailable", observedAt: attemptedAt, reason: "hub_timeout" }; } return { state: "unavailable", observedAt: attemptedAt, reason: "hub_unreachable" }; } }; const readRuntime = async (): Promise<TenantSource<TenantRuntimeValue>> => { const attemptedAt = at(); try { const runs = await input.runtime.listRuns(); return { state: "ok", observedAt: attemptedAt, value: { runs } }; } catch (error) { const code = error instanceof Error && "issues" in error && Array.isArray((error as { issues: Array<{ code?: unknown }> }).issues) && typeof (error as { issues: Array<{ code?: unknown }> }).issues[0]?.code === "string" ? (error as { issues: Array<{ code: string }> }).issues[0]!.code : undefined; return { state: "unavailable", observedAt: attemptedAt, reason: code ?? "runtime_unavailable" }; } }; const [hub, runtime] = await Promise.all([readHub(), readRuntime()]); let runComparison: TenantRunComparison; if (!hub.value || !runtime.value) { runComparison = { state: "unavailable", reason: !hub.value ? `hub_${hub.reason ?? "unavailable"}` : `runtime_${runtime.reason ?? "unavailable"}`, }; } else { // Only cleanly folded rows are authoritative. A row whose fold failed is the cache, and // counting it as agreement is precisely the lie this comparison exists to prevent: a // corrupt event log under a hub run's id would otherwise print "agree". const authoritative = new Map( runtime.value.runs .filter((run) => run.projectionError === undefined) .map((run) => [run.runId, run.status] as const), ); const hubIds = new Set(hub.value.runs.map((run) => run.id)); const foldFailed = runtime.value.runs.filter((run) => run.projectionError !== undefined && hubIds.has(run.runId)); const discrepancies: TenantRunDiscrepancy[] = []; let matched = 0; for (const projected of hub.value.runs) { const runtimeStatus = authoritative.get(projected.id); if (runtimeStatus === undefined) continue; matched += 1; if (runtimeStatus !== projected.status) { discrepancies.push({ runId: projected.id, hubStatus: projected.status, runtimeStatus }); } } if (matched > 0) { runComparison = { state: "compared", matched, ...(foldFailed.length > 0 ? { unverifiedFoldRuns: foldFailed.length } : {}), ...(discrepancies.length > 0 ? { discrepancies } : {}), }; } else { runComparison = foldFailed.length > 0 ? { state: "unverified", reason: "runtime_fold_failed", matched: 0, unverifiedFoldRuns: foldFailed.length } : { state: "unverified", reason: "run_identity_link_absent", matched: 0 }; } } return { schema: TENANT_STATUS_SCHEMA, project: input.project, generatedAt: at(), hubUrl: input.hubUrl, bindingScope: input.bindingScope ?? hubBindingScope(input.hubUrl), hub, runtime, runComparison, degraded: hub.state !== "ok" || runtime.state !== "ok", }; } /** One line per source for humans; the portal reads the JSON, operators read this. */ export function formatTenantStatus(payload: TenantStatusPayload): string { const hubLine = payload.hub.state === "ok" && payload.hub.value ? `hub ${payload.hub.value.agents.filter((agent) => agent.online).length}/${payload.hub.value.agents.length} agents online, ${payload.hub.value.runs.length} hub runs, ${payload.hub.value.openMessageTotal} open messages` : `hub unavailable (${payload.hub.reason ?? "unknown"})`; const runtimeLine = payload.runtime.state === "ok" && payload.runtime.value ? (() => { const cached = payload.runtime.value!.runs.filter((run) => run.projectionError !== undefined).length; const authoritative = payload.runtime.value!.runs.length - cached; // A fold-failed row is the cache; printing it under "authoritative" would put the // lie back in exactly the place an operator reads first. return `runtime ${authoritative} runs (authoritative, on this box)${cached > 0 ? `, ${cached} cached (fold failed, not state)` : ""}`; })() : `runtime unavailable (${payload.runtime.reason ?? "unknown"})`; const comparisonLine = payload.runComparison.state === "compared" ? payload.runComparison.discrepancies && payload.runComparison.discrepancies.length > 0 ? `cross-check: ${payload.runComparison.discrepancies.length} of ${payload.runComparison.matched} matched run(s) disagree${payload.runComparison.unverifiedFoldRuns ? ` (${payload.runComparison.unverifiedFoldRuns} unverified: fold failed)` : ""}` : `cross-check: ${payload.runComparison.matched} matched run(s) agree${payload.runComparison.unverifiedFoldRuns ? ` (${payload.runComparison.unverifiedFoldRuns} unverified: fold failed)` : ""}` : payload.runComparison.state === "unverified" ? payload.runComparison.reason === "runtime_fold_failed" ? `cross-check: unavailable — ${payload.runComparison.unverifiedFoldRuns ?? 0} shared run(s) could not be verified (runtime fold failed)` : "cross-check: unavailable — no run id appears in both sources (independent id spaces)" : `cross-check: unavailable (${payload.runComparison.reason ?? "unknown"})`; return [ `tenant ${payload.project} @ ${payload.hubUrl} (${payload.bindingScope})`, hubLine, runtimeLine, comparisonLine, ].join("\n"); }