@nanobpm/agentic 0.1.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.
Files changed (228) hide show
  1. package/README.md +22 -0
  2. package/dist/blackboard/family.d.ts +40 -0
  3. package/dist/blackboard/family.js +151 -0
  4. package/dist/blackboard/index.d.ts +21 -0
  5. package/dist/blackboard/index.js +19 -0
  6. package/dist/blackboard/schema.d.ts +30 -0
  7. package/dist/blackboard/schema.js +42 -0
  8. package/dist/blackboard/store.d.ts +138 -0
  9. package/dist/blackboard/store.js +216 -0
  10. package/dist/blackboard/test-db.d.ts +5 -0
  11. package/dist/blackboard/test-db.js +42 -0
  12. package/dist/channel/auth.d.ts +41 -0
  13. package/dist/channel/auth.js +67 -0
  14. package/dist/channel/clock.d.ts +11 -0
  15. package/dist/channel/clock.js +4 -0
  16. package/dist/channel/connection.d.ts +75 -0
  17. package/dist/channel/connection.js +14 -0
  18. package/dist/channel/dispatch.d.ts +46 -0
  19. package/dist/channel/dispatch.js +86 -0
  20. package/dist/channel/hub.d.ts +78 -0
  21. package/dist/channel/hub.js +157 -0
  22. package/dist/channel/index.d.ts +27 -0
  23. package/dist/channel/index.js +20 -0
  24. package/dist/channel/registry.d.ts +68 -0
  25. package/dist/channel/registry.js +84 -0
  26. package/dist/channel/ws-transport.d.ts +23 -0
  27. package/dist/channel/ws-transport.js +178 -0
  28. package/dist/cockpit/boot.d.ts +68 -0
  29. package/dist/cockpit/boot.js +202 -0
  30. package/dist/cockpit/fake-dom.d.ts +37 -0
  31. package/dist/cockpit/fake-dom.js +73 -0
  32. package/dist/cockpit/index.d.ts +27 -0
  33. package/dist/cockpit/index.js +27 -0
  34. package/dist/cockpit/relay-client.d.ts +52 -0
  35. package/dist/cockpit/relay-client.js +192 -0
  36. package/dist/cockpit/render.d.ts +58 -0
  37. package/dist/cockpit/render.js +122 -0
  38. package/dist/cockpit/terminal-session.d.ts +95 -0
  39. package/dist/cockpit/terminal-session.js +123 -0
  40. package/dist/cockpit/view.d.ts +79 -0
  41. package/dist/cockpit/view.js +58 -0
  42. package/dist/demand/c8-rest.d.ts +77 -0
  43. package/dist/demand/c8-rest.js +123 -0
  44. package/dist/demand/index.d.ts +24 -0
  45. package/dist/demand/index.js +24 -0
  46. package/dist/demand/model.d.ts +68 -0
  47. package/dist/demand/model.js +118 -0
  48. package/dist/demand/taskdef.d.ts +40 -0
  49. package/dist/demand/taskdef.js +67 -0
  50. package/dist/index.d.ts +17 -0
  51. package/dist/index.js +17 -0
  52. package/dist/presence/family.d.ts +40 -0
  53. package/dist/presence/family.js +166 -0
  54. package/dist/presence/index.d.ts +19 -0
  55. package/dist/presence/index.js +17 -0
  56. package/dist/presence/schema.d.ts +20 -0
  57. package/dist/presence/schema.js +32 -0
  58. package/dist/presence/store.d.ts +130 -0
  59. package/dist/presence/store.js +191 -0
  60. package/dist/presence/test-db.d.ts +5 -0
  61. package/dist/presence/test-db.js +42 -0
  62. package/dist/protocol/conformance/frames.d.ts +24 -0
  63. package/dist/protocol/conformance/frames.js +116 -0
  64. package/dist/protocol/conformance/index.d.ts +13 -0
  65. package/dist/protocol/conformance/index.js +13 -0
  66. package/dist/protocol/conformance/malformed.d.ts +14 -0
  67. package/dist/protocol/conformance/malformed.js +44 -0
  68. package/dist/protocol/conformance/tokens.d.ts +19 -0
  69. package/dist/protocol/conformance/tokens.js +49 -0
  70. package/dist/protocol/conformance/vocab.d.ts +23 -0
  71. package/dist/protocol/conformance/vocab.js +97 -0
  72. package/dist/protocol/families.d.ts +32 -0
  73. package/dist/protocol/families.js +45 -0
  74. package/dist/protocol/frame.d.ts +45 -0
  75. package/dist/protocol/frame.js +114 -0
  76. package/dist/protocol/hex.d.ts +7 -0
  77. package/dist/protocol/hex.js +26 -0
  78. package/dist/protocol/index.d.ts +23 -0
  79. package/dist/protocol/index.js +23 -0
  80. package/dist/protocol/lanes.d.ts +36 -0
  81. package/dist/protocol/lanes.js +40 -0
  82. package/dist/protocol/payloads.d.ts +64 -0
  83. package/dist/protocol/payloads.js +122 -0
  84. package/dist/protocol/token.d.ts +34 -0
  85. package/dist/protocol/token.js +81 -0
  86. package/dist/protocol/vocab/schema.d.ts +51 -0
  87. package/dist/protocol/vocab/schema.js +218 -0
  88. package/dist/relay/incarnation.d.ts +17 -0
  89. package/dist/relay/incarnation.js +50 -0
  90. package/dist/relay/index.d.ts +25 -0
  91. package/dist/relay/index.js +22 -0
  92. package/dist/relay/relay-family.d.ts +68 -0
  93. package/dist/relay/relay-family.js +272 -0
  94. package/dist/relay/ring.d.ts +49 -0
  95. package/dist/relay/ring.js +105 -0
  96. package/dist/relay/scheduler.d.ts +72 -0
  97. package/dist/relay/scheduler.js +180 -0
  98. package/dist/relay/validate.d.ts +29 -0
  99. package/dist/relay/validate.js +39 -0
  100. package/dist/transcript/index.d.ts +18 -0
  101. package/dist/transcript/index.js +17 -0
  102. package/dist/transcript/schema.d.ts +32 -0
  103. package/dist/transcript/schema.js +48 -0
  104. package/dist/transcript/store.d.ts +192 -0
  105. package/dist/transcript/store.js +347 -0
  106. package/dist/transcript/test-db.d.ts +5 -0
  107. package/dist/transcript/test-db.js +41 -0
  108. package/dist/vocab/core-vocab.d.ts +26 -0
  109. package/dist/vocab/core-vocab.js +67 -0
  110. package/dist/vocab/diversity.d.ts +78 -0
  111. package/dist/vocab/diversity.js +89 -0
  112. package/dist/vocab/index.d.ts +22 -0
  113. package/dist/vocab/index.js +22 -0
  114. package/dist/vocab/merge.d.ts +9 -0
  115. package/dist/vocab/merge.js +104 -0
  116. package/dist/vocab/requires.d.ts +49 -0
  117. package/dist/vocab/requires.js +107 -0
  118. package/dist/vocab/resolver.d.ts +62 -0
  119. package/dist/vocab/resolver.js +149 -0
  120. package/dist/vocab/serve.d.ts +39 -0
  121. package/dist/vocab/serve.js +36 -0
  122. package/package.json +108 -0
  123. package/page/cockpit.css +114 -0
  124. package/page/cockpit.page.json +33 -0
  125. package/page/embed.html +40 -0
  126. package/page/mount.js +78 -0
  127. package/page/standalone.html +43 -0
  128. package/src/blackboard/family.test.ts +280 -0
  129. package/src/blackboard/family.ts +208 -0
  130. package/src/blackboard/index.ts +42 -0
  131. package/src/blackboard/schema.test.ts +60 -0
  132. package/src/blackboard/schema.ts +44 -0
  133. package/src/blackboard/store.test.ts +189 -0
  134. package/src/blackboard/store.ts +331 -0
  135. package/src/blackboard/test-db.ts +47 -0
  136. package/src/channel/auth.test.ts +64 -0
  137. package/src/channel/auth.ts +101 -0
  138. package/src/channel/clock.ts +14 -0
  139. package/src/channel/connection.ts +77 -0
  140. package/src/channel/dispatch.test.ts +83 -0
  141. package/src/channel/dispatch.ts +102 -0
  142. package/src/channel/hub.test.ts +335 -0
  143. package/src/channel/hub.ts +222 -0
  144. package/src/channel/index.ts +55 -0
  145. package/src/channel/registry.test.ts +73 -0
  146. package/src/channel/registry.ts +137 -0
  147. package/src/channel/ws-transport.test.ts +234 -0
  148. package/src/channel/ws-transport.ts +212 -0
  149. package/src/cockpit/boot.test.ts +374 -0
  150. package/src/cockpit/boot.ts +280 -0
  151. package/src/cockpit/fake-dom.ts +90 -0
  152. package/src/cockpit/index.ts +63 -0
  153. package/src/cockpit/relay-client.test.ts +359 -0
  154. package/src/cockpit/relay-client.ts +234 -0
  155. package/src/cockpit/render.test.ts +149 -0
  156. package/src/cockpit/render.ts +194 -0
  157. package/src/cockpit/terminal-session.test.ts +252 -0
  158. package/src/cockpit/terminal-session.ts +194 -0
  159. package/src/cockpit/view.test.ts +117 -0
  160. package/src/cockpit/view.ts +140 -0
  161. package/src/demand/c8-rest.test.ts +140 -0
  162. package/src/demand/c8-rest.ts +167 -0
  163. package/src/demand/index.ts +42 -0
  164. package/src/demand/model.test.ts +197 -0
  165. package/src/demand/model.ts +183 -0
  166. package/src/demand/taskdef.test.ts +85 -0
  167. package/src/demand/taskdef.ts +78 -0
  168. package/src/index.ts +17 -0
  169. package/src/presence/family.test.ts +252 -0
  170. package/src/presence/family.ts +205 -0
  171. package/src/presence/index.ts +26 -0
  172. package/src/presence/schema.test.ts +53 -0
  173. package/src/presence/schema.ts +34 -0
  174. package/src/presence/store.test.ts +190 -0
  175. package/src/presence/store.ts +287 -0
  176. package/src/presence/test-db.test.ts +57 -0
  177. package/src/presence/test-db.ts +47 -0
  178. package/src/protocol/conformance/corpus.test.ts +66 -0
  179. package/src/protocol/conformance/frames.ts +142 -0
  180. package/src/protocol/conformance/index.ts +29 -0
  181. package/src/protocol/conformance/malformed.ts +59 -0
  182. package/src/protocol/conformance/tokens.ts +70 -0
  183. package/src/protocol/conformance/vocab.ts +122 -0
  184. package/src/protocol/families.ts +54 -0
  185. package/src/protocol/frame.test.ts +116 -0
  186. package/src/protocol/frame.ts +171 -0
  187. package/src/protocol/hex.ts +28 -0
  188. package/src/protocol/index.ts +84 -0
  189. package/src/protocol/lanes.test.ts +82 -0
  190. package/src/protocol/lanes.ts +54 -0
  191. package/src/protocol/payloads.test.ts +91 -0
  192. package/src/protocol/payloads.ts +201 -0
  193. package/src/protocol/token.test.ts +57 -0
  194. package/src/protocol/token.ts +123 -0
  195. package/src/protocol/vocab/schema.test.ts +67 -0
  196. package/src/protocol/vocab/schema.ts +281 -0
  197. package/src/relay/incarnation.test.ts +53 -0
  198. package/src/relay/incarnation.ts +54 -0
  199. package/src/relay/index.ts +34 -0
  200. package/src/relay/integration.test.ts +135 -0
  201. package/src/relay/relay-family.test.ts +236 -0
  202. package/src/relay/relay-family.ts +336 -0
  203. package/src/relay/ring.test.ts +138 -0
  204. package/src/relay/ring.ts +136 -0
  205. package/src/relay/scheduler.test.ts +233 -0
  206. package/src/relay/scheduler.ts +208 -0
  207. package/src/relay/validate.test.ts +43 -0
  208. package/src/relay/validate.ts +44 -0
  209. package/src/transcript/index.ts +33 -0
  210. package/src/transcript/integration.test.ts +108 -0
  211. package/src/transcript/schema.test.ts +69 -0
  212. package/src/transcript/schema.ts +51 -0
  213. package/src/transcript/store.test.ts +285 -0
  214. package/src/transcript/store.ts +530 -0
  215. package/src/transcript/test-db.ts +46 -0
  216. package/src/vocab/core-vocab.test.ts +34 -0
  217. package/src/vocab/core-vocab.ts +88 -0
  218. package/src/vocab/diversity.test.ts +153 -0
  219. package/src/vocab/diversity.ts +169 -0
  220. package/src/vocab/index.ts +55 -0
  221. package/src/vocab/merge.test.ts +73 -0
  222. package/src/vocab/merge.ts +117 -0
  223. package/src/vocab/requires.test.ts +69 -0
  224. package/src/vocab/requires.ts +155 -0
  225. package/src/vocab/resolver.test.ts +118 -0
  226. package/src/vocab/resolver.ts +187 -0
  227. package/src/vocab/serve.test.ts +64 -0
  228. package/src/vocab/serve.ts +66 -0
