@nanobpm/nano-workforce 0.54.0 → 0.56.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,112 @@
1
+ // Unit tests for the SUPPLY cockpit DOM renderer (H5 / #148), on the in-memory fake DOM.
2
+ import assert from "node:assert/strict";
3
+ import { test } from "node:test";
4
+
5
+ import { FakeDocument, FakeElement } from "../../../test/agentic-cockpit-doubles.ts";
6
+ import { renderSupply } from "./supply-render.ts";
7
+ import { type SupplyReport, supplyView } from "./supply-view.ts";
8
+
9
+ const doc = new FakeDocument();
10
+
11
+ const sample: SupplyReport = {
12
+ count: 2,
13
+ workers: [
14
+ { instance: "wk-a", identity: "leaf-1", stream: "wk-a", family: "senior", host: "h1", jobKeys: ["job-1"], live: true, staleMs: 0 },
15
+ { instance: "wk-b", identity: "leaf-1", stream: "wk-b", family: "junior", host: "h2", jobKeys: [], live: false, staleMs: 0 },
16
+ ],
17
+ leaves: [
18
+ {
19
+ token: "leaf-1",
20
+ workers: [
21
+ { instance: "wk-a", identity: "leaf-1", stream: "wk-a", family: "senior", host: "h1", jobKeys: ["job-1"], live: true, staleMs: 0 },
22
+ { instance: "wk-b", identity: "leaf-1", stream: "wk-b", family: "junior", host: "h2", jobKeys: [], live: false, staleMs: 0 },
23
+ ],
24
+ },
25
+ ],
26
+ };
27
+
28
+ test("renders one leaf section with a worker row per worker (family, host, jobs, liveness)", () => {
29
+ const host = new FakeElement("body");
30
+ renderSupply(host, doc, supplyView(sample));
31
+
32
+ assert.equal(host.byData("leaf", "leaf-1").length, 1);
33
+ const rows = host.byClass("cockpit-supply-worker");
34
+ assert.equal(rows.length, 2);
35
+
36
+ const rowA = host.byData("worker", "wk-a")[0];
37
+ assert.equal(rowA?.getAttribute("data-liveness"), "live");
38
+ assert.equal(rowA?.byClass("cockpit-supply-family")[0]?.text(), "senior");
39
+ assert.equal(rowA?.byClass("cockpit-supply-host")[0]?.text(), "h1");
40
+ assert.equal(rowA?.byClass("cockpit-supply-jobs")[0]?.text(), "job-1");
41
+
42
+ const rowB = host.byData("worker", "wk-b")[0];
43
+ assert.equal(rowB?.getAttribute("data-liveness"), "down");
44
+ assert.equal(rowB?.byClass("cockpit-supply-jobs")[0]?.text(), "—");
45
+ });
46
+
47
+ test("worker buttons carry the drill stream and fire onDrill on click", () => {
48
+ const host = new FakeElement("body");
49
+ const drilled: string[] = [];
50
+ renderSupply(host, doc, supplyView(sample), { onDrill: (stream) => drilled.push(stream) });
51
+
52
+ const button = host.byClass("cockpit-worker").find((b) => b.getAttribute("data-stream") === "wk-a");
53
+ assert.ok(button, "the worker button was rendered with its stream id");
54
+ button?.dispatch("click");
55
+ assert.deepEqual(drilled, ["wk-a"]);
56
+ });
57
+
58
+ test("does NOT render any demand matrix, missing-agent reds, or diversity light", () => {
59
+ const host = new FakeElement("body");
60
+ renderSupply(host, doc, supplyView(sample));
61
+ // Those widgets belong to the packaged demand renderer / enrolment epic #152 — never here.
62
+ assert.equal(host.byClass("cockpit-matrix").length, 0);
63
+ assert.equal(host.byClass("cockpit-network").length, 0);
64
+ assert.equal(host.byClass("cockpit-missing").length, 0);
65
+ assert.equal(
66
+ host.byClass("cockpit-light").filter((l) => l.getAttribute("data-light-id") === "diversity").length,
67
+ 0,
68
+ );
69
+ });
70
+
71
+ test("renders an empty state when no workers are connected", () => {
72
+ const host = new FakeElement("body");
73
+ renderSupply(host, doc, supplyView({ count: 0, workers: [], leaves: [] }));
74
+ assert.equal(host.byData("empty", "true").length, 1);
75
+ assert.equal(host.byClass("cockpit-supply-worker").length, 0);
76
+ });
77
+
78
+ test("H6: renders a process/plan cell that drills into the job's stream", () => {
79
+ const host = new FakeElement("body");
80
+ const drilled: string[] = [];
81
+ const correlated: SupplyReport = {
82
+ count: 1,
83
+ workers: [{ instance: "wk-a", identity: "leaf-1", stream: "job:6494", family: "senior", host: "h1", jobKeys: ["6494"], live: true, staleMs: 0 }],
84
+ leaves: [
85
+ {
86
+ token: "leaf-1",
87
+ workers: [{ instance: "wk-a", identity: "leaf-1", stream: "job:6494", family: "senior", host: "h1", jobKeys: ["6494"], live: true, staleMs: 0 }],
88
+ },
89
+ ],
90
+ correlations: [{ jobKey: "6494", stream: "job:6494", bpmnProcessId: "plan-fanout", processInstanceKey: "4612", planKey: "o/r#142" }],
91
+ };
92
+ renderSupply(host, doc, supplyView(correlated), { onDrill: (stream) => drilled.push(stream) });
93
+
94
+ const cell = host.byData("worker", "wk-a")[0]?.byClass("cockpit-supply-process")[0];
95
+ assert.ok(cell);
96
+ assert.equal(cell?.getAttribute("data-correlations"), "1");
97
+ const link = host.byClass("cockpit-correlation")[0];
98
+ assert.ok(link, "the correlation drill button was rendered");
99
+ assert.equal(link?.getAttribute("data-job-key"), "6494");
100
+ assert.equal(link?.getAttribute("data-stream"), "job:6494");
101
+ assert.equal(link?.text(), "plan-fanout · inst 4612 · o/r#142");
102
+ link?.dispatch("click");
103
+ assert.deepEqual(drilled, ["job:6494"], "drilling the process/plan cell opens the live job's stream");
104
+ });
105
+
106
+ test("H6: a worker with no correlation renders an em-dash process cell", () => {
107
+ const host = new FakeElement("body");
108
+ renderSupply(host, doc, supplyView(sample));
109
+ const cell = host.byData("worker", "wk-b")[0]?.byClass("cockpit-supply-process")[0];
110
+ assert.equal(cell?.text(), "—");
111
+ assert.equal(cell?.getAttribute("data-correlations"), "0");
112
+ });
@@ -0,0 +1,165 @@
1
+ // The SUPPLY-only cockpit DOM renderer (ADR 0056, H5 / #148).
2
+ //
3
+ // Renders a {@link SupplyView} into a host element: the live worker list grouped by leaf token, each
4
+ // worker showing family, host, current jobs, and a liveness dot. Clicking a worker calls
5
+ // {@link RenderOptions.onDrill} with that worker's relay stream id, which the boot layer turns into a
6
+ // live terminal. This renders only the *volatile* part of the page (re-rendered each poll pass); the
7
+ // drill-in terminal is owned by {@link ../supply-boot.ts} in a persistent region so it survives a
8
+ // refresh.
9
+ //
10
+ // It draws a supply-only projection ON PURPOSE — NOT the packaged `renderCockpit`, which draws the
11
+ // demand×supply matrix, the missing-agent-type reds, and the diversity-SLO light. Those are deferred
12
+ // to enrolment epic #152 (see `./supply-view.ts`). The genuinely reusable, correctness-critical parts
13
+ // of the cockpit — the relay client and the resume-from-offset terminal session — ARE reused from
14
+ // `@nanobpm/agentic/cockpit` by the boot layer; only this supply projection, which the package does
15
+ // not provide, is authored here.
16
+ //
17
+ // Like the packaged renderer it builds against the structural {@link ElementLike} / {@link DocumentLike}
18
+ // subset (reused from `@nanobpm/agentic/cockpit`) rather than the global `document`, so the real DOM
19
+ // satisfies it at runtime AND a plain in-memory fake satisfies it for DOM-free Node tests (no `as`).
20
+ import type { DocumentLike, ElementLike } from "@nanobpm/agentic/cockpit";
21
+ import type { Liveness, SupplyLeafView, SupplyView, SupplyWorkerView } from "./supply-view.ts";
22
+
23
+ export interface RenderSupplyOptions {
24
+ /** Called with a worker's relay stream id when the operator drills into it. */
25
+ readonly onDrill?: (stream: string) => void;
26
+ }
27
+
28
+ /** Handles into the rendered tree the caller may need. */
29
+ export interface SupplyDom {
30
+ /** The freshly built root the view was rendered into. */
31
+ readonly root: ElementLike;
32
+ }
33
+
34
+ function el(doc: DocumentLike, tag: string, className?: string, text?: string): ElementLike {
35
+ const node = doc.createElement(tag);
36
+ if (className !== undefined) node.className = className;
37
+ if (text !== undefined) node.textContent = text;
38
+ return node;
39
+ }
40
+
41
+ function dot(doc: DocumentLike, liveness: Liveness): ElementLike {
42
+ const node = el(doc, "span", "cockpit-dot");
43
+ node.setAttribute("data-liveness", liveness);
44
+ return node;
45
+ }
46
+
47
+ function workerRow(doc: DocumentLike, worker: SupplyWorkerView, options: RenderSupplyOptions): ElementLike {
48
+ const row = el(doc, "tr", "cockpit-supply-worker");
49
+ row.setAttribute("data-worker", worker.instance);
50
+ row.setAttribute("data-liveness", worker.liveness);
51
+ row.setAttribute("data-stream", worker.stream);
52
+
53
+ const nameCell = el(doc, "td", "cockpit-td cockpit-supply-name");
54
+ nameCell.appendChild(dot(doc, worker.liveness));
55
+ const button = el(doc, "button", "cockpit-worker", worker.instance);
56
+ button.setAttribute("type", "button");
57
+ button.setAttribute("data-stream", worker.stream);
58
+ const onDrill = options.onDrill;
59
+ if (onDrill !== undefined) {
60
+ button.addEventListener("click", () => onDrill(worker.stream));
61
+ }
62
+ nameCell.appendChild(button);
63
+ row.appendChild(nameCell);
64
+
65
+ row.appendChild(el(doc, "td", "cockpit-td cockpit-supply-family", worker.family));
66
+ row.appendChild(el(doc, "td", "cockpit-td cockpit-supply-host", worker.host));
67
+
68
+ const jobsCell = el(doc, "td", "cockpit-td cockpit-supply-jobs", worker.jobs === 0 ? "—" : worker.jobKeys.join(", "));
69
+ jobsCell.setAttribute("data-jobs", String(worker.jobs));
70
+ row.appendChild(jobsCell);
71
+
72
+ // The process instance / plan each current job belongs to (H6). Each correlation is a drill button
73
+ // onto its jobKey-scoped relay stream, so the operator opens the LIVE job's terminal — not just the
74
+ // worker's default stream. Empty → "—" so the cell always renders something stable.
75
+ const processCell = el(doc, "td", "cockpit-td cockpit-supply-process");
76
+ processCell.setAttribute("data-correlations", String(worker.correlations.length));
77
+ if (worker.correlations.length === 0) {
78
+ processCell.textContent = "—";
79
+ } else {
80
+ for (const correlation of worker.correlations) {
81
+ const link = el(doc, "button", "cockpit-correlation", correlation.label);
82
+ link.setAttribute("type", "button");
83
+ link.setAttribute("data-job-key", correlation.jobKey);
84
+ link.setAttribute("data-stream", correlation.stream);
85
+ if (onDrill !== undefined) {
86
+ link.addEventListener("click", () => onDrill(correlation.stream));
87
+ }
88
+ processCell.appendChild(link);
89
+ }
90
+ }
91
+ row.appendChild(processCell);
92
+
93
+ const livenessCell = el(doc, "td", "cockpit-td cockpit-supply-liveness", worker.liveness);
94
+ livenessCell.setAttribute("data-liveness", worker.liveness);
95
+ row.appendChild(livenessCell);
96
+
97
+ return row;
98
+ }
99
+
100
+ function leafSection(doc: DocumentLike, leaf: SupplyLeafView, options: RenderSupplyOptions): ElementLike {
101
+ const section = el(doc, "section", "cockpit-leaf");
102
+ section.setAttribute("data-leaf", leaf.token);
103
+
104
+ const header = el(doc, "div", "cockpit-leaf-head");
105
+ header.appendChild(el(doc, "span", "cockpit-leaf-name", leaf.token));
106
+ header.appendChild(el(doc, "span", "cockpit-leaf-count", `${leaf.liveCount}/${leaf.total} live`));
107
+ section.appendChild(header);
108
+
109
+ const table = el(doc, "table", "cockpit-supply-table");
110
+ const thead = el(doc, "thead", "cockpit-supply-thead");
111
+ const head = el(doc, "tr", "cockpit-supply-head");
112
+ for (const label of ["worker", "family", "host", "jobs", "process / plan", "liveness"]) {
113
+ head.appendChild(el(doc, "th", "cockpit-th", label));
114
+ }
115
+ thead.appendChild(head);
116
+ table.appendChild(thead);
117
+
118
+ const tbody = el(doc, "tbody", "cockpit-supply-tbody");
119
+ for (const worker of leaf.workers) {
120
+ tbody.appendChild(workerRow(doc, worker, options));
121
+ }
122
+ table.appendChild(tbody);
123
+ section.appendChild(table);
124
+ return section;
125
+ }
126
+
127
+ /**
128
+ * Render `view` into `host`, replacing whatever was there. Idempotent: call it again on every refresh
129
+ * to reflect the latest supply snapshot.
130
+ */
131
+ export function renderSupply(
132
+ host: ElementLike,
133
+ doc: DocumentLike,
134
+ view: SupplyView,
135
+ options: RenderSupplyOptions = {},
136
+ ): SupplyDom {
137
+ host.replaceChildren();
138
+ const root = el(doc, "div", "cockpit-supply");
139
+ root.setAttribute("data-worker-count", String(view.count));
140
+ root.setAttribute("data-live-count", String(view.live));
141
+
142
+ const header = el(doc, "header", "cockpit-header");
143
+ header.appendChild(el(doc, "h1", "cockpit-title", "Workers — supply"));
144
+ const summary = el(doc, "span", "cockpit-supply-summary", `${view.live}/${view.count} live`);
145
+ summary.setAttribute("data-summary", "supply");
146
+ header.appendChild(summary);
147
+ root.appendChild(header);
148
+
149
+ if (view.count === 0) {
150
+ const empty = el(doc, "div", "cockpit-supply-empty", "No workers connected.");
151
+ empty.setAttribute("data-empty", "true");
152
+ root.appendChild(empty);
153
+ host.appendChild(root);
154
+ return { root };
155
+ }
156
+
157
+ const list = el(doc, "div", "cockpit-supply-list");
158
+ for (const leaf of view.leaves) {
159
+ list.appendChild(leafSection(doc, leaf, options));
160
+ }
161
+ root.appendChild(list);
162
+
163
+ host.appendChild(root);
164
+ return { root };
165
+ }
@@ -0,0 +1,116 @@
1
+ // Unit tests for the SUPPLY cockpit view-model projection (H5 / #148).
2
+ import assert from "node:assert/strict";
3
+ import { test } from "node:test";
4
+
5
+ import { type SupplyReport, supplyView } from "./supply-view.ts";
6
+
7
+ function report(over: Partial<SupplyReport> = {}): SupplyReport {
8
+ const workers = over.workers ?? [];
9
+ return {
10
+ workers,
11
+ leaves: over.leaves ?? [],
12
+ count: over.count ?? workers.length,
13
+ generatedAt: over.generatedAt,
14
+ correlations: over.correlations,
15
+ };
16
+ }
17
+
18
+ test("grades liveness: down when disconnected, stale past the threshold, else live", () => {
19
+ const view = supplyView(
20
+ report({
21
+ workers: [
22
+ { instance: "a", identity: "t", stream: "a", jobKeys: [], live: false, staleMs: 0 },
23
+ { instance: "b", identity: "t", stream: "b", jobKeys: [], live: true, staleMs: 20_000 },
24
+ { instance: "c", identity: "t", stream: "c", jobKeys: [], live: true, staleMs: 100 },
25
+ ],
26
+ }),
27
+ { staleAfterMs: 15_000 },
28
+ );
29
+ assert.equal(view.workers.find((w) => w.instance === "a")?.liveness, "down");
30
+ assert.equal(view.workers.find((w) => w.instance === "b")?.liveness, "stale");
31
+ assert.equal(view.workers.find((w) => w.instance === "c")?.liveness, "live");
32
+ assert.equal(view.count, 3);
33
+ assert.equal(view.live, 1);
34
+ });
35
+
36
+ test("defaults absent family/host to a stable dash and counts + sorts jobKeys", () => {
37
+ const view = supplyView(
38
+ report({
39
+ workers: [{ instance: "a", identity: "t", stream: "a", jobKeys: ["z", "a"], live: true, staleMs: 0 }],
40
+ }),
41
+ );
42
+ const w = view.workers[0];
43
+ assert.equal(w?.family, "—");
44
+ assert.equal(w?.host, "—");
45
+ assert.deepEqual(w?.jobKeys, ["a", "z"]);
46
+ assert.equal(w?.jobs, 2);
47
+ });
48
+
49
+ test("sorts leaves by token and workers by instance, with per-leaf live counts", () => {
50
+ const view = supplyView(
51
+ report({
52
+ leaves: [
53
+ {
54
+ token: "leaf-b",
55
+ workers: [
56
+ { instance: "b2", identity: "leaf-b", stream: "b2", jobKeys: [], live: true, staleMs: 0 },
57
+ { instance: "b1", identity: "leaf-b", stream: "b1", jobKeys: [], live: false, staleMs: 0 },
58
+ ],
59
+ },
60
+ {
61
+ token: "leaf-a",
62
+ workers: [{ instance: "a1", identity: "leaf-a", stream: "a1", jobKeys: [], live: true, staleMs: 0 }],
63
+ },
64
+ ],
65
+ }),
66
+ );
67
+ assert.deepEqual(
68
+ view.leaves.map((l) => l.token),
69
+ ["leaf-a", "leaf-b"],
70
+ );
71
+ assert.deepEqual(
72
+ view.leaves[1]?.workers.map((w) => w.instance),
73
+ ["b1", "b2"],
74
+ );
75
+ assert.equal(view.leaves[1]?.liveCount, 1);
76
+ assert.equal(view.leaves[1]?.total, 2);
77
+ });
78
+
79
+ test("H6: resolves each worker's jobKeys into correlation views with a human label", () => {
80
+ const view = supplyView(
81
+ report({
82
+ workers: [{ instance: "wk-a", identity: "leaf-a", stream: "job:6494", jobKeys: ["6494"], live: true, staleMs: 0 }],
83
+ correlations: [
84
+ { jobKey: "6494", stream: "job:6494", processInstanceKey: "4612", bpmnProcessId: "plan-fanout", elementId: "implement-task", planKey: "o/r#142" },
85
+ ],
86
+ }),
87
+ );
88
+ const w = view.workers[0];
89
+ assert.equal(w?.correlations.length, 1);
90
+ const c = w?.correlations[0];
91
+ assert.equal(c?.jobKey, "6494");
92
+ assert.equal(c?.stream, "job:6494");
93
+ assert.equal(c?.processInstanceKey, "4612");
94
+ assert.equal(c?.planKey, "o/r#142");
95
+ assert.equal(c?.label, "plan-fanout · implement-task · inst 4612 · o/r#142");
96
+ });
97
+
98
+ test("H6: a worker with no matching correlation renders an empty correlation list", () => {
99
+ const view = supplyView(
100
+ report({
101
+ workers: [{ instance: "wk-a", identity: "leaf-a", stream: "wk-a", jobKeys: ["nope"], live: true, staleMs: 0 }],
102
+ correlations: [{ jobKey: "other", stream: "job:other" }],
103
+ }),
104
+ );
105
+ assert.deepEqual(view.workers[0]?.correlations, []);
106
+ });
107
+
108
+ test("H6: a correlation with no engine context falls back to a job-key label", () => {
109
+ const view = supplyView(
110
+ report({
111
+ workers: [{ instance: "wk-a", identity: "leaf-a", stream: "job:6494", jobKeys: ["6494"], live: true, staleMs: 0 }],
112
+ correlations: [{ jobKey: "6494", stream: "job:6494" }],
113
+ }),
114
+ );
115
+ assert.equal(view.workers[0]?.correlations[0]?.label, "job 6494");
116
+ });
@@ -0,0 +1,243 @@
1
+ // The SUPPLY-only cockpit view-model (ADR 0056, H5 / #148).
2
+ //
3
+ // A pure, deterministic projection of the app's SUPPLY report — the live worker registry (H1 #144)
4
+ // carried over the agentic channel — onto the shape the supply cockpit renders: a per-leaf-token
5
+ // list of connected workers with family, host, current jobs, and liveness.
6
+ //
7
+ // This is DELIBERATELY the supply half only. The DEMAND×supply matrix, the missing-agent-type reds,
8
+ // and the diversity-SLO lights (the packaged `@nanobpm/agentic/cockpit` view/render draws those from
9
+ // a `DemandSupplyReport`) are OUT OF SCOPE for this epic (#142) — they depend on the vocab /
10
+ // capability→SERVE / diversity-SLO machinery deferred to the paired enrolment epic #152. So this
11
+ // module models a supply-only report and never fabricates demand data.
12
+ //
13
+ // Like the packaged `cockpit/view.ts` it is framework-free and side-effect-free: the same report
14
+ // always yields the same {@link SupplyView}, so it is safe to snapshot in a test and to render
15
+ // identically whether the page is embedded in the console (App View, ADR 0057) or served standalone.
16
+
17
+ /** A worker's coarse liveness grade, rendered as a coloured dot. */
18
+ export type Liveness = "live" | "stale" | "down";
19
+
20
+ /** One connected worker as the app's supply feed reports it (mirrors the H1 registry snapshot row). */
21
+ export interface SupplyWorkerReport {
22
+ /** The worker instance id. */
23
+ readonly instance: string;
24
+ /** The authenticated ADR 0028 principal — the leaf token this worker registered under. */
25
+ readonly identity: string;
26
+ /**
27
+ * The relay stream to drill into for this worker's live terminal. The supply endpoint defaults it
28
+ * to the worker instance; the correlation slice (H6 #149) may repoint it at a jobKey-keyed stream.
29
+ */
30
+ readonly stream: string;
31
+ /** Declared family (enrolment attribute), if any. */
32
+ readonly family?: string;
33
+ /** Declared host (where the worker runs), if any. */
34
+ readonly host?: string;
35
+ /** The jobKeys the worker is currently processing (empty until H6 wires the resolver). */
36
+ readonly jobKeys: readonly string[];
37
+ /** Whether the worker's channel connection is still open. */
38
+ readonly live: boolean;
39
+ /** How long since the worker's last liveness refresh, in ms. */
40
+ readonly staleMs: number;
41
+ }
42
+
43
+ /** The supply registered under one leaf token. */
44
+ export interface SupplyLeafReport {
45
+ readonly token: string;
46
+ readonly workers: readonly SupplyWorkerReport[];
47
+ }
48
+
49
+ /**
50
+ * One current job's engine context (H6 #149) — the process instance / plan a worker's terminal is
51
+ * lined up against. Mirrors the supply endpoint's `AgenticJobCorrelation`.
52
+ */
53
+ export interface SupplyCorrelationReport {
54
+ /** The Camunda-8 job key. */
55
+ readonly jobKey: string;
56
+ /** The relay stream the job's terminal is on (`job:<jobKey>`). */
57
+ readonly stream: string;
58
+ /** The owning process instance key, if known. */
59
+ readonly processInstanceKey?: string;
60
+ /** The BPMN process id, if known. */
61
+ readonly bpmnProcessId?: string;
62
+ /** The BPMN element id, if known. */
63
+ readonly elementId?: string;
64
+ /** The plan / epic key, if known. */
65
+ readonly planKey?: string;
66
+ }
67
+
68
+ /** The supply-only report the cockpit polls (no demand fields — those are enrolment epic #152). */
69
+ export interface SupplyReport {
70
+ /** Supply grouped by leaf token. */
71
+ readonly leaves: readonly SupplyLeafReport[];
72
+ /** Every connected worker, flat. */
73
+ readonly workers: readonly SupplyWorkerReport[];
74
+ /** The number of connected workers. */
75
+ readonly count: number;
76
+ /** When the snapshot was taken, ISO-8601 (optional). */
77
+ readonly generatedAt?: string;
78
+ /** The engine context for every currently-processing job (H6), keyed by jobKey. */
79
+ readonly correlations?: readonly SupplyCorrelationReport[];
80
+ }
81
+
82
+ /** One current job's context as the cockpit renders it in a worker row (H6). */
83
+ export interface JobCorrelationView {
84
+ /** The Camunda-8 job key. */
85
+ readonly jobKey: string;
86
+ /** The relay stream the job's terminal is on. */
87
+ readonly stream: string;
88
+ /** A single human label for the process instance / plan, e.g. `plan-fanout · inst 4612 · owner/repo#142`. */
89
+ readonly label: string;
90
+ /** The owning process instance key, if known. */
91
+ readonly processInstanceKey?: string;
92
+ /** The BPMN process id, if known. */
93
+ readonly bpmnProcessId?: string;
94
+ /** The BPMN element id, if known. */
95
+ readonly elementId?: string;
96
+ /** The plan / epic key, if known. */
97
+ readonly planKey?: string;
98
+ }
99
+
100
+ /** One worker row in the renderable supply view. */
101
+ export interface SupplyWorkerView {
102
+ readonly instance: string;
103
+ readonly identity: string;
104
+ /** The relay stream to open when the operator drills into this worker. */
105
+ readonly stream: string;
106
+ /** Declared family, or `"—"` when absent (so the cell always renders something stable). */
107
+ readonly family: string;
108
+ /** Declared host, or `"—"` when absent. */
109
+ readonly host: string;
110
+ /** The worker's current jobKeys, sorted. */
111
+ readonly jobKeys: readonly string[];
112
+ /** The number of current jobs. */
113
+ readonly jobs: number;
114
+ /**
115
+ * The engine context for each of this worker's current jobs (H6), sorted by jobKey — so the operator
116
+ * sees which process instance / plan the terminal belongs to. Empty when nothing correlates.
117
+ */
118
+ readonly correlations: readonly JobCorrelationView[];
119
+ /** The coarse liveness grade for the status dot. */
120
+ readonly liveness: Liveness;
121
+ /** How long since the last liveness refresh, in ms. */
122
+ readonly staleMs: number;
123
+ }
124
+
125
+ /** One leaf-token section in the renderable supply view. */
126
+ export interface SupplyLeafView {
127
+ readonly token: string;
128
+ readonly workers: readonly SupplyWorkerView[];
129
+ /** Workers under this leaf currently graded `live`. */
130
+ readonly liveCount: number;
131
+ /** Total workers under this leaf. */
132
+ readonly total: number;
133
+ }
134
+
135
+ /** The full renderable supply view. */
136
+ export interface SupplyView {
137
+ /** Supply grouped by leaf token, sorted by token. */
138
+ readonly leaves: readonly SupplyLeafView[];
139
+ /** Every worker, flat, sorted by instance. */
140
+ readonly workers: readonly SupplyWorkerView[];
141
+ /** The number of workers. */
142
+ readonly count: number;
143
+ /** The number of workers graded `live`. */
144
+ readonly live: number;
145
+ }
146
+
147
+ /** Options for {@link supplyView}. */
148
+ export interface SupplyViewOptions {
149
+ /**
150
+ * A live worker whose last refresh is older than this (ms) is graded `stale` rather than `live`.
151
+ * A disconnected worker is always `down`. Default 15000.
152
+ */
153
+ readonly staleAfterMs?: number;
154
+ }
155
+
156
+ const DEFAULT_STALE_AFTER_MS = 15_000;
157
+
158
+ function liveness(worker: SupplyWorkerReport, staleAfterMs: number): Liveness {
159
+ if (!worker.live) return "down";
160
+ return worker.staleMs >= staleAfterMs ? "stale" : "live";
161
+ }
162
+
163
+ /** A single stable human label for a job's process instance / plan (empty parts are dropped). */
164
+ function correlationLabel(c: SupplyCorrelationReport): string {
165
+ const parts: string[] = [];
166
+ if (c.bpmnProcessId !== undefined) parts.push(c.bpmnProcessId);
167
+ if (c.elementId !== undefined) parts.push(c.elementId);
168
+ if (c.processInstanceKey !== undefined) parts.push(`inst ${c.processInstanceKey}`);
169
+ if (c.planKey !== undefined) parts.push(c.planKey);
170
+ return parts.length > 0 ? parts.join(" · ") : `job ${c.jobKey}`;
171
+ }
172
+
173
+ function correlationView(c: SupplyCorrelationReport): JobCorrelationView {
174
+ return {
175
+ jobKey: c.jobKey,
176
+ stream: c.stream,
177
+ label: correlationLabel(c),
178
+ ...(c.processInstanceKey !== undefined ? { processInstanceKey: c.processInstanceKey } : {}),
179
+ ...(c.bpmnProcessId !== undefined ? { bpmnProcessId: c.bpmnProcessId } : {}),
180
+ ...(c.elementId !== undefined ? { elementId: c.elementId } : {}),
181
+ ...(c.planKey !== undefined ? { planKey: c.planKey } : {}),
182
+ };
183
+ }
184
+
185
+ function workerView(
186
+ worker: SupplyWorkerReport,
187
+ staleAfterMs: number,
188
+ byJobKey: ReadonlyMap<string, SupplyCorrelationReport>,
189
+ ): SupplyWorkerView {
190
+ const jobKeys = [...worker.jobKeys].sort((a, b) => a.localeCompare(b));
191
+ const correlations = jobKeys
192
+ .map((jobKey) => byJobKey.get(jobKey))
193
+ .filter((c): c is SupplyCorrelationReport => c !== undefined)
194
+ .map(correlationView);
195
+ return {
196
+ instance: worker.instance,
197
+ identity: worker.identity,
198
+ stream: worker.stream,
199
+ family: worker.family ?? "—",
200
+ host: worker.host ?? "—",
201
+ jobKeys,
202
+ jobs: jobKeys.length,
203
+ correlations,
204
+ liveness: liveness(worker, staleAfterMs),
205
+ staleMs: worker.staleMs,
206
+ };
207
+ }
208
+
209
+ const byInstance = (a: SupplyWorkerView, b: SupplyWorkerView) => a.instance.localeCompare(b.instance);
210
+
211
+ /**
212
+ * Derive the renderable supply view from the app's supply-only report.
213
+ *
214
+ * Pure and total: it re-sorts leaves by token and workers by instance so the derived view is stable
215
+ * and diff-friendly regardless of the report's incoming order; no input mutates and no I/O happens.
216
+ */
217
+ export function supplyView(report: SupplyReport, options: SupplyViewOptions = {}): SupplyView {
218
+ const staleAfterMs = options.staleAfterMs ?? DEFAULT_STALE_AFTER_MS;
219
+
220
+ const byJobKey = new Map<string, SupplyCorrelationReport>();
221
+ for (const c of report.correlations ?? []) byJobKey.set(c.jobKey, c);
222
+
223
+ const leaves: SupplyLeafView[] = report.leaves
224
+ .map((leaf) => {
225
+ const workers = leaf.workers.map((w) => workerView(w, staleAfterMs, byJobKey)).sort(byInstance);
226
+ return {
227
+ token: leaf.token,
228
+ workers,
229
+ liveCount: workers.filter((w) => w.liveness === "live").length,
230
+ total: workers.length,
231
+ };
232
+ })
233
+ .sort((a, b) => a.token.localeCompare(b.token));
234
+
235
+ const workers = report.workers.map((w) => workerView(w, staleAfterMs, byJobKey)).sort(byInstance);
236
+
237
+ return {
238
+ leaves,
239
+ workers,
240
+ count: workers.length,
241
+ live: workers.filter((w) => w.liveness === "live").length,
242
+ };
243
+ }