@nanobpm/nano-workforce 0.54.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 +7 -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/openapi.yaml +94 -0
- 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
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// GET /app/api/agentic/supply → operationId `getAgenticSupply` (ADR 0058/0059 OpenAPI surface, mounted
|
|
2
|
+
// under base /app/api). The SUPPLY-ONLY visibility report the H5 cockpit page (#148) polls: the live
|
|
3
|
+
// worker list — family, host, current jobs, liveness — grouped by leaf token, sourced from the H1
|
|
4
|
+
// presence registry (#144). Read-only projection; it NEVER gates control flow (advisory-only, ADR 0056).
|
|
5
|
+
//
|
|
6
|
+
// This is the supply half of the visibility plane only. The demand×supply matrix, missing-agent-type
|
|
7
|
+
// reds, and diversity-SLO lights are DE-SCOPED to the enrolment epic #152 — this report carries no
|
|
8
|
+
// demand-side fields, and the cockpit renders none.
|
|
9
|
+
//
|
|
10
|
+
// The optional shared-secret guard stays HERE (the runtime does not enforce OpenAPI `security`): when
|
|
11
|
+
// NANO_PR_WEBHOOK_SECRET is set, callers must present it via the x-hook-secret header. Unset → open.
|
|
12
|
+
|
|
13
|
+
import { currentPresenceRegistry, type SupplyWorker } from "../app/agentic/families/presence.family.ts";
|
|
14
|
+
import { envVar } from "../app/version.ts";
|
|
15
|
+
import type { AgenticSupplyReport, AgenticSupplyWorker } from "../nano-generated/api-io.d.ts";
|
|
16
|
+
import { defineOperation } from "../nano-generated/operations.ts";
|
|
17
|
+
|
|
18
|
+
// The optional shared-secret guard: when NANO_PR_WEBHOOK_SECRET is set, callers must present it via
|
|
19
|
+
// the x-hook-secret header. Captured once, at module load.
|
|
20
|
+
const SECRET = envVar("NANO_PR_WEBHOOK_SECRET") ?? "";
|
|
21
|
+
|
|
22
|
+
// The relay stream id to subscribe when drilling into a worker's terminal. Presence keys the relay by
|
|
23
|
+
// worker instance; the H6 correlation slice (#149) may repoint this at a jobKey-scoped stream.
|
|
24
|
+
function toWorker(w: SupplyWorker): AgenticSupplyWorker {
|
|
25
|
+
const out: AgenticSupplyWorker = {
|
|
26
|
+
instance: w.instance,
|
|
27
|
+
identity: w.identity,
|
|
28
|
+
stream: w.instance,
|
|
29
|
+
jobKeys: [...w.jobKeys],
|
|
30
|
+
live: w.live,
|
|
31
|
+
staleMs: w.staleMs,
|
|
32
|
+
};
|
|
33
|
+
if (w.family !== undefined) out.family = w.family;
|
|
34
|
+
if (w.host !== undefined) out.host = w.host;
|
|
35
|
+
return out;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export default defineOperation("getAgenticSupply", async ({ req }, app) => {
|
|
39
|
+
if (SECRET && req.headers.get("x-hook-secret") !== SECRET) {
|
|
40
|
+
app.log.warn("getAgenticSupply rejected: missing/invalid shared secret");
|
|
41
|
+
return { status: 401, body: { error: "unauthorized" } };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const registry = currentPresenceRegistry();
|
|
45
|
+
if (!registry) {
|
|
46
|
+
// The presence family has not mounted (or has torn down) — no supply to report, not an error.
|
|
47
|
+
const empty: AgenticSupplyReport = { count: 0, generatedAt: new Date().toISOString(), workers: [], leaves: [] };
|
|
48
|
+
return { status: 200, body: empty };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const snapshot = registry.snapshot();
|
|
52
|
+
const report: AgenticSupplyReport = {
|
|
53
|
+
count: snapshot.count,
|
|
54
|
+
generatedAt: new Date().toISOString(),
|
|
55
|
+
workers: snapshot.workers.map(toWorker),
|
|
56
|
+
leaves: snapshot.leaves.map((leaf) => ({ token: leaf.token, workers: leaf.workers.map(toWorker) })),
|
|
57
|
+
};
|
|
58
|
+
return { status: 200, body: report };
|
|
59
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/nano-workforce",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.55.0",
|
|
4
4
|
"description": "Nano Workforce — an Agent Graph Orchestration application for Agentic SDLC: durable BPMN processes that coordinate a graph of AI agents across the software delivery lifecycle.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "main.ts",
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/* The SUPPLY cockpit stylesheet (H5 / #148) — a compact, phone-friendly dark cockpit shared by the
|
|
2
|
+
standalone shell and the console App-View embed, so the two render identically. The three-state
|
|
3
|
+
liveness dot keys off `data-liveness` (live | stale | down). It styles the SUPPLY worker list only
|
|
4
|
+
— no demand×supply matrix / diversity-SLO light (deferred to enrolment epic #152). */
|
|
5
|
+
|
|
6
|
+
:root {
|
|
7
|
+
--cockpit-bg: #0b0f14;
|
|
8
|
+
--cockpit-panel: #131a22;
|
|
9
|
+
--cockpit-edge: #223041;
|
|
10
|
+
--cockpit-text: #e6edf3;
|
|
11
|
+
--cockpit-muted: #8b98a5;
|
|
12
|
+
--cockpit-green: #2ea043;
|
|
13
|
+
--cockpit-amber: #d29922;
|
|
14
|
+
--cockpit-red: #f85149;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
.cockpit-shell {
|
|
18
|
+
color: var(--cockpit-text);
|
|
19
|
+
background: var(--cockpit-bg);
|
|
20
|
+
font: 14px/1.4 ui-sans-serif, system-ui, sans-serif;
|
|
21
|
+
display: grid;
|
|
22
|
+
gap: 12px;
|
|
23
|
+
padding: 12px;
|
|
24
|
+
min-height: 100%;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
.cockpit-header {
|
|
28
|
+
display: flex;
|
|
29
|
+
flex-wrap: wrap;
|
|
30
|
+
align-items: center;
|
|
31
|
+
gap: 12px;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
.cockpit-title {
|
|
35
|
+
font-size: 16px;
|
|
36
|
+
margin: 0;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
.cockpit-supply-summary {
|
|
40
|
+
color: var(--cockpit-muted);
|
|
41
|
+
font-variant-numeric: tabular-nums;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
.cockpit-supply-region,
|
|
45
|
+
.cockpit-terminal {
|
|
46
|
+
background: var(--cockpit-panel);
|
|
47
|
+
border: 1px solid var(--cockpit-edge);
|
|
48
|
+
border-radius: 8px;
|
|
49
|
+
padding: 12px;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
.cockpit-panel-title {
|
|
53
|
+
font-size: 13px;
|
|
54
|
+
margin: 0 0 8px;
|
|
55
|
+
color: var(--cockpit-muted);
|
|
56
|
+
text-transform: uppercase;
|
|
57
|
+
letter-spacing: 0.04em;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
.cockpit-leaf {
|
|
61
|
+
margin-bottom: 12px;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
.cockpit-leaf-head {
|
|
65
|
+
display: flex;
|
|
66
|
+
justify-content: space-between;
|
|
67
|
+
align-items: baseline;
|
|
68
|
+
margin-bottom: 6px;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
.cockpit-leaf-name {
|
|
72
|
+
font-weight: 600;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
.cockpit-leaf-count {
|
|
76
|
+
color: var(--cockpit-muted);
|
|
77
|
+
font-size: 12px;
|
|
78
|
+
font-variant-numeric: tabular-nums;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
.cockpit-supply-table {
|
|
82
|
+
width: 100%;
|
|
83
|
+
border-collapse: collapse;
|
|
84
|
+
font-variant-numeric: tabular-nums;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
.cockpit-th {
|
|
88
|
+
text-align: left;
|
|
89
|
+
font-size: 11px;
|
|
90
|
+
text-transform: uppercase;
|
|
91
|
+
letter-spacing: 0.04em;
|
|
92
|
+
color: var(--cockpit-muted);
|
|
93
|
+
padding: 4px 8px;
|
|
94
|
+
border-bottom: 1px solid var(--cockpit-edge);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
.cockpit-td {
|
|
98
|
+
padding: 6px 8px;
|
|
99
|
+
border-bottom: 1px solid rgba(34, 48, 65, 0.5);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
.cockpit-supply-name {
|
|
103
|
+
display: flex;
|
|
104
|
+
align-items: center;
|
|
105
|
+
gap: 8px;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
.cockpit-dot {
|
|
109
|
+
width: 8px;
|
|
110
|
+
height: 8px;
|
|
111
|
+
border-radius: 50%;
|
|
112
|
+
display: inline-block;
|
|
113
|
+
flex: 0 0 auto;
|
|
114
|
+
background: var(--cockpit-muted);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
.cockpit-dot[data-liveness="live"] { background: var(--cockpit-green); }
|
|
118
|
+
.cockpit-dot[data-liveness="stale"] { background: var(--cockpit-amber); }
|
|
119
|
+
.cockpit-dot[data-liveness="down"] { background: var(--cockpit-red); }
|
|
120
|
+
|
|
121
|
+
.cockpit-worker {
|
|
122
|
+
background: none;
|
|
123
|
+
border: none;
|
|
124
|
+
color: var(--cockpit-text);
|
|
125
|
+
cursor: pointer;
|
|
126
|
+
font: inherit;
|
|
127
|
+
padding: 0;
|
|
128
|
+
text-decoration: underline;
|
|
129
|
+
text-underline-offset: 2px;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
.cockpit-worker:hover { color: #58a6ff; }
|
|
133
|
+
|
|
134
|
+
.cockpit-supply-liveness { color: var(--cockpit-muted); }
|
|
135
|
+
|
|
136
|
+
.cockpit-supply-empty {
|
|
137
|
+
color: var(--cockpit-muted);
|
|
138
|
+
padding: 8px 0;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
.cockpit-terminal-host {
|
|
142
|
+
min-height: 220px;
|
|
143
|
+
background: #05080b;
|
|
144
|
+
border-radius: 6px;
|
|
145
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
|
+
<title>Agent cockpit — supply (App View embed)</title>
|
|
7
|
+
<link rel="stylesheet" href="./cockpit.css" />
|
|
8
|
+
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@xterm/xterm@5/css/xterm.min.css" />
|
|
9
|
+
<style>
|
|
10
|
+
html, body { margin: 0; height: 100%; background: #0b0f14; }
|
|
11
|
+
</style>
|
|
12
|
+
<script type="importmap">
|
|
13
|
+
{
|
|
14
|
+
"imports": {
|
|
15
|
+
"@nanobpm/agentic/cockpit": "https://cdn.jsdelivr.net/npm/@nanobpm/agentic@0.1.0/dist/cockpit/index.js",
|
|
16
|
+
"@xterm/xterm": "https://cdn.jsdelivr.net/npm/@xterm/xterm@5/+esm"
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
</script>
|
|
20
|
+
</head>
|
|
21
|
+
<body>
|
|
22
|
+
<!--
|
|
23
|
+
Console App-View embed (ADR 0057). The console loads this document into its App-View surface and
|
|
24
|
+
hands it a host element; we mount the SAME supply cockpit via the SAME ./mount.js as the
|
|
25
|
+
standalone shell — only the host and the injected endpoints differ, so the page renders
|
|
26
|
+
identically. When the console injects endpoint config via `window.__NANO_APP_VIEW__`, it wins.
|
|
27
|
+
-->
|
|
28
|
+
<main id="cockpit-root"></main>
|
|
29
|
+
<script type="module">
|
|
30
|
+
import { mountCockpit } from "./mount.js";
|
|
31
|
+
|
|
32
|
+
const cfg = window.__NANO_APP_VIEW__ ?? {};
|
|
33
|
+
mountCockpit(cfg.host ?? document.getElementById("cockpit-root"), {
|
|
34
|
+
reportUrl: cfg.reportUrl,
|
|
35
|
+
relayUrl: cfg.relayUrl,
|
|
36
|
+
hookSecret: cfg.hookSecret,
|
|
37
|
+
relayToken: cfg.relayToken,
|
|
38
|
+
relayCapability: cfg.relayCapability,
|
|
39
|
+
});
|
|
40
|
+
</script>
|
|
41
|
+
</body>
|
|
42
|
+
</html>
|
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
// Browser adapter for the SUPPLY-only agentic cockpit (H5 / #148).
|
|
2
|
+
//
|
|
3
|
+
// This is the ONE wiring both the standalone shell and the console App-View embed call — they differ
|
|
4
|
+
// only in the host element they pass, so the supply cockpit renders identically embedded and
|
|
5
|
+
// standalone. It supplies the browser capabilities the injection-based core needs: the real
|
|
6
|
+
// `document`, a `fetch`-based supply report source, a `WebSocket` relay socket factory, and an
|
|
7
|
+
// xterm.js terminal sink.
|
|
8
|
+
//
|
|
9
|
+
// The genuinely reusable, correctness-critical parts — the relay client and the resume-from-offset
|
|
10
|
+
// terminal session — are REUSED from `@nanobpm/agentic/cockpit` (resolved via the host page's import
|
|
11
|
+
// map). Only the SUPPLY projection + render + poll orchestration the package does NOT provide is
|
|
12
|
+
// re-expressed here in plain browser ESM (the app has no build step, so the typed core under
|
|
13
|
+
// `app/agentic/cockpit/` cannot be imported directly by the browser). It is kept faithful to that
|
|
14
|
+
// tested TypeScript core: same DOM shape (`data-worker`, `data-liveness`, `data-stream`, …), same
|
|
15
|
+
// liveness grading, same self-scheduling poll + persistent-terminal discipline.
|
|
16
|
+
//
|
|
17
|
+
// It renders the SUPPLY worker list ONLY — NOT the packaged demand×supply matrix / missing-agent reds
|
|
18
|
+
// / diversity-SLO light (deferred to enrolment epic #152).
|
|
19
|
+
import { RelayChannelClient, TerminalSession } from "@nanobpm/agentic/cockpit";
|
|
20
|
+
import { Terminal } from "@xterm/xterm";
|
|
21
|
+
|
|
22
|
+
const DEFAULT_REFRESH_MS = 2000;
|
|
23
|
+
const DEFAULT_STALE_AFTER_MS = 15_000;
|
|
24
|
+
|
|
25
|
+
function isPosInt(value) {
|
|
26
|
+
return Number.isSafeInteger(value) && value > 0;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// ── supply projection (mirrors app/agentic/cockpit/supply-view.ts) ─────────────────────────────
|
|
30
|
+
|
|
31
|
+
function liveness(worker, staleAfterMs) {
|
|
32
|
+
if (!worker.live) return "down";
|
|
33
|
+
return worker.staleMs >= staleAfterMs ? "stale" : "live";
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function workerView(worker, staleAfterMs) {
|
|
37
|
+
const jobKeys = [...(worker.jobKeys ?? [])].sort((a, b) => a.localeCompare(b));
|
|
38
|
+
return {
|
|
39
|
+
instance: worker.instance,
|
|
40
|
+
identity: worker.identity,
|
|
41
|
+
stream: worker.stream,
|
|
42
|
+
family: worker.family ?? "\u2014",
|
|
43
|
+
host: worker.host ?? "\u2014",
|
|
44
|
+
jobKeys,
|
|
45
|
+
jobs: jobKeys.length,
|
|
46
|
+
liveness: liveness(worker, staleAfterMs),
|
|
47
|
+
staleMs: worker.staleMs,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function supplyView(report, staleAfterMs) {
|
|
52
|
+
const byInstance = (a, b) => a.instance.localeCompare(b.instance);
|
|
53
|
+
const leaves = (report.leaves ?? [])
|
|
54
|
+
.map((leaf) => {
|
|
55
|
+
const workers = leaf.workers.map((w) => workerView(w, staleAfterMs)).sort(byInstance);
|
|
56
|
+
return {
|
|
57
|
+
token: leaf.token,
|
|
58
|
+
workers,
|
|
59
|
+
liveCount: workers.filter((w) => w.liveness === "live").length,
|
|
60
|
+
total: workers.length,
|
|
61
|
+
};
|
|
62
|
+
})
|
|
63
|
+
.sort((a, b) => a.token.localeCompare(b.token));
|
|
64
|
+
const workers = (report.workers ?? []).map((w) => workerView(w, staleAfterMs)).sort(byInstance);
|
|
65
|
+
return { leaves, workers, count: workers.length, live: workers.filter((w) => w.liveness === "live").length };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// ── supply render (mirrors app/agentic/cockpit/supply-render.ts) ───────────────────────────────
|
|
69
|
+
|
|
70
|
+
function el(doc, tag, className, text) {
|
|
71
|
+
const node = doc.createElement(tag);
|
|
72
|
+
if (className !== undefined) node.className = className;
|
|
73
|
+
if (text !== undefined) node.textContent = text;
|
|
74
|
+
return node;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function dot(doc, live) {
|
|
78
|
+
const node = el(doc, "span", "cockpit-dot");
|
|
79
|
+
node.setAttribute("data-liveness", live);
|
|
80
|
+
return node;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function workerRow(doc, worker, onDrill) {
|
|
84
|
+
const row = el(doc, "tr", "cockpit-supply-worker");
|
|
85
|
+
row.setAttribute("data-worker", worker.instance);
|
|
86
|
+
row.setAttribute("data-liveness", worker.liveness);
|
|
87
|
+
row.setAttribute("data-stream", worker.stream);
|
|
88
|
+
|
|
89
|
+
const nameCell = el(doc, "td", "cockpit-td cockpit-supply-name");
|
|
90
|
+
nameCell.appendChild(dot(doc, worker.liveness));
|
|
91
|
+
const button = el(doc, "button", "cockpit-worker", worker.instance);
|
|
92
|
+
button.setAttribute("type", "button");
|
|
93
|
+
button.setAttribute("data-stream", worker.stream);
|
|
94
|
+
if (onDrill) button.addEventListener("click", () => onDrill(worker.stream));
|
|
95
|
+
nameCell.appendChild(button);
|
|
96
|
+
row.appendChild(nameCell);
|
|
97
|
+
|
|
98
|
+
row.appendChild(el(doc, "td", "cockpit-td cockpit-supply-family", worker.family));
|
|
99
|
+
row.appendChild(el(doc, "td", "cockpit-td cockpit-supply-host", worker.host));
|
|
100
|
+
const jobsCell = el(doc, "td", "cockpit-td cockpit-supply-jobs", worker.jobs === 0 ? "\u2014" : worker.jobKeys.join(", "));
|
|
101
|
+
jobsCell.setAttribute("data-jobs", String(worker.jobs));
|
|
102
|
+
row.appendChild(jobsCell);
|
|
103
|
+
const livenessCell = el(doc, "td", "cockpit-td cockpit-supply-liveness", worker.liveness);
|
|
104
|
+
livenessCell.setAttribute("data-liveness", worker.liveness);
|
|
105
|
+
row.appendChild(livenessCell);
|
|
106
|
+
return row;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function leafSection(doc, leaf, onDrill) {
|
|
110
|
+
const section = el(doc, "section", "cockpit-leaf");
|
|
111
|
+
section.setAttribute("data-leaf", leaf.token);
|
|
112
|
+
const header = el(doc, "div", "cockpit-leaf-head");
|
|
113
|
+
header.appendChild(el(doc, "span", "cockpit-leaf-name", leaf.token));
|
|
114
|
+
header.appendChild(el(doc, "span", "cockpit-leaf-count", `${leaf.liveCount}/${leaf.total} live`));
|
|
115
|
+
section.appendChild(header);
|
|
116
|
+
const table = el(doc, "table", "cockpit-supply-table");
|
|
117
|
+
const thead = el(doc, "thead", "cockpit-supply-thead");
|
|
118
|
+
const head = el(doc, "tr", "cockpit-supply-head");
|
|
119
|
+
for (const label of ["worker", "family", "host", "jobs", "liveness"]) head.appendChild(el(doc, "th", "cockpit-th", label));
|
|
120
|
+
thead.appendChild(head);
|
|
121
|
+
table.appendChild(thead);
|
|
122
|
+
const tbody = el(doc, "tbody", "cockpit-supply-tbody");
|
|
123
|
+
for (const worker of leaf.workers) tbody.appendChild(workerRow(doc, worker, onDrill));
|
|
124
|
+
table.appendChild(tbody);
|
|
125
|
+
section.appendChild(table);
|
|
126
|
+
return section;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function renderSupply(host, doc, view, onDrill) {
|
|
130
|
+
host.replaceChildren();
|
|
131
|
+
const root = el(doc, "div", "cockpit-supply");
|
|
132
|
+
root.setAttribute("data-worker-count", String(view.count));
|
|
133
|
+
root.setAttribute("data-live-count", String(view.live));
|
|
134
|
+
const header = el(doc, "header", "cockpit-header");
|
|
135
|
+
header.appendChild(el(doc, "h1", "cockpit-title", "Workers — supply"));
|
|
136
|
+
const summary = el(doc, "span", "cockpit-supply-summary", `${view.live}/${view.count} live`);
|
|
137
|
+
summary.setAttribute("data-summary", "supply");
|
|
138
|
+
header.appendChild(summary);
|
|
139
|
+
root.appendChild(header);
|
|
140
|
+
if (view.count === 0) {
|
|
141
|
+
const empty = el(doc, "div", "cockpit-supply-empty", "No workers connected.");
|
|
142
|
+
empty.setAttribute("data-empty", "true");
|
|
143
|
+
root.appendChild(empty);
|
|
144
|
+
host.appendChild(root);
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
const list = el(doc, "div", "cockpit-supply-list");
|
|
148
|
+
for (const leaf of view.leaves) list.appendChild(leafSection(doc, leaf, onDrill));
|
|
149
|
+
root.appendChild(list);
|
|
150
|
+
host.appendChild(root);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// ── boot orchestration (mirrors app/agentic/cockpit/supply-boot.ts) ────────────────────────────
|
|
154
|
+
|
|
155
|
+
/** An xterm.js-backed terminal sink mounted into `host`. */
|
|
156
|
+
function xtermSink(host) {
|
|
157
|
+
const term = new Terminal({ convertEol: true, fontFamily: "ui-monospace, monospace", fontSize: 13 });
|
|
158
|
+
term.open(host);
|
|
159
|
+
return { write: (chunk) => term.write(chunk), dispose: () => term.dispose() };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** A WebSocket relay socket factory for the agentic channel at `url`. */
|
|
163
|
+
function relaySocketFactory(url) {
|
|
164
|
+
return () => {
|
|
165
|
+
const ws = new WebSocket(url);
|
|
166
|
+
ws.binaryType = "arraybuffer";
|
|
167
|
+
return {
|
|
168
|
+
send: (bytes) => ws.send(bytes),
|
|
169
|
+
close: () => ws.close(),
|
|
170
|
+
onMessage: (cb) => ws.addEventListener("message", (event) => cb(new Uint8Array(event.data))),
|
|
171
|
+
onOpen: (cb) => ws.addEventListener("open", () => cb()),
|
|
172
|
+
onClose: (cb) => ws.addEventListener("close", () => cb()),
|
|
173
|
+
};
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Mount the SUPPLY cockpit into `host` and start polling.
|
|
179
|
+
*
|
|
180
|
+
* @param {Element} host — where the cockpit renders (standalone: document.body; embedded: the App-View host).
|
|
181
|
+
* @param {object} [opts]
|
|
182
|
+
* @param {string} [opts.reportUrl] — the supply JSON endpoint the app serves.
|
|
183
|
+
* @param {string} [opts.relayUrl] — the agentic channel WebSocket URL (with auth token + capability query).
|
|
184
|
+
* @param {string} [opts.hookSecret] — shared secret sent as `x-hook-secret` on the report fetch when the
|
|
185
|
+
* app's supply endpoint is guarded by NANO_PR_WEBHOOK_SECRET (omit for open deployments).
|
|
186
|
+
* @param {string} [opts.relayToken] — identity token appended to the default relay URL as `?token=…`.
|
|
187
|
+
* @param {string} [opts.relayCapability] — capability credential appended to the default relay URL as `&capability=…`.
|
|
188
|
+
* @param {number} [opts.refreshMs] — poll interval (default 2000).
|
|
189
|
+
* @param {number} [opts.staleAfterMs] — a worker is rendered "stale" once its last heartbeat is at
|
|
190
|
+
* least this many ms old (default 15000).
|
|
191
|
+
* @returns a handle with `.dispose()`.
|
|
192
|
+
*/
|
|
193
|
+
export function mountCockpit(host, opts = {}) {
|
|
194
|
+
if (host == null || typeof host.replaceChildren !== "function") {
|
|
195
|
+
throw new Error(
|
|
196
|
+
`mountCockpit(host): host must be a mounted DOM element (got ${host === null ? "null" : typeof host}).`,
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
const doc = document;
|
|
200
|
+
const reportUrl = opts.reportUrl ?? "/app/api/agentic/supply";
|
|
201
|
+
const hookSecret = opts.hookSecret;
|
|
202
|
+
const relayUrl = opts.relayUrl ?? defaultRelayUrl(opts.relayToken, opts.relayCapability);
|
|
203
|
+
const refreshMs = opts.refreshMs ?? DEFAULT_REFRESH_MS;
|
|
204
|
+
// refreshMs feeds setTimeout as a poll delay. A negative/NaN/fractional/unsafe value silently
|
|
205
|
+
// collapses to a ~0ms delay, turning the poll into a hot loop that hammers the supply endpoint.
|
|
206
|
+
// Require a positive safe integer up-front (mirroring the TS boot layer) so a bad opt fails loudly.
|
|
207
|
+
if (!isPosInt(refreshMs)) {
|
|
208
|
+
throw new RangeError(`mountCockpit(opts.refreshMs): must be a positive safe integer, got ${refreshMs}.`);
|
|
209
|
+
}
|
|
210
|
+
const staleAfterMs = opts.staleAfterMs ?? DEFAULT_STALE_AFTER_MS;
|
|
211
|
+
const connectRelay = relaySocketFactory(relayUrl);
|
|
212
|
+
const onError = (err) => console.error("[cockpit]", err);
|
|
213
|
+
|
|
214
|
+
// Stable skeleton: a volatile list region the poll re-renders + a PERSISTENT terminal region a
|
|
215
|
+
// refresh never touches (so a drilled-in terminal survives a list refresh).
|
|
216
|
+
host.replaceChildren();
|
|
217
|
+
const shell = el(doc, "div", "cockpit-shell");
|
|
218
|
+
const listRegion = el(doc, "div", "cockpit-supply-region");
|
|
219
|
+
const terminalPanel = el(doc, "section", "cockpit-terminal");
|
|
220
|
+
terminalPanel.appendChild(el(doc, "h2", "cockpit-panel-title", "Worker terminal"));
|
|
221
|
+
const terminalHost = el(doc, "div", "cockpit-terminal-host");
|
|
222
|
+
terminalHost.setAttribute("data-terminal", "host");
|
|
223
|
+
terminalPanel.appendChild(terminalHost);
|
|
224
|
+
shell.appendChild(listRegion);
|
|
225
|
+
shell.appendChild(terminalPanel);
|
|
226
|
+
host.appendChild(shell);
|
|
227
|
+
|
|
228
|
+
let running = false;
|
|
229
|
+
let disposed = false;
|
|
230
|
+
let timer;
|
|
231
|
+
let generation = 0;
|
|
232
|
+
let drill; // { stream, client }
|
|
233
|
+
let terminal; // the current xterm sink
|
|
234
|
+
|
|
235
|
+
function drillInto(stream) {
|
|
236
|
+
if (disposed || drill?.stream === stream) return;
|
|
237
|
+
drill?.client.close();
|
|
238
|
+
drill = undefined;
|
|
239
|
+
terminal?.dispose?.();
|
|
240
|
+
terminal = undefined;
|
|
241
|
+
try {
|
|
242
|
+
terminalHost.replaceChildren();
|
|
243
|
+
const sink = xtermSink(terminalHost);
|
|
244
|
+
terminal = sink;
|
|
245
|
+
let session;
|
|
246
|
+
const client = new RelayChannelClient({
|
|
247
|
+
connect: connectRelay,
|
|
248
|
+
onRelay: (message) => session?.handle(message),
|
|
249
|
+
onOpen: () => session?.attach(),
|
|
250
|
+
onError,
|
|
251
|
+
});
|
|
252
|
+
session = new TerminalSession({ stream, sink, send: (message) => client.sendRelay(message) });
|
|
253
|
+
client.open();
|
|
254
|
+
drill = { stream, client };
|
|
255
|
+
} catch (err) {
|
|
256
|
+
onError(err);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
async function refresh() {
|
|
261
|
+
if (disposed) return;
|
|
262
|
+
let report;
|
|
263
|
+
try {
|
|
264
|
+
const headers = { accept: "application/json" };
|
|
265
|
+
if (hookSecret) headers["x-hook-secret"] = hookSecret;
|
|
266
|
+
const res = await fetch(reportUrl, { headers });
|
|
267
|
+
if (!res.ok) throw new Error(`supply fetch failed: ${res.status}`);
|
|
268
|
+
report = await res.json();
|
|
269
|
+
} catch (err) {
|
|
270
|
+
onError(err);
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
if (disposed) return;
|
|
274
|
+
try {
|
|
275
|
+
renderSupply(listRegion, doc, supplyView(report, staleAfterMs), drillInto);
|
|
276
|
+
} catch (err) {
|
|
277
|
+
onError(err);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function tick(gen) {
|
|
282
|
+
void refresh().finally(() => {
|
|
283
|
+
if (gen !== generation || !running || disposed) return;
|
|
284
|
+
timer = setTimeout(() => tick(gen), refreshMs);
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function start() {
|
|
289
|
+
if (disposed || running) return;
|
|
290
|
+
running = true;
|
|
291
|
+
tick(++generation);
|
|
292
|
+
}
|
|
293
|
+
function stop() {
|
|
294
|
+
running = false;
|
|
295
|
+
generation++;
|
|
296
|
+
if (timer !== undefined) {
|
|
297
|
+
clearTimeout(timer);
|
|
298
|
+
timer = undefined;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
function dispose() {
|
|
302
|
+
if (disposed) return;
|
|
303
|
+
disposed = true;
|
|
304
|
+
stop();
|
|
305
|
+
drill?.client.close();
|
|
306
|
+
drill = undefined;
|
|
307
|
+
terminal?.dispose?.();
|
|
308
|
+
terminal = undefined;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
start();
|
|
312
|
+
return { start, stop, dispose, refresh, drill: drillInto };
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Derive the channel WebSocket URL from the current origin (path `/agentic`).
|
|
317
|
+
*
|
|
318
|
+
* The agentic hub authenticates upgrades with `sharedSecretAuthenticator({ requireCredential: true })`,
|
|
319
|
+
* so a bare `ws(s)://host/agentic` is rejected (4401/4403). When a `token` (and optional `capability`)
|
|
320
|
+
* are supplied, they are appended as the `?token=…&capability=…` query the hub requires; without them
|
|
321
|
+
* the default URL cannot authenticate and drill-in will be refused — pass credentials (or an explicit
|
|
322
|
+
* `relayUrl`) for secured deployments.
|
|
323
|
+
*/
|
|
324
|
+
function defaultRelayUrl(token, capability) {
|
|
325
|
+
const proto = location.protocol === "https:" ? "wss:" : "ws:";
|
|
326
|
+
const base = `${proto}//${location.host}/agentic`;
|
|
327
|
+
if (!token) return base;
|
|
328
|
+
const query = new URLSearchParams({ token });
|
|
329
|
+
if (capability) query.set("capability", capability);
|
|
330
|
+
return `${base}?${query}`;
|
|
331
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
|
6
|
+
<title>Agent cockpit — supply</title>
|
|
7
|
+
<link rel="stylesheet" href="./cockpit.css" />
|
|
8
|
+
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@xterm/xterm@5/css/xterm.min.css" />
|
|
9
|
+
<style>
|
|
10
|
+
html, body { margin: 0; height: 100%; background: #0b0f14; }
|
|
11
|
+
</style>
|
|
12
|
+
<!--
|
|
13
|
+
Resolve the reusable cockpit ESM (the relay client + resume-from-offset terminal session) and
|
|
14
|
+
xterm.js. Only these correctness-critical primitives are imported from the package; the supply
|
|
15
|
+
projection/render/poll lives inline in ./mount.js (the app has no build step to share its typed
|
|
16
|
+
core with the browser). The SAME ./mount.js the console App-View embed uses is loaded here, so
|
|
17
|
+
the standalone phone view and the embedded console view render identically.
|
|
18
|
+
-->
|
|
19
|
+
<script type="importmap">
|
|
20
|
+
{
|
|
21
|
+
"imports": {
|
|
22
|
+
"@nanobpm/agentic/cockpit": "https://cdn.jsdelivr.net/npm/@nanobpm/agentic@0.1.0/dist/cockpit/index.js",
|
|
23
|
+
"@xterm/xterm": "https://cdn.jsdelivr.net/npm/@xterm/xterm@5/+esm"
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
</script>
|
|
27
|
+
</head>
|
|
28
|
+
<body>
|
|
29
|
+
<main id="cockpit-root"></main>
|
|
30
|
+
<script type="module">
|
|
31
|
+
import { mountCockpit } from "./mount.js";
|
|
32
|
+
|
|
33
|
+
// Standalone: mount into the page body. Endpoints default to the current origin; override via
|
|
34
|
+
// ?report= and ?relay= for a remote app. For a secured deployment, pass the report guard secret
|
|
35
|
+
// via ?secret= (sent as x-hook-secret) and the relay credentials via ?token= and ?capability=.
|
|
36
|
+
const params = new URLSearchParams(location.search);
|
|
37
|
+
mountCockpit(document.getElementById("cockpit-root"), {
|
|
38
|
+
reportUrl: params.get("report") ?? undefined,
|
|
39
|
+
relayUrl: params.get("relay") ?? undefined,
|
|
40
|
+
hookSecret: params.get("secret") ?? undefined,
|
|
41
|
+
relayToken: params.get("token") ?? undefined,
|
|
42
|
+
relayCapability: params.get("capability") ?? undefined,
|
|
43
|
+
});
|
|
44
|
+
</script>
|
|
45
|
+
</body>
|
|
46
|
+
</html>
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schemaVersion": "1.0",
|
|
3
|
+
"title": "Cockpit",
|
|
4
|
+
"nodes": [
|
|
5
|
+
{
|
|
6
|
+
"type": "nav",
|
|
7
|
+
"id": "nav",
|
|
8
|
+
"props": {
|
|
9
|
+
"variant": "bar",
|
|
10
|
+
"title": "Nano Workforce",
|
|
11
|
+
"items": [
|
|
12
|
+
{ "label": "Convergence", "page": "home" },
|
|
13
|
+
{ "label": "Epics", "page": "epic" },
|
|
14
|
+
{ "label": "Cockpit", "page": "cockpit" }
|
|
15
|
+
],
|
|
16
|
+
"sticky": true
|
|
17
|
+
}
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
"type": "text",
|
|
21
|
+
"id": "title",
|
|
22
|
+
"props": {
|
|
23
|
+
"text": "Agent cockpit — live supply",
|
|
24
|
+
"variant": "heading"
|
|
25
|
+
}
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
"type": "text",
|
|
29
|
+
"id": "intro",
|
|
30
|
+
"props": {
|
|
31
|
+
"text": "The live worker/supply view: every connected worker grouped by leaf token, with family, host, current jobs, and liveness — sourced from the agentic presence registry. Drill into a worker to stream its terminal live over the relay; the terminal stays mounted across a list refresh and re-attaches (resume-from-offset) on reconnect. The same view renders embedded here (App View) and standalone on a phone. (The demand×supply matrix, missing-agent-type lights, and the diversity SLO are the enrolment epic's board — not shown here.)",
|
|
32
|
+
"variant": "sub"
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
"type": "appView",
|
|
37
|
+
"id": "cockpit",
|
|
38
|
+
"props": {
|
|
39
|
+
"title": "Live supply",
|
|
40
|
+
"embed": "./cockpit/embed.html",
|
|
41
|
+
"standalone": "./cockpit/standalone.html",
|
|
42
|
+
"fill": true
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
]
|
|
46
|
+
}
|