@@ -0,0 +1,192 @@
1
+ /**
2
+ * The browser-side relay channel client — S8.
3
+ *
4
+ * Ties one relay stream's {@link TerminalSession} to a live WebSocket-style
5
+ * connection: it encodes the session's outbound relay messages into S0 frames
6
+ * ({@link encodeFrame}) on the control lane, decodes inbound frames
7
+ * ({@link decodeFrame}) and routes `relay`-family payloads back to the session,
8
+ * and — critically for "survives a cockpit reconnect" — re-opens the socket when
9
+ * it drops and fires {@link RelayChannelClientOptions.onOpen} on **every**
10
+ * (re)connect, so the caller re-attaches the session and resumes from its last
11
+ * offset.
12
+ *
13
+ * It is transport- and timer-injected: the socket comes from a
14
+ * {@link SocketFactory} and reconnect scheduling from an injected
15
+ * {@link Scheduler}, so the whole reconnect→resume path is exercised
16
+ * deterministically in tests with a fake socket and a manual scheduler — no real
17
+ * timers, no real network (AGENTS.md: no flaky tests, no test retries).
18
+ */
19
+ import { isNonNegInt, RELAY_FAMILY } from "../relay/index.js";
20
+ import { decodeFrame, encodeFrame, MAX_SEQ } from "../protocol/index.js";
21
+ const CONTROL_LANE = "control";
22
+ function defaultSchedule(run) {
23
+ setTimeout(run, 0);
24
+ }
25
+ function isRecord(value) {
26
+ return typeof value === "object" && value !== null && !Array.isArray(value);
27
+ }
28
+ /**
29
+ * Narrow a decoded `relay`-family payload to the inbound sub-protocol without an
30
+ * unchecked cast (the repo bans `as`): a data chunk `{ stream, offset, chunk }`
31
+ * or a resume ack `{ op: "subscribed", stream, gap, nextOffset }`. Any payload
32
+ * carrying an `op` field is treated as an op-message and rejected unless it is a
33
+ * well-formed known op, so a malformed/unknown op can never fall through and be
34
+ * mistaken for a data chunk.
35
+ */
36
+ function asRelayInbound(payload) {
37
+ if (!isRecord(payload))
38
+ return null;
39
+ if ("op" in payload) {
40
+ if (payload.op === "subscribed" &&
41
+ typeof payload.stream === "string" &&
42
+ typeof payload.gap === "boolean" &&
43
+ isNonNegInt(payload.nextOffset)) {
44
+ return { op: "subscribed", stream: payload.stream, gap: payload.gap, nextOffset: payload.nextOffset };
45
+ }
46
+ return null;
47
+ }
48
+ if (typeof payload.stream === "string" && isNonNegInt(payload.offset) && typeof payload.chunk === "string") {
49
+ const data = { stream: payload.stream, offset: payload.offset, chunk: payload.chunk };
50
+ return data;
51
+ }
52
+ return null;
53
+ }
54
+ /**
55
+ * Manages one relay socket with automatic resume-on-reconnect. Construct once
56
+ * per drill-in and pair with a {@link TerminalSession}: wire the session's
57
+ * `send` to {@link sendRelay}, feed {@link RelayChannelClientOptions.onRelay} to
58
+ * `session.handle`, and call `session.attach()` from `onOpen`.
59
+ */
60
+ export class RelayChannelClient {
61
+ #connect;
62
+ #onRelay;
63
+ #onOpen;
64
+ #onClose;
65
+ #onError;
66
+ #schedule;
67
+ #autoReconnect;
68
+ #socket;
69
+ #seq = 0;
70
+ #closed = false;
71
+ constructor(options) {
72
+ this.#connect = options.connect;
73
+ this.#onRelay = options.onRelay;
74
+ this.#onOpen = options.onOpen;
75
+ this.#onClose = options.onClose;
76
+ this.#onError = options.onError;
77
+ this.#schedule = options.schedule ?? defaultSchedule;
78
+ this.#autoReconnect = options.autoReconnect ?? true;
79
+ }
80
+ /** True once {@link close} has been called (no further reconnects). */
81
+ get isClosed() {
82
+ return this.#closed;
83
+ }
84
+ /** Open the first socket and wire its lifecycle. Idempotent while connected. */
85
+ open() {
86
+ if (this.#closed || this.#socket !== undefined)
87
+ return;
88
+ // Guard the injected factory: connect() can throw synchronously (e.g. the
89
+ // browser adapter's WebSocket constructor on an invalid URL / CSP block).
90
+ // An unguarded throw here escapes open()/#reconnect() and kills the
91
+ // reconnect→resume loop with no error surfaced. Route it to onError and, if
92
+ // autoReconnect is enabled, schedule a retry so the loop survives a
93
+ // transient connect failure — the same way #handleClose recovers a drop.
94
+ let socket;
95
+ try {
96
+ socket = this.#connect();
97
+ }
98
+ catch (err) {
99
+ this.#onError?.(err);
100
+ if (!this.#closed && this.#autoReconnect)
101
+ this.#schedule(() => this.#reconnect());
102
+ return;
103
+ }
104
+ this.#socket = socket;
105
+ socket.onMessage((bytes) => this.#receive(bytes));
106
+ // Guard the consumer callback the same way #receive() does: a consumer
107
+ // onOpen handler can throw, and an unguarded throw escapes the socket open
108
+ // callback. Route it to onError so a bad handler can't wedge the client.
109
+ socket.onOpen(() => {
110
+ try {
111
+ this.#onOpen?.();
112
+ }
113
+ catch (err) {
114
+ this.#onError?.(err);
115
+ }
116
+ });
117
+ socket.onClose(() => this.#handleClose());
118
+ }
119
+ /** Encode and send one outbound relay message on the control lane. */
120
+ sendRelay(message) {
121
+ const socket = this.#socket;
122
+ if (socket === undefined)
123
+ return;
124
+ const frame = { lane: CONTROL_LANE, family: RELAY_FAMILY, seq: this.#nextSeq(), payload: message };
125
+ try {
126
+ socket.send(encodeFrame(frame));
127
+ }
128
+ catch (err) {
129
+ this.#onError?.(err);
130
+ }
131
+ }
132
+ /** Close for good — no reconnect will follow. */
133
+ close() {
134
+ this.#closed = true;
135
+ const socket = this.#socket;
136
+ this.#socket = undefined;
137
+ socket?.close();
138
+ }
139
+ #nextSeq() {
140
+ const seq = this.#seq;
141
+ this.#seq = this.#seq >= MAX_SEQ ? 0 : this.#seq + 1;
142
+ return seq;
143
+ }
144
+ #receive(bytes) {
145
+ let frame;
146
+ try {
147
+ frame = decodeFrame(bytes);
148
+ }
149
+ catch (err) {
150
+ this.#onError?.(err);
151
+ return;
152
+ }
153
+ if (frame.family !== RELAY_FAMILY)
154
+ return;
155
+ const message = asRelayInbound(frame.payload);
156
+ if (message === null) {
157
+ this.#onError?.(new Error("malformed relay payload"));
158
+ return;
159
+ }
160
+ // Guard the consumer the same way decode/malformed errors are guarded: a
161
+ // handler (e.g. TerminalSession.handle) can throw on an unsafe offset via
162
+ // addSafeInt, and an unguarded throw here escapes the socket message
163
+ // callback and can wedge the relay client / page. Route it to onError.
164
+ try {
165
+ this.#onRelay(message);
166
+ }
167
+ catch (err) {
168
+ this.#onError?.(err);
169
+ }
170
+ }
171
+ #handleClose() {
172
+ this.#socket = undefined;
173
+ // Guard the consumer onClose: if it throws, an unguarded exception here
174
+ // skips the reconnect scheduling below and breaks the "survives reconnect"
175
+ // invariant for reasons unrelated to transport. Route it to onError, then
176
+ // continue with reconnect regardless.
177
+ try {
178
+ this.#onClose?.();
179
+ }
180
+ catch (err) {
181
+ this.#onError?.(err);
182
+ }
183
+ if (this.#closed || !this.#autoReconnect)
184
+ return;
185
+ this.#schedule(() => this.#reconnect());
186
+ }
187
+ #reconnect() {
188
+ if (this.#closed || this.#socket !== undefined)
189
+ return;
190
+ this.open();
191
+ }
192
+ }
@@ -0,0 +1,58 @@
1
+ /**
2
+ * The cockpit DOM renderer — S8.
3
+ *
4
+ * Renders a {@link CockpitView} into a host element: the demand×supply networks
5
+ * matrix, a red light per *missing agent type*, and the diversity-SLO light.
6
+ * Clicking a worker instance calls {@link RenderOptions.onDrill} with that
7
+ * instance's relay stream id. This renders only the *volatile* part of the page
8
+ * (the part that refreshes each poll); the drill-in terminal is owned by
9
+ * {@link mountCockpit} in a persistent region so it survives a matrix refresh.
10
+ *
11
+ * It renders against a **structural** DOM subset ({@link ElementLike} /
12
+ * {@link DocumentLike}) rather than the global `document`, for two reasons that
13
+ * matter to this slice:
14
+ * 1. it is the *same* function the standalone shell and the embedded (App View)
15
+ * host both call — via the plain-JS `page/mount.js` adapter, which passes the
16
+ * browser's real `document` and their own host element — so the two render
17
+ * **identically** by construction (one code path, no standalone/embedded
18
+ * branch); and
19
+ * 2. a plain in-memory fake satisfies the structural type, so the renderer is
20
+ * unit-tested on Node with no DOM library and no `as` cast.
21
+ *
22
+ * These interfaces are a deliberately *minimal* subset, NOT lib.dom's `Element`/
23
+ * `Document`: the real DOM satisfies them at runtime (as `page/mount.js` — plain
24
+ * JS, untyped — relies on), but a real `HTMLElement` is not TS-assignable to
25
+ * {@link ElementLike} (lib.dom's `appendChild` is `Node`-constrained). Keeping the
26
+ * subset this narrow — rather than widening to lib.dom, which the in-memory fake
27
+ * could not satisfy without a banned `as` cast — is the tradeoff that buys the
28
+ * DOM-free Node tests. A TypeScript browser caller wanting to invoke
29
+ * {@link bootCockpit} directly supplies a thin structural adapter over the real DOM.
30
+ */
31
+ import type { CockpitView } from "./view.ts";
32
+ /** The minimal element surface the renderer builds against (the in-memory fake, and the real DOM at runtime, satisfy it — see file header). */
33
+ export interface ElementLike {
34
+ className: string;
35
+ textContent: string | null;
36
+ setAttribute(name: string, value: string): void;
37
+ appendChild(child: ElementLike): ElementLike;
38
+ replaceChildren(): void;
39
+ addEventListener(type: string, handler: () => void): void;
40
+ }
41
+ /** The minimal document surface the renderer builds against (the in-memory fake, and the real DOM at runtime, satisfy it — see file header). */
42
+ export interface DocumentLike {
43
+ createElement(tagName: string): ElementLike;
44
+ }
45
+ export interface RenderOptions {
46
+ /** Called with a worker instance's relay stream id when the operator drills in. */
47
+ readonly onDrill?: (stream: string) => void;
48
+ }
49
+ /** Handles into the rendered tree the caller may need. */
50
+ export interface CockpitDom {
51
+ /** The freshly built root the view was rendered into. */
52
+ readonly root: ElementLike;
53
+ }
54
+ /**
55
+ * Render `view` into `host`, replacing whatever was there. Idempotent: call it
56
+ * again on every refresh to reflect the latest demand×supply snapshot.
57
+ */
58
+ export declare function renderCockpit(host: ElementLike, doc: DocumentLike, view: CockpitView, options?: RenderOptions): CockpitDom;
@@ -0,0 +1,122 @@
1
+ function el(doc, tag, className, text) {
2
+ const node = doc.createElement(tag);
3
+ if (className !== undefined)
4
+ node.className = className;
5
+ if (text !== undefined)
6
+ node.textContent = text;
7
+ return node;
8
+ }
9
+ function lightNode(doc, light) {
10
+ const row = el(doc, "div", "cockpit-light");
11
+ row.setAttribute("data-status", light.status);
12
+ row.setAttribute("data-light-id", light.id);
13
+ const dot = el(doc, "span", "cockpit-dot");
14
+ dot.setAttribute("data-status", light.status);
15
+ row.appendChild(dot);
16
+ row.appendChild(el(doc, "span", "cockpit-light-label", light.label));
17
+ if (light.detail !== undefined) {
18
+ const detail = el(doc, "span", "cockpit-light-detail", light.detail);
19
+ detail.setAttribute("title", light.detail);
20
+ row.appendChild(detail);
21
+ }
22
+ return row;
23
+ }
24
+ function statusBadge(doc, id, label, status) {
25
+ const badge = el(doc, "div", "cockpit-status", `${label}: ${status}`);
26
+ badge.setAttribute("data-status", status);
27
+ badge.setAttribute("data-badge-id", id);
28
+ return badge;
29
+ }
30
+ function tokenCell(doc, network, options) {
31
+ const table = el(doc, "table", "cockpit-tokens");
32
+ const thead = el(doc, "thead", "cockpit-tokens-thead");
33
+ const head = el(doc, "tr", "cockpit-tokens-head");
34
+ head.appendChild(el(doc, "th", "cockpit-th", "token"));
35
+ head.appendChild(el(doc, "th", "cockpit-th", "supply"));
36
+ head.appendChild(el(doc, "th", "cockpit-th", "workers"));
37
+ thead.appendChild(head);
38
+ table.appendChild(thead);
39
+ const tbody = el(doc, "tbody", "cockpit-tokens-tbody");
40
+ for (const token of network.tokens) {
41
+ const row = el(doc, "tr", "cockpit-token");
42
+ row.setAttribute("data-status", token.status);
43
+ row.setAttribute("data-token", token.token);
44
+ const dot = el(doc, "td", "cockpit-td");
45
+ const light = el(doc, "span", "cockpit-dot");
46
+ light.setAttribute("data-status", token.status);
47
+ dot.appendChild(light);
48
+ dot.appendChild(el(doc, "span", "cockpit-token-name", token.token));
49
+ row.appendChild(dot);
50
+ row.appendChild(el(doc, "td", "cockpit-td", String(token.supply)));
51
+ const workers = el(doc, "td", "cockpit-td cockpit-workers");
52
+ for (const instance of token.instances) {
53
+ const chip = el(doc, "button", "cockpit-worker", instance);
54
+ chip.setAttribute("data-stream", instance);
55
+ chip.setAttribute("type", "button");
56
+ const onDrill = options.onDrill;
57
+ if (onDrill !== undefined) {
58
+ chip.addEventListener("click", () => onDrill(instance));
59
+ }
60
+ workers.appendChild(chip);
61
+ }
62
+ row.appendChild(workers);
63
+ tbody.appendChild(row);
64
+ }
65
+ table.appendChild(tbody);
66
+ return table;
67
+ }
68
+ function networkSection(doc, network, options) {
69
+ const section = el(doc, "section", "cockpit-network");
70
+ section.setAttribute("data-status", network.status);
71
+ section.setAttribute("data-network", network.network);
72
+ const header = el(doc, "div", "cockpit-network-head");
73
+ const dot = el(doc, "span", "cockpit-dot");
74
+ dot.setAttribute("data-status", network.status);
75
+ header.appendChild(dot);
76
+ header.appendChild(el(doc, "span", "cockpit-network-name", network.network));
77
+ if (network.missing.length > 0) {
78
+ header.appendChild(el(doc, "span", "cockpit-network-missing", `missing: ${network.missing.join(", ")}`));
79
+ }
80
+ section.appendChild(header);
81
+ section.appendChild(tokenCell(doc, network, options));
82
+ return section;
83
+ }
84
+ function missingPanel(doc, view) {
85
+ const panel = el(doc, "aside", "cockpit-missing");
86
+ panel.setAttribute("data-empty", view.missingLights.length === 0 ? "true" : "false");
87
+ panel.appendChild(el(doc, "h2", "cockpit-panel-title", "Missing agent types"));
88
+ if (view.missingLights.length === 0) {
89
+ panel.appendChild(el(doc, "div", "cockpit-missing-none", "none — every demanded token is served"));
90
+ return panel;
91
+ }
92
+ for (const light of view.missingLights) {
93
+ panel.appendChild(lightNode(doc, light));
94
+ }
95
+ return panel;
96
+ }
97
+ /**
98
+ * Render `view` into `host`, replacing whatever was there. Idempotent: call it
99
+ * again on every refresh to reflect the latest demand×supply snapshot.
100
+ */
101
+ export function renderCockpit(host, doc, view, options = {}) {
102
+ host.replaceChildren();
103
+ const root = el(doc, "div", "cockpit");
104
+ root.setAttribute("data-status", view.status);
105
+ const header = el(doc, "header", "cockpit-header");
106
+ header.appendChild(el(doc, "h1", "cockpit-title", "Agent networks — the cockpit"));
107
+ header.appendChild(statusBadge(doc, "overall", "overall", view.status));
108
+ header.appendChild(lightNode(doc, view.diversityLight));
109
+ root.appendChild(header);
110
+ root.appendChild(missingPanel(doc, view));
111
+ const matrix = el(doc, "div", "cockpit-matrix");
112
+ for (const network of view.networks) {
113
+ matrix.appendChild(networkSection(doc, network, options));
114
+ }
115
+ root.appendChild(matrix);
116
+ if (view.nonAgentic.length > 0) {
117
+ const footer = el(doc, "footer", "cockpit-nonagentic", `non-agentic task types: ${view.nonAgentic.join(", ")}`);
118
+ root.appendChild(footer);
119
+ }
120
+ host.appendChild(root);
121
+ return { root };
122
+ }
@@ -0,0 +1,95 @@
1
+ /**
2
+ * The worker terminal session — S8's drill-into-a-worker live terminal.
3
+ *
4
+ * A consumer of the S5 relay sub-protocol that **survives a cockpit reconnect**
5
+ * via resume-from-offset. It is deliberately timer-free and transport-free: it
6
+ * emits the relay messages to send ({@link RelaySend}) and consumes the relay
7
+ * messages received ({@link TerminalSession.handle}), tracking exactly one piece
8
+ * of durable state — `nextOffset`, the offset to resume from. On any (re)attach
9
+ * it re-subscribes from `nextOffset`, and it drops any replayed chunk it has
10
+ * already applied, so a reconnect neither loses nor double-writes output (within
11
+ * the relay ring's retained window). If the hub's `subscribed` ack reports a
12
+ * `nextOffset` (stream head) *below* our resume point — a hub restart/reset left
13
+ * us ahead of the stream — it clamps `nextOffset` back down so fresh chunks are
14
+ * not dropped as stale.
15
+ *
16
+ * The S5 relay wire (see `@nanobpm/agentic-relay`):
17
+ * - outbound `{ op: "subscribe", stream, from, credit }` — (re)attach and resume,
18
+ * - outbound `{ op: "credit", credit }` — grant more bulk credit,
19
+ * - inbound `{ op: "subscribed", stream, gap, nextOffset }` — the resume ack
20
+ * (`gap: boolean` — the S5 wire flags whether chunks aged out),
21
+ * - inbound {@link RelayPayload} `{ stream, offset, chunk }` — a data chunk.
22
+ */
23
+ import type { RelayPayload } from "../protocol/index.ts";
24
+ /** The terminal sink the session writes decoded output to (xterm.js satisfies this). */
25
+ export interface TerminalSink {
26
+ /** Append a chunk of terminal output. */
27
+ write(chunk: string): void;
28
+ /** Tear down the underlying terminal widget and its listeners, if any. */
29
+ dispose?(): void;
30
+ }
31
+ /** An outbound relay message the session asks its transport to send. */
32
+ export type RelayOutbound = {
33
+ readonly op: "subscribe";
34
+ readonly stream: string;
35
+ readonly from: number;
36
+ readonly credit: number;
37
+ } | {
38
+ readonly op: "credit";
39
+ readonly credit: number;
40
+ };
41
+ /** An inbound relay message the session consumes (a data chunk or a resume ack). */
42
+ export type RelayInbound = RelayPayload | {
43
+ readonly op: "subscribed";
44
+ readonly stream: string;
45
+ readonly gap: boolean;
46
+ readonly nextOffset: number;
47
+ };
48
+ /** Sends one outbound relay message over the channel. */
49
+ export type RelaySend = (message: RelayOutbound) => void;
50
+ export interface TerminalSessionOptions {
51
+ /** The relay stream id (one worker's terminal). */
52
+ readonly stream: string;
53
+ /** Where decoded output is written. */
54
+ readonly sink: TerminalSink;
55
+ /** Emits outbound relay messages. */
56
+ readonly send: RelaySend;
57
+ /** Bulk credit requested on each (re)subscribe. Default 1024. */
58
+ readonly credit?: number;
59
+ /** Offset to resume from on first attach. Default 0 (from the start). */
60
+ readonly from?: number;
61
+ /** Notified when the resume ack reports a gap (chunks aged out of the ring). */
62
+ readonly onGap?: () => void;
63
+ }
64
+ /**
65
+ * A resume-from-offset consumer of one relay stream. Construct once per worker
66
+ * drill-in; call {@link attach} on every (re)connection and feed every inbound
67
+ * relay message to {@link handle}.
68
+ */
69
+ export declare class TerminalSession {
70
+ #private;
71
+ constructor(options: TerminalSessionOptions);
72
+ /** The stream id this session follows. */
73
+ get stream(): string;
74
+ /** The offset the session will resume from on the next {@link attach}. */
75
+ get nextOffset(): number;
76
+ /** Whether the most recent resume ack reported a gap (false when none was lost). */
77
+ get gap(): boolean;
78
+ /**
79
+ * (Re)subscribe to the stream, resuming from {@link nextOffset}. Call this on
80
+ * first connect AND after every reconnect — because it always resumes from the
81
+ * offset just past the last applied chunk, a reconnect replays only the
82
+ * un-applied tail (no loss) and re-delivered chunks below `nextOffset` are
83
+ * dropped by {@link handle} (no duplication).
84
+ */
85
+ attach(): void;
86
+ /** Grant additional bulk credit (backpressure release) mid-stream. */
87
+ grant(credit: number): void;
88
+ /**
89
+ * Process one inbound relay message. A data chunk at or beyond `nextOffset` is
90
+ * written and advances the resume point; a chunk below it (a duplicate replay
91
+ * after a reconnect) is dropped. A resume ack records any gap and clamps the
92
+ * resume point down to the hub's head when we are ahead of it.
93
+ */
94
+ handle(message: RelayInbound): void;
95
+ }
@@ -0,0 +1,123 @@
1
+ import { addSafeInt, isNonNegInt, isPosInt } from "../relay/index.js";
2
+ const DEFAULT_CREDIT = 1024;
3
+ function isRelayData(message) {
4
+ return !("op" in message);
5
+ }
6
+ /**
7
+ * A resume-from-offset consumer of one relay stream. Construct once per worker
8
+ * drill-in; call {@link attach} on every (re)connection and feed every inbound
9
+ * relay message to {@link handle}.
10
+ */
11
+ export class TerminalSession {
12
+ #stream;
13
+ #sink;
14
+ #send;
15
+ #credit;
16
+ #onGap;
17
+ /** The next offset we still need — everything below it has been applied. */
18
+ #nextOffset;
19
+ /** Whether the most recent resume ack reported a gap (retained window overrun). */
20
+ #gap = false;
21
+ constructor(options) {
22
+ this.#stream = options.stream;
23
+ this.#sink = options.sink;
24
+ this.#send = options.send;
25
+ this.#credit = options.credit ?? DEFAULT_CREDIT;
26
+ if (!isPosInt(this.#credit)) {
27
+ throw new RangeError(`TerminalSession.credit must be a positive safe integer, got ${this.#credit}`);
28
+ }
29
+ this.#onGap = options.onGap;
30
+ const from = options.from ?? 0;
31
+ if (!isNonNegInt(from)) {
32
+ throw new RangeError(`TerminalSession.from must be a non-negative safe integer, got ${from}`);
33
+ }
34
+ this.#nextOffset = from;
35
+ }
36
+ /** The stream id this session follows. */
37
+ get stream() {
38
+ return this.#stream;
39
+ }
40
+ /** The offset the session will resume from on the next {@link attach}. */
41
+ get nextOffset() {
42
+ return this.#nextOffset;
43
+ }
44
+ /** Whether the most recent resume ack reported a gap (false when none was lost). */
45
+ get gap() {
46
+ return this.#gap;
47
+ }
48
+ /**
49
+ * (Re)subscribe to the stream, resuming from {@link nextOffset}. Call this on
50
+ * first connect AND after every reconnect — because it always resumes from the
51
+ * offset just past the last applied chunk, a reconnect replays only the
52
+ * un-applied tail (no loss) and re-delivered chunks below `nextOffset` are
53
+ * dropped by {@link handle} (no duplication).
54
+ */
55
+ attach() {
56
+ this.#send({ op: "subscribe", stream: this.#stream, from: this.#nextOffset, credit: this.#credit });
57
+ }
58
+ /** Grant additional bulk credit (backpressure release) mid-stream. */
59
+ grant(credit) {
60
+ if (!isPosInt(credit)) {
61
+ throw new RangeError(`TerminalSession.grant credit must be a positive safe integer, got ${credit}`);
62
+ }
63
+ this.#send({ op: "credit", credit });
64
+ }
65
+ /**
66
+ * Process one inbound relay message. A data chunk at or beyond `nextOffset` is
67
+ * written and advances the resume point; a chunk below it (a duplicate replay
68
+ * after a reconnect) is dropped. A resume ack records any gap and clamps the
69
+ * resume point down to the hub's head when we are ahead of it.
70
+ */
71
+ handle(message) {
72
+ if (isRelayData(message)) {
73
+ this.#onData(message);
74
+ return;
75
+ }
76
+ if (message.op === "subscribed" && message.stream === this.#stream) {
77
+ this.#onSubscribed(message.gap, message.nextOffset);
78
+ }
79
+ }
80
+ #onData(data) {
81
+ if (data.stream !== this.#stream)
82
+ return;
83
+ // Idempotent apply: a reconnect resubscribes from nextOffset, so the hub may
84
+ // re-deliver the boundary chunk; anything we have already applied is dropped.
85
+ if (data.offset < this.#nextOffset)
86
+ return;
87
+ // Compute the next resume point BEFORE writing so the apply is atomic: a
88
+ // chunk at Number.MAX_SAFE_INTEGER makes addSafeInt throw, and it must throw
89
+ // before we touch the sink — otherwise the chunk is written but #nextOffset
90
+ // is not advanced, leaving a partially-applied state that re-delivers (and
91
+ // so duplicates) the chunk on reconnect. Advancing via addSafeInt also fails
92
+ // fast rather than overflowing into an unsafe nextOffset — that value would
93
+ // later be echoed in subscribe.from and lose precision on any JSON
94
+ // round-trip, silently corrupting resume semantics.
95
+ const nextOffset = addSafeInt(data.offset, 1, "nextOffset");
96
+ this.#sink.write(data.chunk);
97
+ this.#nextOffset = nextOffset;
98
+ }
99
+ #onSubscribed(gap, nextOffset) {
100
+ // Validate the ack's resume point exactly as the constructor validates
101
+ // `from`: `nextOffset` is echoed back into subscribe.from on the next
102
+ // attach, so a negative/NaN/unsafe value (from a buggy or malicious
103
+ // transport that bypasses RelayChannelClient's validation) would corrupt
104
+ // resume semantics or lose precision on a JSON round-trip. Fail fast.
105
+ if (!isNonNegInt(nextOffset)) {
106
+ throw new RangeError(`TerminalSession.subscribed nextOffset must be a non-negative safe integer, got ${nextOffset}`);
107
+ }
108
+ // Clamp our resume point down to the hub's current head when we are ahead of
109
+ // it. Without this, a hub restart/reset (or a `from` seeded past the head)
110
+ // leaves #nextOffset above every offset the hub will now emit, so #onData
111
+ // silently drops all fresh chunks until the stream catches back up — losing
112
+ // terminal output, possibly indefinitely. Only clamp DOWN: a head at or above
113
+ // #nextOffset is the normal case and must not skip un-applied chunks.
114
+ if (nextOffset < this.#nextOffset) {
115
+ this.#nextOffset = nextOffset;
116
+ }
117
+ // Record the gap on every ack so a later no-gap resume clears a prior gap.
118
+ this.#gap = gap;
119
+ if (gap) {
120
+ this.#onGap?.();
121
+ }
122
+ }
123
+ }
@@ -0,0 +1,79 @@
1
+ /**
2
+ * The cockpit view-model — S8.
3
+ *
4
+ * A pure, deterministic projection of the S4 {@link DemandSupplyReport} onto the
5
+ * shape the cockpit renders: a per-network demand×supply matrix, a red light per
6
+ * *missing agent type*, and the diversity-SLO state (from S3, carried through S4).
7
+ *
8
+ * It is deliberately framework-free and side-effect-free: the same report always
9
+ * yields the same {@link CockpitView}, so it is safe to diff frame-to-frame, to
10
+ * snapshot in a test, and to render identically whether the page is embedded in
11
+ * the console (App View) or served standalone — the view is the single source of
12
+ * truth both render paths consume.
13
+ */
14
+ import type { DemandSupplyReport, SloStatus } from "../demand/index.ts";
15
+ import type { DiversityReport } from "../vocab/index.ts";
16
+ export type { SloStatus } from "../demand/index.ts";
17
+ /** A three-state status light rendered as a coloured dot + label. */
18
+ export interface CockpitLight {
19
+ /** Stable id for the DOM node / keyed diff. */
20
+ readonly id: string;
21
+ /** Human label shown next to the dot. */
22
+ readonly label: string;
23
+ /** The grade the dot colours to. */
24
+ readonly status: SloStatus;
25
+ /** Optional one-line detail (tooltip / secondary text). */
26
+ readonly detail?: string;
27
+ }
28
+ /** One demanded routing token as a matrix cell. */
29
+ export interface CockpitTokenRow {
30
+ /** The demanded routing token. */
31
+ readonly token: string;
32
+ /** How many registered workers currently serve it. */
33
+ readonly supply: number;
34
+ /** The serving worker instances (provenance for the drill-in), sorted. */
35
+ readonly instances: readonly string[];
36
+ /** False when no worker serves it — a missing agent type. */
37
+ readonly satisfied: boolean;
38
+ /** GREEN when served, RED when missing. */
39
+ readonly status: SloStatus;
40
+ }
41
+ /** One network prefix as a matrix row. */
42
+ export interface CockpitNetworkRow {
43
+ /** The network prefix bucket. */
44
+ readonly network: string;
45
+ /** Every demanded token in the bucket, sorted by token. */
46
+ readonly tokens: readonly CockpitTokenRow[];
47
+ /** The demanded tokens in this bucket with zero supply, sorted. */
48
+ readonly missing: readonly string[];
49
+ /** RED when the row has any missing agent type, else GREEN. */
50
+ readonly status: SloStatus;
51
+ }
52
+ /** The full renderable cockpit view. */
53
+ export interface CockpitView {
54
+ /** The overall SLO state: worst of missing-agent and diversity. */
55
+ readonly status: SloStatus;
56
+ /** The demand×supply matrix, one row per network, sorted by network. */
57
+ readonly networks: readonly CockpitNetworkRow[];
58
+ /** Every missing agent type across all networks, sorted and de-duplicated. */
59
+ readonly missing: readonly string[];
60
+ /** One RED light per missing agent type (empty when nothing is missing). */
61
+ readonly missingLights: readonly CockpitLight[];
62
+ /** The S3 diversity report, carried through unchanged for detail rendering. */
63
+ readonly diversity: DiversityReport;
64
+ /** A single light summarising the diversity SLO. */
65
+ readonly diversityLight: CockpitLight;
66
+ /**
67
+ * Deployed task types that are not valid routing tokens (ordinary,
68
+ * non-agentic C8 jobs) — surfaced so an operator can spot a mistyped token,
69
+ * excluded from the agentic accounting.
70
+ */
71
+ readonly nonAgentic: readonly string[];
72
+ }
73
+ /**
74
+ * Derive the renderable cockpit view from an S4 demand×supply report.
75
+ *
76
+ * Pure and total: every list is already sorted by S4, so the derived view is
77
+ * stable and diff-friendly; no input mutates and no I/O happens.
78
+ */
79
+ export declare function cockpitView(report: DemandSupplyReport): CockpitView;