// The SUPPLY-only cockpit view-model (ADR 0056, H5 / #148). // // A pure, deterministic projection of the app's SUPPLY report — the live worker registry (H1 #144) // carried over the agentic channel — onto the shape the supply cockpit renders: a per-leaf-token // list of connected workers with family, host, current jobs, and liveness. // // This is DELIBERATELY the supply half only. The DEMAND×supply matrix, the missing-agent-type reds, // and the diversity-SLO lights (the packaged `@nanobpm/agentic/cockpit` view/render draws those from // a `DemandSupplyReport`) are OUT OF SCOPE for this epic (#142) — they depend on the vocab / // capability→SERVE / diversity-SLO machinery deferred to the paired enrolment epic #152. So this // module models a supply-only report and never fabricates demand data. // // Like the packaged `cockpit/view.ts` it is framework-free and side-effect-free: the same report // always yields the same {@link SupplyView}, so it is safe to snapshot in a test and to render // identically whether the page is embedded in the console (App View, ADR 0057) or served standalone. /** A worker's coarse liveness grade, rendered as a coloured dot. */ export type Liveness = "live" | "stale" | "down"; /** One connected worker as the app's supply feed reports it (mirrors the H1 registry snapshot row). */ export interface SupplyWorkerReport { /** The worker instance id. */ readonly instance: string; /** The authenticated ADR 0028 principal — the leaf token this worker registered under. */ readonly identity: string; /** * The relay stream to drill into for this worker's live terminal. The supply endpoint defaults it * to the worker instance; the correlation slice (H6 #149) may repoint it at a jobKey-keyed stream. */ readonly stream: string; /** Declared family (enrolment attribute), if any. */ readonly family?: string; /** Declared host (where the worker runs), if any. */ readonly host?: string; /** The jobKeys the worker is currently processing (empty until H6 wires the resolver). */ readonly jobKeys: readonly string[]; /** Whether the worker's channel connection is still open. */ readonly live: boolean; /** How long since the worker's last liveness refresh, in ms. */ readonly staleMs: number; /** * Whether the worker's HARNESS is stale — its advertised harness protocol is below the configured * minimum, or it advertised none at all (issue #802). Distinct from {@link Liveness} `"stale"`, * which grades heartbeat recency. A stale harness silently swallows machine-readable artifacts * (AgentInstance / transcript / result envelope), so its jobs dead-end at human escalations. */ readonly harnessStale?: boolean; /** The harness protocol version the worker advertised at enrolment, if any (issue #802). */ readonly harnessProtocol?: number; } export interface SupplyLeafReport { readonly token: string; readonly workers: readonly SupplyWorkerReport[]; } /** * One current job's engine context (H6 #149) — the process instance / plan a worker's terminal is * lined up against. Mirrors the supply endpoint's `AgenticJobCorrelation`. */ export interface SupplyCorrelationReport { /** The Camunda-8 job key. */ readonly jobKey: string; /** The relay stream the job's terminal is on (`composeStreamId(instance, jobKey)`, issue #738). */ readonly stream: string; /** The owning process instance key, if known. */ readonly processInstanceKey?: string; /** The BPMN process id, if known. */ readonly bpmnProcessId?: string; /** The BPMN element id, if known. */ readonly elementId?: string; /** The plan / epic key, if known. */ readonly planKey?: string; } /** The supply-only report the cockpit polls (no demand fields — those are enrolment epic #152). */ export interface SupplyReport { /** Supply grouped by leaf token. */ readonly leaves: readonly SupplyLeafReport[]; /** Every connected worker, flat. */ readonly workers: readonly SupplyWorkerReport[]; /** The number of connected workers. */ readonly count: number; /** When the snapshot was taken, ISO-8601 (optional). */ readonly generatedAt?: string; /** The engine context for every currently-processing job (H6), keyed by jobKey. */ readonly correlations?: readonly SupplyCorrelationReport[]; } /** One current job's context as the cockpit renders it in a worker row (H6). */ export interface JobCorrelationView { /** The Camunda-8 job key. */ readonly jobKey: string; /** The relay stream the job's terminal is on. */ readonly stream: string; /** A single human label for the process instance / plan, e.g. `plan-fanout · inst 4612 · owner/repo#142`. */ readonly label: string; /** The owning process instance key, if known. */ readonly processInstanceKey?: string; /** The BPMN process id, if known. */ readonly bpmnProcessId?: string; /** The BPMN element id, if known. */ readonly elementId?: string; /** The plan / epic key, if known. */ readonly planKey?: string; } /** One worker row in the renderable supply view. */ export interface SupplyWorkerView { readonly instance: string; readonly identity: string; /** The relay stream to open when the operator drills into this worker. */ readonly stream: string; /** Declared family, or `"—"` when absent (so the cell always renders something stable). */ readonly family: string; /** Declared host, or `"—"` when absent. */ readonly host: string; /** The worker's current jobKeys, sorted. */ readonly jobKeys: readonly string[]; /** The number of current jobs. */ readonly jobs: number; /** * Whether this worker has a LIVE terminal to drill into. True only while it holds a current job: * a worker relays its terminal on the instance-scoped `composeStreamId(instance, jobKey)` stream * (issue #738), and the supply endpoint repoints {@link stream} at it. An IDLE worker (no jobs) has its `stream` default back to the bare * instance id — a stream NO producer ever writes to — so drilling it opens a permanently blank * "live" terminal. The renderer suppresses the drill affordance when this is false. */ readonly drillable: boolean; /** * The engine context for each of this worker's current jobs (H6), sorted by jobKey — so the operator * sees which process instance / plan the terminal belongs to. Empty when nothing correlates. */ readonly correlations: readonly JobCorrelationView[]; /** The coarse liveness grade for the status dot. */ readonly liveness: Liveness; /** How long since the last liveness refresh, in ms. */ readonly staleMs: number; /** Whether the worker's harness protocol is stale (below minimum / absent) — issue #802. */ readonly harnessStale: boolean; /** The advertised harness protocol version, if any (issue #802). */ readonly harnessProtocol?: number; } /** One leaf-token section in the renderable supply view. */ export interface SupplyLeafView { readonly token: string; readonly workers: readonly SupplyWorkerView[]; /** Workers under this leaf currently graded `live`. */ readonly liveCount: number; /** Total workers under this leaf. */ readonly total: number; } /** The full renderable supply view. */ export interface SupplyView { /** Supply grouped by leaf token, sorted by token. */ readonly leaves: readonly SupplyLeafView[]; /** Every worker, flat, sorted by instance. */ readonly workers: readonly SupplyWorkerView[]; /** The number of workers. */ readonly count: number; /** The number of workers graded `live`. */ readonly live: number; } /** Options for {@link supplyView}. */ export interface SupplyViewOptions { /** * A live worker whose last refresh is older than this (ms) is graded `stale` rather than `live`. * A disconnected worker is always `down`. Default 15000. */ readonly staleAfterMs?: number; } const DEFAULT_STALE_AFTER_MS = 15_000; function liveness(worker: SupplyWorkerReport, staleAfterMs: number): Liveness { if (!worker.live) return "down"; return worker.staleMs >= staleAfterMs ? "stale" : "live"; } /** A single stable human label for a job's process instance / plan (empty parts are dropped). */ function correlationLabel(c: SupplyCorrelationReport): string { const parts: string[] = []; if (c.bpmnProcessId !== undefined) parts.push(c.bpmnProcessId); if (c.elementId !== undefined) parts.push(c.elementId); if (c.processInstanceKey !== undefined) parts.push(`inst ${c.processInstanceKey}`); if (c.planKey !== undefined) parts.push(c.planKey); return parts.length > 0 ? parts.join(" · ") : `job ${c.jobKey}`; } function correlationView(c: SupplyCorrelationReport): JobCorrelationView { return { jobKey: c.jobKey, stream: c.stream, label: correlationLabel(c), ...(c.processInstanceKey !== undefined ? { processInstanceKey: c.processInstanceKey } : {}), ...(c.bpmnProcessId !== undefined ? { bpmnProcessId: c.bpmnProcessId } : {}), ...(c.elementId !== undefined ? { elementId: c.elementId } : {}), ...(c.planKey !== undefined ? { planKey: c.planKey } : {}), }; } function workerView( worker: SupplyWorkerReport, staleAfterMs: number, byJobKey: ReadonlyMap, ): SupplyWorkerView { const jobKeys = [...worker.jobKeys].sort((a, b) => a.localeCompare(b)); const correlations = jobKeys .map((jobKey) => byJobKey.get(jobKey)) .filter((c): c is SupplyCorrelationReport => c !== undefined) .map(correlationView); return { instance: worker.instance, identity: worker.identity, stream: worker.stream, family: worker.family ?? "—", host: worker.host ?? "—", jobKeys, jobs: jobKeys.length, drillable: jobKeys.length > 0, correlations, liveness: liveness(worker, staleAfterMs), staleMs: worker.staleMs, // Fail loud: a report without a harness verdict (an older/cached response) has no trustworthy // protocol assessment, so keep the worker visible as STALE until the server supplies a healthy one // — mirrors the server's `harness?.stale ?? true` (issue #802). Defaulting to `false` here would // let a cached response hide exactly the workers this surface is meant to expose. harnessStale: worker.harnessStale ?? true, ...(worker.harnessProtocol !== undefined ? { harnessProtocol: worker.harnessProtocol } : {}), }; } const byInstance = (a: SupplyWorkerView, b: SupplyWorkerView) => a.instance.localeCompare(b.instance); /** * Derive the renderable supply view from the app's supply-only report. * * Pure and total: it re-sorts leaves by token and workers by instance so the derived view is stable * and diff-friendly regardless of the report's incoming order; no input mutates and no I/O happens. */ export function supplyView(report: SupplyReport, options: SupplyViewOptions = {}): SupplyView { const staleAfterMs = options.staleAfterMs ?? DEFAULT_STALE_AFTER_MS; const byJobKey = new Map(); for (const c of report.correlations ?? []) byJobKey.set(c.jobKey, c); const leaves: SupplyLeafView[] = report.leaves .map((leaf) => { const workers = leaf.workers.map((w) => workerView(w, staleAfterMs, byJobKey)).sort(byInstance); return { token: leaf.token, workers, liveCount: workers.filter((w) => w.liveness === "live").length, total: workers.length, }; }) .sort((a, b) => a.token.localeCompare(b.token)); const workers = report.workers.map((w) => workerView(w, staleAfterMs, byJobKey)).sort(byInstance); return { leaves, workers, count: workers.length, live: workers.filter((w) => w.liveness === "live").length, }; }