@nanobpm/nano-workforce 0.53.0 → 0.55.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.
- package/CHANGELOG.md +14 -0
- package/app/agentic/cockpit/index.ts +30 -0
- package/app/agentic/cockpit/supply-boot.test.ts +279 -0
- package/app/agentic/cockpit/supply-boot.ts +282 -0
- package/app/agentic/cockpit/supply-render.test.ts +76 -0
- package/app/agentic/cockpit/supply-render.ts +144 -0
- package/app/agentic/cockpit/supply-view.test.ts +76 -0
- package/app/agentic/cockpit/supply-view.ts +165 -0
- package/app/agentic/families/blackboard.family.test.ts +189 -0
- package/app/agentic/families/blackboard.family.ts +69 -0
- package/app/blackboard.schema.test.ts +21 -0
- package/app/blackboard.test.ts +37 -69
- package/app/blackboard.ts +101 -124
- package/app/retro.test.ts +3 -1
- package/db/migrations/025_agentic_blackboard.sql +39 -0
- package/openapi.yaml +94 -0
- package/operations/blackboard.test.ts +11 -27
- package/operations/getAgenticSupply.test.ts +153 -0
- package/operations/getAgenticSupply.ts +59 -0
- package/package.json +1 -1
- package/pages/cockpit/cockpit.css +145 -0
- package/pages/cockpit/embed.html +42 -0
- package/pages/cockpit/mount.js +331 -0
- package/pages/cockpit/standalone.html +46 -0
- package/pages/cockpit.page.json +46 -0
- package/pages/epic-detail.page.json +2 -1
- package/pages/epic.page.json +2 -1
- package/pages/home.page.json +4 -0
- package/test/agentic-cockpit-doubles.ts +136 -0
- package/test/blackboardDb.ts +108 -0
- package/workers/retro-gather/worker.test.ts +2 -1
|
@@ -0,0 +1,144 @@
|
|
|
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
|
+
const livenessCell = el(doc, "td", "cockpit-td cockpit-supply-liveness", worker.liveness);
|
|
73
|
+
livenessCell.setAttribute("data-liveness", worker.liveness);
|
|
74
|
+
row.appendChild(livenessCell);
|
|
75
|
+
|
|
76
|
+
return row;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function leafSection(doc: DocumentLike, leaf: SupplyLeafView, options: RenderSupplyOptions): ElementLike {
|
|
80
|
+
const section = el(doc, "section", "cockpit-leaf");
|
|
81
|
+
section.setAttribute("data-leaf", leaf.token);
|
|
82
|
+
|
|
83
|
+
const header = el(doc, "div", "cockpit-leaf-head");
|
|
84
|
+
header.appendChild(el(doc, "span", "cockpit-leaf-name", leaf.token));
|
|
85
|
+
header.appendChild(el(doc, "span", "cockpit-leaf-count", `${leaf.liveCount}/${leaf.total} live`));
|
|
86
|
+
section.appendChild(header);
|
|
87
|
+
|
|
88
|
+
const table = el(doc, "table", "cockpit-supply-table");
|
|
89
|
+
const thead = el(doc, "thead", "cockpit-supply-thead");
|
|
90
|
+
const head = el(doc, "tr", "cockpit-supply-head");
|
|
91
|
+
for (const label of ["worker", "family", "host", "jobs", "liveness"]) {
|
|
92
|
+
head.appendChild(el(doc, "th", "cockpit-th", label));
|
|
93
|
+
}
|
|
94
|
+
thead.appendChild(head);
|
|
95
|
+
table.appendChild(thead);
|
|
96
|
+
|
|
97
|
+
const tbody = el(doc, "tbody", "cockpit-supply-tbody");
|
|
98
|
+
for (const worker of leaf.workers) {
|
|
99
|
+
tbody.appendChild(workerRow(doc, worker, options));
|
|
100
|
+
}
|
|
101
|
+
table.appendChild(tbody);
|
|
102
|
+
section.appendChild(table);
|
|
103
|
+
return section;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Render `view` into `host`, replacing whatever was there. Idempotent: call it again on every refresh
|
|
108
|
+
* to reflect the latest supply snapshot.
|
|
109
|
+
*/
|
|
110
|
+
export function renderSupply(
|
|
111
|
+
host: ElementLike,
|
|
112
|
+
doc: DocumentLike,
|
|
113
|
+
view: SupplyView,
|
|
114
|
+
options: RenderSupplyOptions = {},
|
|
115
|
+
): SupplyDom {
|
|
116
|
+
host.replaceChildren();
|
|
117
|
+
const root = el(doc, "div", "cockpit-supply");
|
|
118
|
+
root.setAttribute("data-worker-count", String(view.count));
|
|
119
|
+
root.setAttribute("data-live-count", String(view.live));
|
|
120
|
+
|
|
121
|
+
const header = el(doc, "header", "cockpit-header");
|
|
122
|
+
header.appendChild(el(doc, "h1", "cockpit-title", "Workers — supply"));
|
|
123
|
+
const summary = el(doc, "span", "cockpit-supply-summary", `${view.live}/${view.count} live`);
|
|
124
|
+
summary.setAttribute("data-summary", "supply");
|
|
125
|
+
header.appendChild(summary);
|
|
126
|
+
root.appendChild(header);
|
|
127
|
+
|
|
128
|
+
if (view.count === 0) {
|
|
129
|
+
const empty = el(doc, "div", "cockpit-supply-empty", "No workers connected.");
|
|
130
|
+
empty.setAttribute("data-empty", "true");
|
|
131
|
+
root.appendChild(empty);
|
|
132
|
+
host.appendChild(root);
|
|
133
|
+
return { root };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const list = el(doc, "div", "cockpit-supply-list");
|
|
137
|
+
for (const leaf of view.leaves) {
|
|
138
|
+
list.appendChild(leafSection(doc, leaf, options));
|
|
139
|
+
}
|
|
140
|
+
root.appendChild(list);
|
|
141
|
+
|
|
142
|
+
host.appendChild(root);
|
|
143
|
+
return { root };
|
|
144
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
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
|
+
};
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
test("grades liveness: down when disconnected, stale past the threshold, else live", () => {
|
|
18
|
+
const view = supplyView(
|
|
19
|
+
report({
|
|
20
|
+
workers: [
|
|
21
|
+
{ instance: "a", identity: "t", stream: "a", jobKeys: [], live: false, staleMs: 0 },
|
|
22
|
+
{ instance: "b", identity: "t", stream: "b", jobKeys: [], live: true, staleMs: 20_000 },
|
|
23
|
+
{ instance: "c", identity: "t", stream: "c", jobKeys: [], live: true, staleMs: 100 },
|
|
24
|
+
],
|
|
25
|
+
}),
|
|
26
|
+
{ staleAfterMs: 15_000 },
|
|
27
|
+
);
|
|
28
|
+
assert.equal(view.workers.find((w) => w.instance === "a")?.liveness, "down");
|
|
29
|
+
assert.equal(view.workers.find((w) => w.instance === "b")?.liveness, "stale");
|
|
30
|
+
assert.equal(view.workers.find((w) => w.instance === "c")?.liveness, "live");
|
|
31
|
+
assert.equal(view.count, 3);
|
|
32
|
+
assert.equal(view.live, 1);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("defaults absent family/host to a stable dash and counts + sorts jobKeys", () => {
|
|
36
|
+
const view = supplyView(
|
|
37
|
+
report({
|
|
38
|
+
workers: [{ instance: "a", identity: "t", stream: "a", jobKeys: ["z", "a"], live: true, staleMs: 0 }],
|
|
39
|
+
}),
|
|
40
|
+
);
|
|
41
|
+
const w = view.workers[0];
|
|
42
|
+
assert.equal(w?.family, "—");
|
|
43
|
+
assert.equal(w?.host, "—");
|
|
44
|
+
assert.deepEqual(w?.jobKeys, ["a", "z"]);
|
|
45
|
+
assert.equal(w?.jobs, 2);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("sorts leaves by token and workers by instance, with per-leaf live counts", () => {
|
|
49
|
+
const view = supplyView(
|
|
50
|
+
report({
|
|
51
|
+
leaves: [
|
|
52
|
+
{
|
|
53
|
+
token: "leaf-b",
|
|
54
|
+
workers: [
|
|
55
|
+
{ instance: "b2", identity: "leaf-b", stream: "b2", jobKeys: [], live: true, staleMs: 0 },
|
|
56
|
+
{ instance: "b1", identity: "leaf-b", stream: "b1", jobKeys: [], live: false, staleMs: 0 },
|
|
57
|
+
],
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
token: "leaf-a",
|
|
61
|
+
workers: [{ instance: "a1", identity: "leaf-a", stream: "a1", jobKeys: [], live: true, staleMs: 0 }],
|
|
62
|
+
},
|
|
63
|
+
],
|
|
64
|
+
}),
|
|
65
|
+
);
|
|
66
|
+
assert.deepEqual(
|
|
67
|
+
view.leaves.map((l) => l.token),
|
|
68
|
+
["leaf-a", "leaf-b"],
|
|
69
|
+
);
|
|
70
|
+
assert.deepEqual(
|
|
71
|
+
view.leaves[1]?.workers.map((w) => w.instance),
|
|
72
|
+
["b1", "b2"],
|
|
73
|
+
);
|
|
74
|
+
assert.equal(view.leaves[1]?.liveCount, 1);
|
|
75
|
+
assert.equal(view.leaves[1]?.total, 2);
|
|
76
|
+
});
|
|
@@ -0,0 +1,165 @@
|
|
|
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
|
+
/** The supply-only report the cockpit polls (no demand fields — those are enrolment epic #152). */
|
|
50
|
+
export interface SupplyReport {
|
|
51
|
+
/** Supply grouped by leaf token. */
|
|
52
|
+
readonly leaves: readonly SupplyLeafReport[];
|
|
53
|
+
/** Every connected worker, flat. */
|
|
54
|
+
readonly workers: readonly SupplyWorkerReport[];
|
|
55
|
+
/** The number of connected workers. */
|
|
56
|
+
readonly count: number;
|
|
57
|
+
/** When the snapshot was taken, ISO-8601 (optional). */
|
|
58
|
+
readonly generatedAt?: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** One worker row in the renderable supply view. */
|
|
62
|
+
export interface SupplyWorkerView {
|
|
63
|
+
readonly instance: string;
|
|
64
|
+
readonly identity: string;
|
|
65
|
+
/** The relay stream to open when the operator drills into this worker. */
|
|
66
|
+
readonly stream: string;
|
|
67
|
+
/** Declared family, or `"—"` when absent (so the cell always renders something stable). */
|
|
68
|
+
readonly family: string;
|
|
69
|
+
/** Declared host, or `"—"` when absent. */
|
|
70
|
+
readonly host: string;
|
|
71
|
+
/** The worker's current jobKeys, sorted. */
|
|
72
|
+
readonly jobKeys: readonly string[];
|
|
73
|
+
/** The number of current jobs. */
|
|
74
|
+
readonly jobs: number;
|
|
75
|
+
/** The coarse liveness grade for the status dot. */
|
|
76
|
+
readonly liveness: Liveness;
|
|
77
|
+
/** How long since the last liveness refresh, in ms. */
|
|
78
|
+
readonly staleMs: number;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** One leaf-token section in the renderable supply view. */
|
|
82
|
+
export interface SupplyLeafView {
|
|
83
|
+
readonly token: string;
|
|
84
|
+
readonly workers: readonly SupplyWorkerView[];
|
|
85
|
+
/** Workers under this leaf currently graded `live`. */
|
|
86
|
+
readonly liveCount: number;
|
|
87
|
+
/** Total workers under this leaf. */
|
|
88
|
+
readonly total: number;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** The full renderable supply view. */
|
|
92
|
+
export interface SupplyView {
|
|
93
|
+
/** Supply grouped by leaf token, sorted by token. */
|
|
94
|
+
readonly leaves: readonly SupplyLeafView[];
|
|
95
|
+
/** Every worker, flat, sorted by instance. */
|
|
96
|
+
readonly workers: readonly SupplyWorkerView[];
|
|
97
|
+
/** The number of workers. */
|
|
98
|
+
readonly count: number;
|
|
99
|
+
/** The number of workers graded `live`. */
|
|
100
|
+
readonly live: number;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Options for {@link supplyView}. */
|
|
104
|
+
export interface SupplyViewOptions {
|
|
105
|
+
/**
|
|
106
|
+
* A live worker whose last refresh is older than this (ms) is graded `stale` rather than `live`.
|
|
107
|
+
* A disconnected worker is always `down`. Default 15000.
|
|
108
|
+
*/
|
|
109
|
+
readonly staleAfterMs?: number;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const DEFAULT_STALE_AFTER_MS = 15_000;
|
|
113
|
+
|
|
114
|
+
function liveness(worker: SupplyWorkerReport, staleAfterMs: number): Liveness {
|
|
115
|
+
if (!worker.live) return "down";
|
|
116
|
+
return worker.staleMs >= staleAfterMs ? "stale" : "live";
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function workerView(worker: SupplyWorkerReport, staleAfterMs: number): SupplyWorkerView {
|
|
120
|
+
const jobKeys = [...worker.jobKeys].sort((a, b) => a.localeCompare(b));
|
|
121
|
+
return {
|
|
122
|
+
instance: worker.instance,
|
|
123
|
+
identity: worker.identity,
|
|
124
|
+
stream: worker.stream,
|
|
125
|
+
family: worker.family ?? "—",
|
|
126
|
+
host: worker.host ?? "—",
|
|
127
|
+
jobKeys,
|
|
128
|
+
jobs: jobKeys.length,
|
|
129
|
+
liveness: liveness(worker, staleAfterMs),
|
|
130
|
+
staleMs: worker.staleMs,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const byInstance = (a: SupplyWorkerView, b: SupplyWorkerView) => a.instance.localeCompare(b.instance);
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Derive the renderable supply view from the app's supply-only report.
|
|
138
|
+
*
|
|
139
|
+
* Pure and total: it re-sorts leaves by token and workers by instance so the derived view is stable
|
|
140
|
+
* and diff-friendly regardless of the report's incoming order; no input mutates and no I/O happens.
|
|
141
|
+
*/
|
|
142
|
+
export function supplyView(report: SupplyReport, options: SupplyViewOptions = {}): SupplyView {
|
|
143
|
+
const staleAfterMs = options.staleAfterMs ?? DEFAULT_STALE_AFTER_MS;
|
|
144
|
+
|
|
145
|
+
const leaves: SupplyLeafView[] = report.leaves
|
|
146
|
+
.map((leaf) => {
|
|
147
|
+
const workers = leaf.workers.map((w) => workerView(w, staleAfterMs)).sort(byInstance);
|
|
148
|
+
return {
|
|
149
|
+
token: leaf.token,
|
|
150
|
+
workers,
|
|
151
|
+
liveCount: workers.filter((w) => w.liveness === "live").length,
|
|
152
|
+
total: workers.length,
|
|
153
|
+
};
|
|
154
|
+
})
|
|
155
|
+
.sort((a, b) => a.token.localeCompare(b.token));
|
|
156
|
+
|
|
157
|
+
const workers = report.workers.map((w) => workerView(w, staleAfterMs)).sort(byInstance);
|
|
158
|
+
|
|
159
|
+
return {
|
|
160
|
+
leaves,
|
|
161
|
+
workers,
|
|
162
|
+
count: workers.length,
|
|
163
|
+
live: workers.filter((w) => w.liveness === "live").length,
|
|
164
|
+
};
|
|
165
|
+
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
// Tests for H4's channel-side `blackboard` family module (#147). These prove the generalized
|
|
2
|
+
// agentic-channel path is a faithful bridge to the SAME per-plan board the HTTP hook serves:
|
|
3
|
+
// - a channel `append` frame writes the very rows `readBlackboard(data, planKey)` (the HTTP path)
|
|
4
|
+
// reads back — one canonical store, no drift surface;
|
|
5
|
+
// - `file-claim` conflict-of-intent is reported on the channel exactly as over HTTP;
|
|
6
|
+
// - board scope is capability-derived (the plan's blackboard token → plan_key), so a connection
|
|
7
|
+
// only ever touches the board its credential authorises;
|
|
8
|
+
// - an unknown/absent credential is rejected (advisory — the frame is dropped, never a hard-lock).
|
|
9
|
+
import test from "node:test";
|
|
10
|
+
import { AgenticHub, type HubConnection, sharedSecretAuthenticator } from "@nanobpm/agentic/channel";
|
|
11
|
+
import type { ChannelTransport, HandshakeRequest } from "@nanobpm/agentic/channel";
|
|
12
|
+
import type { Frame } from "@nanobpm/agentic/protocol";
|
|
13
|
+
import { assert, assertEquals } from "#test-assert";
|
|
14
|
+
import { noopLog } from "../../../test/log.ts";
|
|
15
|
+
import { memBlackboardData } from "../../../test/blackboardDb.ts";
|
|
16
|
+
import { readBlackboard } from "../../blackboard.ts";
|
|
17
|
+
import type { AgenticContext } from "../registry.ts";
|
|
18
|
+
import { family } from "./blackboard.family.ts";
|
|
19
|
+
|
|
20
|
+
/** A do-nothing transport just to satisfy the hub constructor; the tests drive the router directly. */
|
|
21
|
+
function fakeTransport(): ChannelTransport {
|
|
22
|
+
return {
|
|
23
|
+
onConnection() {},
|
|
24
|
+
address: null,
|
|
25
|
+
async close() {},
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Build a hub + mount the blackboard family over a real in-memory DataLayer. */
|
|
30
|
+
function mountFamily() {
|
|
31
|
+
const { data, db, close } = memBlackboardData();
|
|
32
|
+
const hub = new AgenticHub({
|
|
33
|
+
transport: fakeTransport(),
|
|
34
|
+
authenticator: sharedSecretAuthenticator({ secret: "s3cr3t" }),
|
|
35
|
+
sweepIntervalMs: 0,
|
|
36
|
+
});
|
|
37
|
+
const ctx: AgenticContext = {
|
|
38
|
+
hub,
|
|
39
|
+
registry: hub.registry,
|
|
40
|
+
// The blackboard family never touches the transport; a minimal stand-in is enough.
|
|
41
|
+
transport: undefined as unknown as AgenticContext["transport"],
|
|
42
|
+
data,
|
|
43
|
+
log: noopLog(),
|
|
44
|
+
};
|
|
45
|
+
family.mount(ctx);
|
|
46
|
+
return { hub, data, db, close };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** A HubConnection whose sends are captured, presenting `credential` at the handshake. */
|
|
50
|
+
function conn(hub: AgenticHub, credential: string | undefined, sent: Frame[]): HubConnection {
|
|
51
|
+
const handshake: HandshakeRequest = credential === undefined ? {} : { credential };
|
|
52
|
+
return {
|
|
53
|
+
id: `c-${credential ?? "anon"}`,
|
|
54
|
+
identity: "peer",
|
|
55
|
+
handshake,
|
|
56
|
+
registry: hub.registry,
|
|
57
|
+
send: (frame) => sent.push(frame),
|
|
58
|
+
close() {},
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function seedToken(db: { run(sql: string, params?: unknown[]): unknown }, planKey: string, token: string): void {
|
|
63
|
+
db.run("INSERT INTO plans (plan_key, blackboard_token) VALUES (?, ?)", [planKey, token]);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function appendFrame(seq: number, payload: Record<string, unknown>): Frame {
|
|
67
|
+
return { lane: "control", family: "blackboard", seq, payload: { op: "append", ...payload } };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function readFrame(seq: number, since?: number): Frame {
|
|
71
|
+
return { lane: "control", family: "blackboard", seq, payload: since === undefined ? { op: "read" } : { op: "read", since } };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
test("channel append writes the SAME board the HTTP readBlackboard path reads", async () => {
|
|
75
|
+
const { hub, data, db, close } = mountFamily();
|
|
76
|
+
try {
|
|
77
|
+
seedToken(db, "o/r#1", "tok-1");
|
|
78
|
+
const sent: Frame[] = [];
|
|
79
|
+
const c = conn(hub, "tok-1", sent);
|
|
80
|
+
|
|
81
|
+
const ran = await hub.router.route(
|
|
82
|
+
appendFrame(1, { authorTask: "gap-2", kind: "note", body: "hello board" }),
|
|
83
|
+
c,
|
|
84
|
+
);
|
|
85
|
+
assertEquals(ran, true);
|
|
86
|
+
assertEquals(sent.length, 1);
|
|
87
|
+
const reply = sent[0].payload as { op: string; inserted: boolean; id: number };
|
|
88
|
+
assertEquals(reply.op, "append");
|
|
89
|
+
assertEquals(reply.inserted, true);
|
|
90
|
+
assert(reply.id > 0);
|
|
91
|
+
|
|
92
|
+
// Parity: the HTTP-side reader sees exactly what the channel wrote, under the same plan scope.
|
|
93
|
+
const entries = await readBlackboard(data, "o/r#1");
|
|
94
|
+
assertEquals(entries.length, 1);
|
|
95
|
+
assertEquals(entries[0].author_task, "gap-2");
|
|
96
|
+
assertEquals(entries[0].body, "hello board");
|
|
97
|
+
assertEquals(entries[0].kind, "note");
|
|
98
|
+
} finally {
|
|
99
|
+
await hub.close();
|
|
100
|
+
close();
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("channel file-claim reports conflicts with a prior claim by another author", async () => {
|
|
105
|
+
const { hub, data, db, close } = mountFamily();
|
|
106
|
+
try {
|
|
107
|
+
seedToken(db, "o/r#1", "tok-1");
|
|
108
|
+
const sent: Frame[] = [];
|
|
109
|
+
const c = conn(hub, "tok-1", sent);
|
|
110
|
+
|
|
111
|
+
await hub.router.route(
|
|
112
|
+
appendFrame(1, { authorTask: "gap-1", kind: "file-claim", files: ["engine/state.rs"], body: "own state.rs" }),
|
|
113
|
+
c,
|
|
114
|
+
);
|
|
115
|
+
await hub.router.route(
|
|
116
|
+
appendFrame(2, { authorTask: "gap-2", kind: "file-claim", files: ["engine/state.rs"], body: "also want state.rs" }),
|
|
117
|
+
c,
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
const second = sent[1].payload as { conflicts: { authorTask: string; file: string }[] };
|
|
121
|
+
assertEquals(second.conflicts.length, 1);
|
|
122
|
+
assertEquals(second.conflicts[0].authorTask, "gap-1");
|
|
123
|
+
assertEquals(second.conflicts[0].file, "engine/state.rs");
|
|
124
|
+
|
|
125
|
+
// And both rows landed on the shared board.
|
|
126
|
+
const entries = await readBlackboard(data, "o/r#1");
|
|
127
|
+
assertEquals(entries.length, 2);
|
|
128
|
+
} finally {
|
|
129
|
+
await hub.close();
|
|
130
|
+
close();
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test("channel read returns entries appended over HTTP (bidirectional bridge parity)", async () => {
|
|
135
|
+
const { hub, data, db, close } = mountFamily();
|
|
136
|
+
try {
|
|
137
|
+
seedToken(db, "o/r#1", "tok-1");
|
|
138
|
+
// Write via the HTTP-path adapter…
|
|
139
|
+
const { appendEntry } = await import("../../blackboard.ts");
|
|
140
|
+
await appendEntry(data, "o/r#1", { author_task: "gap-3", kind: "note", body: "via http" });
|
|
141
|
+
|
|
142
|
+
// …and read it back over the channel.
|
|
143
|
+
const sent: Frame[] = [];
|
|
144
|
+
const ran = await hub.router.route(readFrame(9), conn(hub, "tok-1", sent));
|
|
145
|
+
assertEquals(ran, true);
|
|
146
|
+
const reply = sent[0].payload as { op: string; entries: { authorTask: string; body: string }[] };
|
|
147
|
+
assertEquals(reply.op, "read");
|
|
148
|
+
assertEquals(reply.entries.length, 1);
|
|
149
|
+
assertEquals(reply.entries[0].authorTask, "gap-3");
|
|
150
|
+
assertEquals(reply.entries[0].body, "via http");
|
|
151
|
+
} finally {
|
|
152
|
+
await hub.close();
|
|
153
|
+
close();
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test("an unknown credential is rejected — no board is touched, no reply sent", async () => {
|
|
158
|
+
const { hub, data, db, close } = mountFamily();
|
|
159
|
+
try {
|
|
160
|
+
seedToken(db, "o/r#1", "tok-1");
|
|
161
|
+
const sent: Frame[] = [];
|
|
162
|
+
const ran = await hub.router.route(
|
|
163
|
+
appendFrame(1, { authorTask: "gap-2", kind: "note", body: "should not land" }),
|
|
164
|
+
conn(hub, "bogus-token", sent),
|
|
165
|
+
);
|
|
166
|
+
assertEquals(ran, true); // the handler ran (and rejected) — the family owns the frame
|
|
167
|
+
assertEquals(sent.length, 0); // no reply: scope could not be resolved
|
|
168
|
+
// Nothing was written under any scope.
|
|
169
|
+
const [{ n }] = db.all<{ n: number }>("SELECT COUNT(*) AS n FROM agentic_blackboard");
|
|
170
|
+
assertEquals(n, 0);
|
|
171
|
+
assertEquals((await readBlackboard(data, "o/r#1")).length, 0);
|
|
172
|
+
} finally {
|
|
173
|
+
await hub.close();
|
|
174
|
+
close();
|
|
175
|
+
}
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test("an absent credential is rejected too", async () => {
|
|
179
|
+
const { hub, db, close } = mountFamily();
|
|
180
|
+
try {
|
|
181
|
+
seedToken(db, "o/r#1", "tok-1");
|
|
182
|
+
const sent: Frame[] = [];
|
|
183
|
+
await hub.router.route(appendFrame(1, { kind: "note", body: "x" }), conn(hub, undefined, sent));
|
|
184
|
+
assertEquals(sent.length, 0);
|
|
185
|
+
} finally {
|
|
186
|
+
await hub.close();
|
|
187
|
+
close();
|
|
188
|
+
}
|
|
189
|
+
});
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// nano-workforce — the agentic-channel `blackboard` family (ADR 0056, H4 / #147).
|
|
2
|
+
//
|
|
3
|
+
// This is H4's ONE new file plugged into the H0 (#143) family-registration seam. It mounts
|
|
4
|
+
// `@nanobpm/agentic/blackboard`'s `blackboard` message family on the app-tier hub, backed by the
|
|
5
|
+
// SAME `BlackboardStore` — over the SAME app SQLite DataLayer (`ctx.data.source().db`) — that the
|
|
6
|
+
// legacy `/app/api/hooks/blackboard` HTTP hook now uses (see `app/blackboard.ts`). One canonical
|
|
7
|
+
// store, one table (`agentic_blackboard`), reached two ways: the HTTP side-channel and the agentic
|
|
8
|
+
// channel serve the identical per-plan board with no drift surface.
|
|
9
|
+
//
|
|
10
|
+
// Board scope parity: the family derives each connection's board `scope` from its capability
|
|
11
|
+
// credential — the per-plan blackboard token — resolved back to its `plan_key` via
|
|
12
|
+
// `planKeyForTokenSync`, EXACTLY as the HTTP hook resolves `?token=` to a plan. So a channel client
|
|
13
|
+
// and an HTTP caller holding the same plan token read/write the very same rows. An unknown/absent
|
|
14
|
+
// credential yields no scope and the frame is rejected (advisory — never a hard-lock, never gates a
|
|
15
|
+
// BPMN sequence flow).
|
|
16
|
+
//
|
|
17
|
+
// Adds NO migration of its own: H4's reserved `db/migrations/025_agentic_blackboard.sql` (owned by
|
|
18
|
+
// the app-side adapter) creates `agentic_blackboard`; `store.ensureSchema()` here is the idempotent
|
|
19
|
+
// belt-and-braces the store's own contract expects.
|
|
20
|
+
import { attachBlackboardFamily, BlackboardStore } from "@nanobpm/agentic/blackboard";
|
|
21
|
+
import type { HubConnection } from "@nanobpm/agentic/channel";
|
|
22
|
+
import { planKeyForTokenSync } from "../../blackboard.ts";
|
|
23
|
+
import type { AgenticContext, AgenticFamily } from "../registry.ts";
|
|
24
|
+
|
|
25
|
+
/** The capability-credential a connection presents at the handshake (the blackboard token). */
|
|
26
|
+
function credentialOf(conn: HubConnection): string {
|
|
27
|
+
return (conn.handshake.credential ?? conn.handshake.query?.capability ?? "").trim();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
let handle: { stop(): void } | undefined;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The `blackboard` family module. `mount` attaches the family to the hub when the app has a data
|
|
34
|
+
* layer; without one (data isn't mounted) it is a no-op — the channel simply serves no blackboard,
|
|
35
|
+
* exactly as the HTTP hook would 404. `teardown` detaches it.
|
|
36
|
+
*/
|
|
37
|
+
export const family: AgenticFamily = {
|
|
38
|
+
name: "blackboard",
|
|
39
|
+
mount(ctx: AgenticContext): void {
|
|
40
|
+
// Stop any previously-attached family before (re)mounting, so a repeat mount() (tests or a
|
|
41
|
+
// future remount path) can't leave stale handlers attached and double-handle frames / leak
|
|
42
|
+
// resources. Done unconditionally — before the data check — so even a no-data remount detaches
|
|
43
|
+
// the prior handle instead of silently leaving it live.
|
|
44
|
+
handle?.stop();
|
|
45
|
+
handle = undefined;
|
|
46
|
+
const data = ctx.data;
|
|
47
|
+
if (!data) {
|
|
48
|
+
ctx.log.warn("agentic blackboard family: no data layer; not mounting");
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
const db = data.source().db;
|
|
52
|
+
const store = new BlackboardStore(db);
|
|
53
|
+
store.ensureSchema();
|
|
54
|
+
handle = attachBlackboardFamily(ctx.hub, store, {
|
|
55
|
+
// Scope every board to the plan the credential's token maps to — the same plan the HTTP hook
|
|
56
|
+
// scopes to — so the two paths share one board. Returning undefined rejects the frame.
|
|
57
|
+
scopeOf: (conn) => planKeyForTokenSync(db, credentialOf(conn)),
|
|
58
|
+
onError: (err, connectionId) =>
|
|
59
|
+
ctx.log.warn("agentic blackboard family error", { connectionId, err: String(err) }),
|
|
60
|
+
});
|
|
61
|
+
ctx.log.info("agentic blackboard family mounted");
|
|
62
|
+
},
|
|
63
|
+
teardown(): void {
|
|
64
|
+
handle?.stop();
|
|
65
|
+
handle = undefined;
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
export default family;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// Schema-drift guard (#147). The `db/migrations/025_agentic_blackboard.sql` CREATE statements MUST be
|
|
2
|
+
// the canonical `BLACKBOARD_SCHEMA_SQL` verbatim — the exact DDL `@nanobpm/agentic/blackboard`'s
|
|
3
|
+
// `BlackboardStore.ensureSchema()` (and the agentic-channel family) apply. If the two ever drift, a
|
|
4
|
+
// board created by a migration on one host and by `ensureSchema()` on another would disagree — this
|
|
5
|
+
// test fails the build before that can ship.
|
|
6
|
+
import { readFileSync } from "node:fs";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
import test from "node:test";
|
|
9
|
+
import { BLACKBOARD_SCHEMA_SQL } from "@nanobpm/agentic/blackboard";
|
|
10
|
+
import { assert, assertEquals } from "#test-assert";
|
|
11
|
+
|
|
12
|
+
test("migration 025 CREATE statements equal BLACKBOARD_SCHEMA_SQL verbatim", () => {
|
|
13
|
+
const path = fileURLToPath(new URL("../db/migrations/025_agentic_blackboard.sql", import.meta.url));
|
|
14
|
+
const sql = readFileSync(path, "utf8");
|
|
15
|
+
const start = sql.indexOf("CREATE TABLE IF NOT EXISTS agentic_blackboard");
|
|
16
|
+
const end = sql.indexOf("(scope, id);");
|
|
17
|
+
assert(start !== -1, "migration 025 is missing the `CREATE TABLE IF NOT EXISTS agentic_blackboard` marker");
|
|
18
|
+
assert(end !== -1, "migration 025 is missing the `(scope, id);` index marker");
|
|
19
|
+
const createBlock = sql.slice(start, end + "(scope, id);".length).trim();
|
|
20
|
+
assertEquals(createBlock, BLACKBOARD_SCHEMA_SQL.trim());
|
|
21
|
+
});
|