@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/openapi.yaml CHANGED
@@ -99,6 +99,79 @@ components:
99
99
  type: array
100
100
  items:
101
101
  $ref: "#/components/schemas/ActivePr"
102
+ AgenticSupplyWorker:
103
+ type: object
104
+ description: One connected worker in the supply mirror (H5 cockpit; sourced from the H1 presence registry).
105
+ required:
106
+ - instance
107
+ - identity
108
+ - stream
109
+ - jobKeys
110
+ - live
111
+ - staleMs
112
+ properties:
113
+ instance:
114
+ type: string
115
+ description: The worker instance id.
116
+ identity:
117
+ type: string
118
+ description: The ADR 0028 leaf token the worker registered under.
119
+ stream:
120
+ type: string
121
+ description: The relay stream id to subscribe when drilling into this worker's terminal.
122
+ family:
123
+ type: string
124
+ description: Declared family (enrolment attribute), if any.
125
+ host:
126
+ type: string
127
+ description: Declared host (where the worker runs), if any.
128
+ jobKeys:
129
+ type: array
130
+ description: The jobKeys this worker is currently processing (empty until the H6 correlation seam lands).
131
+ items:
132
+ type: string
133
+ live:
134
+ type: boolean
135
+ description: Whether the worker's channel connection is still open.
136
+ staleMs:
137
+ type: integer
138
+ description: Milliseconds since the last liveness refresh (0 when fresh).
139
+ AgenticSupplyLeaf:
140
+ type: object
141
+ description: The supply for one leaf token — the workers registered under it.
142
+ required:
143
+ - token
144
+ - workers
145
+ properties:
146
+ token:
147
+ type: string
148
+ workers:
149
+ type: array
150
+ items:
151
+ $ref: "#/components/schemas/AgenticSupplyWorker"
152
+ AgenticSupplyReport:
153
+ type: object
154
+ description: The SUPPLY-ONLY visibility report — the live worker list grouped by leaf. No demand-side
155
+ fields (the demand×supply matrix / diversity SLO are deferred to enrolment epic #152).
156
+ required:
157
+ - count
158
+ - workers
159
+ - leaves
160
+ properties:
161
+ count:
162
+ type: integer
163
+ description: The number of connected workers.
164
+ generatedAt:
165
+ type: string
166
+ description: When this snapshot was taken, ISO-8601.
167
+ workers:
168
+ type: array
169
+ items:
170
+ $ref: "#/components/schemas/AgenticSupplyWorker"
171
+ leaves:
172
+ type: array
173
+ items:
174
+ $ref: "#/components/schemas/AgenticSupplyLeaf"
102
175
  VersionInfo:
103
176
  type: object
104
177
  description: The running app's identity (which code is actually live).
@@ -509,6 +582,27 @@ paths:
509
582
  application/json:
510
583
  schema:
511
584
  $ref: "#/components/schemas/ErrorBody"
585
+ /agentic/supply:
586
+ get:
587
+ operationId: getAgenticSupply
588
+ summary: The SUPPLY-ONLY agentic visibility report — the live worker list (family, host, current jobs,
589
+ liveness) grouped by leaf, sourced from the H1 presence registry. Feeds the H5 cockpit page.
590
+ security:
591
+ - hookSecret: []
592
+ - {}
593
+ responses:
594
+ "200":
595
+ description: The supply report.
596
+ content:
597
+ application/json:
598
+ schema:
599
+ $ref: "#/components/schemas/AgenticSupplyReport"
600
+ "401":
601
+ description: Missing/invalid shared secret (only when NANO_PR_WEBHOOK_SECRET is set).
602
+ content:
603
+ application/json:
604
+ schema:
605
+ $ref: "#/components/schemas/ErrorBody"
512
606
  /version:
513
607
  get:
514
608
  operationId: getVersion
@@ -3,35 +3,18 @@
3
3
  import { test } from "node:test";
4
4
  import { assertEquals } from "#test-assert";
5
5
  import type { AppApi } from "@nanobpm/urban";
6
+ import { memBlackboardData } from "../test/blackboardDb.ts";
6
7
  import { noopLog } from "../test/log.ts";
7
8
  import readBlackboard from "./readBlackboard.ts";
8
9
  import appendBlackboard from "./appendBlackboard.ts";
9
10
 
10
- function memApp(): { app: AppApi; stores: Record<string, any[]> } {
11
- const stores: Record<string, any[]> = {};
12
- const seq: Record<string, number> = {};
13
- function tbl(name: string, pk = "id") {
14
- const rows = (stores[name] ??= [] as any[]);
15
- return {
16
- async insert(row: any) {
17
- if (pk === "id") {
18
- const id = (seq[name] = (seq[name] ?? 0) + 1);
19
- rows.push({ id, ...row });
20
- return id;
21
- }
22
- rows.push({ ...row });
23
- return row[pk];
24
- },
25
- async find(where: any = {}) {
26
- return rows.filter((r) => Object.entries(where).every(([k, v]) => r[k] === v));
27
- },
28
- async findOne(where: any = {}) {
29
- return rows.find((r) => Object.entries(where).every(([k, v]) => r[k] === v));
30
- },
31
- };
32
- }
33
- const app = { data: { table: (n: string, pk?: string) => tbl(n, pk) }, log: noopLog() } as any as AppApi;
34
- return { app, stores };
11
+ // The operations bind to `app.data`; back it with a real in-memory SQLite DataLayer (the same
12
+ // harness `app/blackboard.test.ts` uses) so the hook path exercises the shared `BlackboardStore` /
13
+ // `agentic_blackboard` table end-to-end. `db` is exposed for row-count assertions.
14
+ function memApp(): { app: AppApi; db: { all<T>(sql: string, params?: unknown[]): T[] } } {
15
+ const { data, db } = memBlackboardData();
16
+ const app = { data, log: noopLog() } as unknown as AppApi;
17
+ return { app, db };
35
18
  }
36
19
 
37
20
  function req(method: string, query: Record<string, string>) {
@@ -106,14 +89,15 @@ test("POST with a blank body → 400", async () => {
106
89
  });
107
90
 
108
91
  test("POST is idempotent on dedupe_key (retry → 200, not a duplicate)", async () => {
109
- const { app, stores } = memApp();
92
+ const { app, db } = memApp();
110
93
  await seedPlan(app, "o/r#1", "tok");
111
94
  const body = { author_task: "t", body: "claim", dedupe_key: "t:claim:1" };
112
95
  assertEquals((await call(app, "POST", { token: "tok" }, body)).status, 201);
113
96
  const retry = await call(app, "POST", { token: "tok" }, body);
114
97
  assertEquals(retry.status, 200);
115
98
  assertEquals(retry.body.inserted, false);
116
- assertEquals(stores["plan_blackboard"].length, 1);
99
+ const [{ n }] = db.all<{ n: number }>("SELECT COUNT(*) AS n FROM agentic_blackboard WHERE scope = ?", ["o/r#1"]);
100
+ assertEquals(n, 1);
117
101
  });
118
102
 
119
103
  test("GET ?since returns only newer entries", async () => {
@@ -0,0 +1,153 @@
1
+ // Tests for GET /app/api/agentic/supply → operation `getAgenticSupply` (H5 / #148).
2
+ //
3
+ // Covers: the empty report when no presence family is mounted; the shared-secret guard; and the
4
+ // end-to-end mapping of a mounted presence registry's snapshot into the supply report (stream keyed
5
+ // by instance, family/host/jobKeys/liveness) — driven through a REAL AgenticHub + in-memory transport
6
+ // exactly as the presence family is exercised, so the singleton the operation reads is the live one.
7
+ import { DatabaseSync } from "node:sqlite";
8
+ import { test } from "node:test";
9
+ import { AgenticHub } from "@nanobpm/agentic/channel";
10
+ import type { Authenticator, ChannelConnection, ChannelTransport } from "@nanobpm/agentic/channel";
11
+ import { encodeFrame, type Frame } from "@nanobpm/agentic/protocol";
12
+ import type { SqliteDb } from "@nanobpm/agentic/presence";
13
+ import type { AppApi, DataLayer } from "@nanobpm/urban";
14
+ import { assert, assertEquals } from "#test-assert";
15
+ import { family } from "../app/agentic/families/presence.family.ts";
16
+ import type { AgenticContext } from "../app/agentic/registry.ts";
17
+ import { noopLog } from "../test/log.ts";
18
+ import handler from "./getAgenticSupply.ts";
19
+
20
+ function memSqlite(): SqliteDb {
21
+ const db = new DatabaseSync(":memory:");
22
+ return {
23
+ exec: (sql) => db.exec(sql),
24
+ run: (sql, params = []) => {
25
+ const r = db.prepare(sql).run(...(params as never[]));
26
+ return { changes: Number(r.changes), lastInsertRowid: Number(r.lastInsertRowid) };
27
+ },
28
+ all: <T = Record<string, unknown>>(sql: string, params: unknown[] = []) =>
29
+ db.prepare(sql).all(...(params as never[])) as T[],
30
+ };
31
+ }
32
+
33
+ function memData(db: SqliteDb): DataLayer {
34
+ return { source: () => ({ db }) } as unknown as DataLayer;
35
+ }
36
+
37
+ function memTransport(): { transport: ChannelTransport; connect(conn: ChannelConnection): void } {
38
+ let onConnection: ((conn: ChannelConnection) => void) | undefined;
39
+ const transport: ChannelTransport = {
40
+ onConnection: (l) => {
41
+ onConnection = l;
42
+ },
43
+ address: { port: 0 },
44
+ close: async () => {},
45
+ };
46
+ return { transport, connect: (conn) => onConnection?.(conn) };
47
+ }
48
+
49
+ function fakeConn(id: string, identity: string): { conn: ChannelConnection; feed(frame: Frame): void } {
50
+ let onMessage: ((bytes: Uint8Array) => void) | undefined;
51
+ const conn: ChannelConnection = {
52
+ id,
53
+ handshake: { query: { identity }, token: "t", credential: "c" },
54
+ send: () => {},
55
+ close: () => {},
56
+ onMessage: (l) => {
57
+ onMessage = l;
58
+ },
59
+ onClose: () => {},
60
+ };
61
+ return { conn, feed: (frame) => onMessage?.(encodeFrame(frame)) };
62
+ }
63
+
64
+ const authenticator: Authenticator = (req) => ({ ok: true, grant: { identity: req.query?.identity ?? "anon" } });
65
+ const flush = () => new Promise((resolve) => setImmediate(resolve));
66
+
67
+ async function mountPresence(db: SqliteDb): Promise<AgenticHub> {
68
+ const transport = memTransport();
69
+ const hub = new AgenticHub({ transport: transport.transport, authenticator, sweepIntervalMs: 0 });
70
+ const ctx: AgenticContext = {
71
+ hub,
72
+ registry: hub.registry,
73
+ transport: transport.transport as never,
74
+ data: memData(db),
75
+ log: noopLog(),
76
+ };
77
+ await family.mount(ctx);
78
+ // Register one live worker under leaf "leafA" with declared family + host.
79
+ const peer = fakeConn("c1", "leafA");
80
+ transport.connect(peer.conn);
81
+ await flush();
82
+ peer.feed({ lane: "control", family: "register", seq: 1, payload: { instance: "wk-a", capability: { family: "opus", host: "boxA" } } });
83
+ await flush();
84
+ return hub;
85
+ }
86
+
87
+ function input(headers: Record<string, string> = {}) {
88
+ return {
89
+ req: {
90
+ method: "GET",
91
+ path: "/app/api/agentic/supply",
92
+ query: new URLSearchParams(),
93
+ headers: new Headers(headers),
94
+ text: async () => "",
95
+ } as never,
96
+ params: {},
97
+ query: {},
98
+ body: undefined,
99
+ };
100
+ }
101
+
102
+ const app = { log: noopLog() } as unknown as AppApi;
103
+
104
+ test("returns an empty supply report when no presence family is mounted", async () => {
105
+ family.teardown?.();
106
+ const res = (await handler(input(), app)) as { status: number; body: { count: number; workers: unknown[]; leaves: unknown[] } };
107
+ assertEquals(res.status, 200);
108
+ assertEquals(res.body.count, 0);
109
+ assertEquals(res.body.workers.length, 0);
110
+ assertEquals(res.body.leaves.length, 0);
111
+ });
112
+
113
+ test("maps the presence snapshot into the supply report (stream, family, host, liveness)", async () => {
114
+ const hub = await mountPresence(memSqlite());
115
+ try {
116
+ const res = (await handler(input(), app)) as {
117
+ status: number;
118
+ body: { count: number; workers: Array<Record<string, unknown>>; leaves: Array<{ token: string; workers: unknown[] }> };
119
+ };
120
+ assertEquals(res.status, 200);
121
+ assertEquals(res.body.count, 1);
122
+ const w = res.body.workers[0];
123
+ assertEquals(w.instance, "wk-a");
124
+ assertEquals(w.identity, "leafA");
125
+ assertEquals(w.stream, "wk-a", "the drill stream is keyed by the worker instance");
126
+ assertEquals(w.family, "opus");
127
+ assertEquals(w.host, "boxA");
128
+ assertEquals(w.live, true);
129
+ assertEquals(w.jobKeys, []);
130
+ assertEquals(res.body.leaves[0]?.token, "leafA");
131
+ assertEquals(res.body.leaves[0]?.workers.length, 1);
132
+ } finally {
133
+ family.teardown?.();
134
+ await hub.close();
135
+ }
136
+ });
137
+
138
+ test("shared-secret guard rejects a missing secret when configured", async () => {
139
+ const prev = process.env["NANO_PR_WEBHOOK_SECRET"];
140
+ process.env["NANO_PR_WEBHOOK_SECRET"] = "s3cr3t";
141
+ try {
142
+ const mod = await import(`./getAgenticSupply.ts?guard=${Date.now()}`);
143
+ const guarded = mod.default as typeof handler;
144
+ const bad = (await guarded(input(), app)) as { status: number };
145
+ assertEquals(bad.status, 401);
146
+ const ok = (await guarded(input({ "x-hook-secret": "s3cr3t" }), app)) as { status: number; body: Record<string, unknown> };
147
+ assertEquals(ok.status, 200);
148
+ assert("count" in ok.body);
149
+ } finally {
150
+ if (prev === undefined) delete process.env["NANO_PR_WEBHOOK_SECRET"];
151
+ else process.env["NANO_PR_WEBHOOK_SECRET"] = prev;
152
+ }
153
+ });
@@ -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.53.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>