@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.
- package/README.md +22 -0
- package/dist/blackboard/family.d.ts +40 -0
- package/dist/blackboard/family.js +151 -0
- package/dist/blackboard/index.d.ts +21 -0
- package/dist/blackboard/index.js +19 -0
- package/dist/blackboard/schema.d.ts +30 -0
- package/dist/blackboard/schema.js +42 -0
- package/dist/blackboard/store.d.ts +138 -0
- package/dist/blackboard/store.js +216 -0
- package/dist/blackboard/test-db.d.ts +5 -0
- package/dist/blackboard/test-db.js +42 -0
- package/dist/channel/auth.d.ts +41 -0
- package/dist/channel/auth.js +67 -0
- package/dist/channel/clock.d.ts +11 -0
- package/dist/channel/clock.js +4 -0
- package/dist/channel/connection.d.ts +75 -0
- package/dist/channel/connection.js +14 -0
- package/dist/channel/dispatch.d.ts +46 -0
- package/dist/channel/dispatch.js +86 -0
- package/dist/channel/hub.d.ts +78 -0
- package/dist/channel/hub.js +157 -0
- package/dist/channel/index.d.ts +27 -0
- package/dist/channel/index.js +20 -0
- package/dist/channel/registry.d.ts +68 -0
- package/dist/channel/registry.js +84 -0
- package/dist/channel/ws-transport.d.ts +23 -0
- package/dist/channel/ws-transport.js +178 -0
- package/dist/cockpit/boot.d.ts +68 -0
- package/dist/cockpit/boot.js +202 -0
- package/dist/cockpit/fake-dom.d.ts +37 -0
- package/dist/cockpit/fake-dom.js +73 -0
- package/dist/cockpit/index.d.ts +27 -0
- package/dist/cockpit/index.js +27 -0
- package/dist/cockpit/relay-client.d.ts +52 -0
- package/dist/cockpit/relay-client.js +192 -0
- package/dist/cockpit/render.d.ts +58 -0
- package/dist/cockpit/render.js +122 -0
- package/dist/cockpit/terminal-session.d.ts +95 -0
- package/dist/cockpit/terminal-session.js +123 -0
- package/dist/cockpit/view.d.ts +79 -0
- package/dist/cockpit/view.js +58 -0
- package/dist/demand/c8-rest.d.ts +77 -0
- package/dist/demand/c8-rest.js +123 -0
- package/dist/demand/index.d.ts +24 -0
- package/dist/demand/index.js +24 -0
- package/dist/demand/model.d.ts +68 -0
- package/dist/demand/model.js +118 -0
- package/dist/demand/taskdef.d.ts +40 -0
- package/dist/demand/taskdef.js +67 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.js +17 -0
- package/dist/presence/family.d.ts +40 -0
- package/dist/presence/family.js +166 -0
- package/dist/presence/index.d.ts +19 -0
- package/dist/presence/index.js +17 -0
- package/dist/presence/schema.d.ts +20 -0
- package/dist/presence/schema.js +32 -0
- package/dist/presence/store.d.ts +130 -0
- package/dist/presence/store.js +191 -0
- package/dist/presence/test-db.d.ts +5 -0
- package/dist/presence/test-db.js +42 -0
- package/dist/protocol/conformance/frames.d.ts +24 -0
- package/dist/protocol/conformance/frames.js +116 -0
- package/dist/protocol/conformance/index.d.ts +13 -0
- package/dist/protocol/conformance/index.js +13 -0
- package/dist/protocol/conformance/malformed.d.ts +14 -0
- package/dist/protocol/conformance/malformed.js +44 -0
- package/dist/protocol/conformance/tokens.d.ts +19 -0
- package/dist/protocol/conformance/tokens.js +49 -0
- package/dist/protocol/conformance/vocab.d.ts +23 -0
- package/dist/protocol/conformance/vocab.js +97 -0
- package/dist/protocol/families.d.ts +32 -0
- package/dist/protocol/families.js +45 -0
- package/dist/protocol/frame.d.ts +45 -0
- package/dist/protocol/frame.js +114 -0
- package/dist/protocol/hex.d.ts +7 -0
- package/dist/protocol/hex.js +26 -0
- package/dist/protocol/index.d.ts +23 -0
- package/dist/protocol/index.js +23 -0
- package/dist/protocol/lanes.d.ts +36 -0
- package/dist/protocol/lanes.js +40 -0
- package/dist/protocol/payloads.d.ts +64 -0
- package/dist/protocol/payloads.js +122 -0
- package/dist/protocol/token.d.ts +34 -0
- package/dist/protocol/token.js +81 -0
- package/dist/protocol/vocab/schema.d.ts +51 -0
- package/dist/protocol/vocab/schema.js +218 -0
- package/dist/relay/incarnation.d.ts +17 -0
- package/dist/relay/incarnation.js +50 -0
- package/dist/relay/index.d.ts +25 -0
- package/dist/relay/index.js +22 -0
- package/dist/relay/relay-family.d.ts +68 -0
- package/dist/relay/relay-family.js +272 -0
- package/dist/relay/ring.d.ts +49 -0
- package/dist/relay/ring.js +105 -0
- package/dist/relay/scheduler.d.ts +72 -0
- package/dist/relay/scheduler.js +180 -0
- package/dist/relay/validate.d.ts +29 -0
- package/dist/relay/validate.js +39 -0
- package/dist/transcript/index.d.ts +18 -0
- package/dist/transcript/index.js +17 -0
- package/dist/transcript/schema.d.ts +32 -0
- package/dist/transcript/schema.js +48 -0
- package/dist/transcript/store.d.ts +192 -0
- package/dist/transcript/store.js +347 -0
- package/dist/transcript/test-db.d.ts +5 -0
- package/dist/transcript/test-db.js +41 -0
- package/dist/vocab/core-vocab.d.ts +26 -0
- package/dist/vocab/core-vocab.js +67 -0
- package/dist/vocab/diversity.d.ts +78 -0
- package/dist/vocab/diversity.js +89 -0
- package/dist/vocab/index.d.ts +22 -0
- package/dist/vocab/index.js +22 -0
- package/dist/vocab/merge.d.ts +9 -0
- package/dist/vocab/merge.js +104 -0
- package/dist/vocab/requires.d.ts +49 -0
- package/dist/vocab/requires.js +107 -0
- package/dist/vocab/resolver.d.ts +62 -0
- package/dist/vocab/resolver.js +149 -0
- package/dist/vocab/serve.d.ts +39 -0
- package/dist/vocab/serve.js +36 -0
- package/package.json +108 -0
- package/page/cockpit.css +114 -0
- package/page/cockpit.page.json +33 -0
- package/page/embed.html +40 -0
- package/page/mount.js +78 -0
- package/page/standalone.html +43 -0
- package/src/blackboard/family.test.ts +280 -0
- package/src/blackboard/family.ts +208 -0
- package/src/blackboard/index.ts +42 -0
- package/src/blackboard/schema.test.ts +60 -0
- package/src/blackboard/schema.ts +44 -0
- package/src/blackboard/store.test.ts +189 -0
- package/src/blackboard/store.ts +331 -0
- package/src/blackboard/test-db.ts +47 -0
- package/src/channel/auth.test.ts +64 -0
- package/src/channel/auth.ts +101 -0
- package/src/channel/clock.ts +14 -0
- package/src/channel/connection.ts +77 -0
- package/src/channel/dispatch.test.ts +83 -0
- package/src/channel/dispatch.ts +102 -0
- package/src/channel/hub.test.ts +335 -0
- package/src/channel/hub.ts +222 -0
- package/src/channel/index.ts +55 -0
- package/src/channel/registry.test.ts +73 -0
- package/src/channel/registry.ts +137 -0
- package/src/channel/ws-transport.test.ts +234 -0
- package/src/channel/ws-transport.ts +212 -0
- package/src/cockpit/boot.test.ts +374 -0
- package/src/cockpit/boot.ts +280 -0
- package/src/cockpit/fake-dom.ts +90 -0
- package/src/cockpit/index.ts +63 -0
- package/src/cockpit/relay-client.test.ts +359 -0
- package/src/cockpit/relay-client.ts +234 -0
- package/src/cockpit/render.test.ts +149 -0
- package/src/cockpit/render.ts +194 -0
- package/src/cockpit/terminal-session.test.ts +252 -0
- package/src/cockpit/terminal-session.ts +194 -0
- package/src/cockpit/view.test.ts +117 -0
- package/src/cockpit/view.ts +140 -0
- package/src/demand/c8-rest.test.ts +140 -0
- package/src/demand/c8-rest.ts +167 -0
- package/src/demand/index.ts +42 -0
- package/src/demand/model.test.ts +197 -0
- package/src/demand/model.ts +183 -0
- package/src/demand/taskdef.test.ts +85 -0
- package/src/demand/taskdef.ts +78 -0
- package/src/index.ts +17 -0
- package/src/presence/family.test.ts +252 -0
- package/src/presence/family.ts +205 -0
- package/src/presence/index.ts +26 -0
- package/src/presence/schema.test.ts +53 -0
- package/src/presence/schema.ts +34 -0
- package/src/presence/store.test.ts +190 -0
- package/src/presence/store.ts +287 -0
- package/src/presence/test-db.test.ts +57 -0
- package/src/presence/test-db.ts +47 -0
- package/src/protocol/conformance/corpus.test.ts +66 -0
- package/src/protocol/conformance/frames.ts +142 -0
- package/src/protocol/conformance/index.ts +29 -0
- package/src/protocol/conformance/malformed.ts +59 -0
- package/src/protocol/conformance/tokens.ts +70 -0
- package/src/protocol/conformance/vocab.ts +122 -0
- package/src/protocol/families.ts +54 -0
- package/src/protocol/frame.test.ts +116 -0
- package/src/protocol/frame.ts +171 -0
- package/src/protocol/hex.ts +28 -0
- package/src/protocol/index.ts +84 -0
- package/src/protocol/lanes.test.ts +82 -0
- package/src/protocol/lanes.ts +54 -0
- package/src/protocol/payloads.test.ts +91 -0
- package/src/protocol/payloads.ts +201 -0
- package/src/protocol/token.test.ts +57 -0
- package/src/protocol/token.ts +123 -0
- package/src/protocol/vocab/schema.test.ts +67 -0
- package/src/protocol/vocab/schema.ts +281 -0
- package/src/relay/incarnation.test.ts +53 -0
- package/src/relay/incarnation.ts +54 -0
- package/src/relay/index.ts +34 -0
- package/src/relay/integration.test.ts +135 -0
- package/src/relay/relay-family.test.ts +236 -0
- package/src/relay/relay-family.ts +336 -0
- package/src/relay/ring.test.ts +138 -0
- package/src/relay/ring.ts +136 -0
- package/src/relay/scheduler.test.ts +233 -0
- package/src/relay/scheduler.ts +208 -0
- package/src/relay/validate.test.ts +43 -0
- package/src/relay/validate.ts +44 -0
- package/src/transcript/index.ts +33 -0
- package/src/transcript/integration.test.ts +108 -0
- package/src/transcript/schema.test.ts +69 -0
- package/src/transcript/schema.ts +51 -0
- package/src/transcript/store.test.ts +285 -0
- package/src/transcript/store.ts +530 -0
- package/src/transcript/test-db.ts +46 -0
- package/src/vocab/core-vocab.test.ts +34 -0
- package/src/vocab/core-vocab.ts +88 -0
- package/src/vocab/diversity.test.ts +153 -0
- package/src/vocab/diversity.ts +169 -0
- package/src/vocab/index.ts +55 -0
- package/src/vocab/merge.test.ts +73 -0
- package/src/vocab/merge.ts +117 -0
- package/src/vocab/requires.test.ts +69 -0
- package/src/vocab/requires.ts +155 -0
- package/src/vocab/resolver.test.ts +118 -0
- package/src/vocab/resolver.ts +187 -0
- package/src/vocab/serve.test.ts +64 -0
- package/src/vocab/serve.ts +66 -0
|
@@ -0,0 +1,234 @@
|
|
|
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.ts";
|
|
20
|
+
import { decodeFrame, encodeFrame, MAX_SEQ } from "../protocol/index.ts";
|
|
21
|
+
import type { Frame, RelayPayload } from "../protocol/index.ts";
|
|
22
|
+
import type { RelayInbound, RelayOutbound } from "./terminal-session.ts";
|
|
23
|
+
|
|
24
|
+
/** The minimal socket surface the client drives (a browser `WebSocket` adapts to this). */
|
|
25
|
+
export interface RawSocket {
|
|
26
|
+
/** Send one binary frame. */
|
|
27
|
+
send(bytes: Uint8Array): void;
|
|
28
|
+
/** Close the socket. */
|
|
29
|
+
close(): void;
|
|
30
|
+
/** Register the inbound-binary-frame listener. */
|
|
31
|
+
onMessage(listener: (bytes: Uint8Array) => void): void;
|
|
32
|
+
/** Register the open listener (fired once per successful connect). */
|
|
33
|
+
onOpen(listener: () => void): void;
|
|
34
|
+
/** Register the close listener (fired once when the socket drops). */
|
|
35
|
+
onClose(listener: () => void): void;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Opens a fresh socket. Called once per (re)connect. */
|
|
39
|
+
export type SocketFactory = () => RawSocket;
|
|
40
|
+
|
|
41
|
+
/** Schedules a reconnect attempt. Injected so tests can run it synchronously. */
|
|
42
|
+
export type Scheduler = (run: () => void) => void;
|
|
43
|
+
|
|
44
|
+
export interface RelayChannelClientOptions {
|
|
45
|
+
/** Opens a fresh socket for each (re)connect. */
|
|
46
|
+
readonly connect: SocketFactory;
|
|
47
|
+
/** Receives every inbound `relay`-family payload (data chunk or resume ack). */
|
|
48
|
+
readonly onRelay: (message: RelayInbound) => void;
|
|
49
|
+
/** Fired on every (re)connect — the caller re-attaches the session here. */
|
|
50
|
+
readonly onOpen?: () => void;
|
|
51
|
+
/** Fired when the socket drops (before a reconnect is scheduled). */
|
|
52
|
+
readonly onClose?: () => void;
|
|
53
|
+
/** Notified of a decode/dispatch error on an inbound frame. */
|
|
54
|
+
readonly onError?: (err: unknown) => void;
|
|
55
|
+
/** Reconnect scheduler. Default `setTimeout(run, 0)`. */
|
|
56
|
+
readonly schedule?: Scheduler;
|
|
57
|
+
/** Reconnect automatically on close. Default true. */
|
|
58
|
+
readonly autoReconnect?: boolean;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const CONTROL_LANE: Frame["lane"] = "control";
|
|
62
|
+
|
|
63
|
+
function defaultSchedule(run: () => void): void {
|
|
64
|
+
setTimeout(run, 0);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
68
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Narrow a decoded `relay`-family payload to the inbound sub-protocol without an
|
|
73
|
+
* unchecked cast (the repo bans `as`): a data chunk `{ stream, offset, chunk }`
|
|
74
|
+
* or a resume ack `{ op: "subscribed", stream, gap, nextOffset }`. Any payload
|
|
75
|
+
* carrying an `op` field is treated as an op-message and rejected unless it is a
|
|
76
|
+
* well-formed known op, so a malformed/unknown op can never fall through and be
|
|
77
|
+
* mistaken for a data chunk.
|
|
78
|
+
*/
|
|
79
|
+
function asRelayInbound(payload: unknown): RelayInbound | null {
|
|
80
|
+
if (!isRecord(payload)) return null;
|
|
81
|
+
if ("op" in payload) {
|
|
82
|
+
if (
|
|
83
|
+
payload.op === "subscribed" &&
|
|
84
|
+
typeof payload.stream === "string" &&
|
|
85
|
+
typeof payload.gap === "boolean" &&
|
|
86
|
+
isNonNegInt(payload.nextOffset)
|
|
87
|
+
) {
|
|
88
|
+
return { op: "subscribed", stream: payload.stream, gap: payload.gap, nextOffset: payload.nextOffset };
|
|
89
|
+
}
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
if (typeof payload.stream === "string" && isNonNegInt(payload.offset) && typeof payload.chunk === "string") {
|
|
93
|
+
const data: RelayPayload = { stream: payload.stream, offset: payload.offset, chunk: payload.chunk };
|
|
94
|
+
return data;
|
|
95
|
+
}
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Manages one relay socket with automatic resume-on-reconnect. Construct once
|
|
101
|
+
* per drill-in and pair with a {@link TerminalSession}: wire the session's
|
|
102
|
+
* `send` to {@link sendRelay}, feed {@link RelayChannelClientOptions.onRelay} to
|
|
103
|
+
* `session.handle`, and call `session.attach()` from `onOpen`.
|
|
104
|
+
*/
|
|
105
|
+
export class RelayChannelClient {
|
|
106
|
+
readonly #connect: SocketFactory;
|
|
107
|
+
readonly #onRelay: RelayChannelClientOptions["onRelay"];
|
|
108
|
+
readonly #onOpen: RelayChannelClientOptions["onOpen"];
|
|
109
|
+
readonly #onClose: RelayChannelClientOptions["onClose"];
|
|
110
|
+
readonly #onError: RelayChannelClientOptions["onError"];
|
|
111
|
+
readonly #schedule: Scheduler;
|
|
112
|
+
readonly #autoReconnect: boolean;
|
|
113
|
+
#socket: RawSocket | undefined;
|
|
114
|
+
#seq = 0;
|
|
115
|
+
#closed = false;
|
|
116
|
+
|
|
117
|
+
constructor(options: RelayChannelClientOptions) {
|
|
118
|
+
this.#connect = options.connect;
|
|
119
|
+
this.#onRelay = options.onRelay;
|
|
120
|
+
this.#onOpen = options.onOpen;
|
|
121
|
+
this.#onClose = options.onClose;
|
|
122
|
+
this.#onError = options.onError;
|
|
123
|
+
this.#schedule = options.schedule ?? defaultSchedule;
|
|
124
|
+
this.#autoReconnect = options.autoReconnect ?? true;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** True once {@link close} has been called (no further reconnects). */
|
|
128
|
+
get isClosed(): boolean {
|
|
129
|
+
return this.#closed;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Open the first socket and wire its lifecycle. Idempotent while connected. */
|
|
133
|
+
open(): void {
|
|
134
|
+
if (this.#closed || this.#socket !== undefined) return;
|
|
135
|
+
// Guard the injected factory: connect() can throw synchronously (e.g. the
|
|
136
|
+
// browser adapter's WebSocket constructor on an invalid URL / CSP block).
|
|
137
|
+
// An unguarded throw here escapes open()/#reconnect() and kills the
|
|
138
|
+
// reconnect→resume loop with no error surfaced. Route it to onError and, if
|
|
139
|
+
// autoReconnect is enabled, schedule a retry so the loop survives a
|
|
140
|
+
// transient connect failure — the same way #handleClose recovers a drop.
|
|
141
|
+
let socket: RawSocket;
|
|
142
|
+
try {
|
|
143
|
+
socket = this.#connect();
|
|
144
|
+
} catch (err) {
|
|
145
|
+
this.#onError?.(err);
|
|
146
|
+
if (!this.#closed && this.#autoReconnect) this.#schedule(() => this.#reconnect());
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
this.#socket = socket;
|
|
150
|
+
socket.onMessage((bytes) => this.#receive(bytes));
|
|
151
|
+
// Guard the consumer callback the same way #receive() does: a consumer
|
|
152
|
+
// onOpen handler can throw, and an unguarded throw escapes the socket open
|
|
153
|
+
// callback. Route it to onError so a bad handler can't wedge the client.
|
|
154
|
+
socket.onOpen(() => {
|
|
155
|
+
try {
|
|
156
|
+
this.#onOpen?.();
|
|
157
|
+
} catch (err) {
|
|
158
|
+
this.#onError?.(err);
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
socket.onClose(() => this.#handleClose());
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Encode and send one outbound relay message on the control lane. */
|
|
165
|
+
sendRelay(message: RelayOutbound): void {
|
|
166
|
+
const socket = this.#socket;
|
|
167
|
+
if (socket === undefined) return;
|
|
168
|
+
const frame: Frame = { lane: CONTROL_LANE, family: RELAY_FAMILY, seq: this.#nextSeq(), payload: message };
|
|
169
|
+
try {
|
|
170
|
+
socket.send(encodeFrame(frame));
|
|
171
|
+
} catch (err) {
|
|
172
|
+
this.#onError?.(err);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Close for good — no reconnect will follow. */
|
|
177
|
+
close(): void {
|
|
178
|
+
this.#closed = true;
|
|
179
|
+
const socket = this.#socket;
|
|
180
|
+
this.#socket = undefined;
|
|
181
|
+
socket?.close();
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
#nextSeq(): number {
|
|
185
|
+
const seq = this.#seq;
|
|
186
|
+
this.#seq = this.#seq >= MAX_SEQ ? 0 : this.#seq + 1;
|
|
187
|
+
return seq;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
#receive(bytes: Uint8Array): void {
|
|
191
|
+
let frame: Frame;
|
|
192
|
+
try {
|
|
193
|
+
frame = decodeFrame(bytes);
|
|
194
|
+
} catch (err) {
|
|
195
|
+
this.#onError?.(err);
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
if (frame.family !== RELAY_FAMILY) return;
|
|
199
|
+
const message = asRelayInbound(frame.payload);
|
|
200
|
+
if (message === null) {
|
|
201
|
+
this.#onError?.(new Error("malformed relay payload"));
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
// Guard the consumer the same way decode/malformed errors are guarded: a
|
|
205
|
+
// handler (e.g. TerminalSession.handle) can throw on an unsafe offset via
|
|
206
|
+
// addSafeInt, and an unguarded throw here escapes the socket message
|
|
207
|
+
// callback and can wedge the relay client / page. Route it to onError.
|
|
208
|
+
try {
|
|
209
|
+
this.#onRelay(message);
|
|
210
|
+
} catch (err) {
|
|
211
|
+
this.#onError?.(err);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
#handleClose(): void {
|
|
216
|
+
this.#socket = undefined;
|
|
217
|
+
// Guard the consumer onClose: if it throws, an unguarded exception here
|
|
218
|
+
// skips the reconnect scheduling below and breaks the "survives reconnect"
|
|
219
|
+
// invariant for reasons unrelated to transport. Route it to onError, then
|
|
220
|
+
// continue with reconnect regardless.
|
|
221
|
+
try {
|
|
222
|
+
this.#onClose?.();
|
|
223
|
+
} catch (err) {
|
|
224
|
+
this.#onError?.(err);
|
|
225
|
+
}
|
|
226
|
+
if (this.#closed || !this.#autoReconnect) return;
|
|
227
|
+
this.#schedule(() => this.#reconnect());
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
#reconnect(): void {
|
|
231
|
+
if (this.#closed || this.#socket !== undefined) return;
|
|
232
|
+
this.open();
|
|
233
|
+
}
|
|
234
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { test } from "node:test";
|
|
3
|
+
|
|
4
|
+
import { FakeDocument, FakeElement } from "./fake-dom.ts";
|
|
5
|
+
import { renderCockpit } from "./render.ts";
|
|
6
|
+
import { type CockpitView, cockpitView } from "./view.ts";
|
|
7
|
+
|
|
8
|
+
function view(): CockpitView {
|
|
9
|
+
return cockpitView({
|
|
10
|
+
status: "red",
|
|
11
|
+
missing: ["planning.plan#blue"],
|
|
12
|
+
networks: [
|
|
13
|
+
{
|
|
14
|
+
network: "ci",
|
|
15
|
+
tokens: [{ token: "ci.build", supply: 2, instances: ["ci-a", "ci-b"], satisfied: true }],
|
|
16
|
+
missing: [],
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
network: "planning",
|
|
20
|
+
tokens: [
|
|
21
|
+
{ token: "planning.plan#blue", supply: 0, instances: [], satisfied: false },
|
|
22
|
+
{ token: "planning.plan#red", supply: 1, instances: ["plan-a"], satisfied: true },
|
|
23
|
+
],
|
|
24
|
+
missing: ["planning.plan#blue"],
|
|
25
|
+
},
|
|
26
|
+
],
|
|
27
|
+
diversity: { status: "green", roles: [] },
|
|
28
|
+
nonAgentic: ["send-email"],
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
test("renders one section per network with a demand×supply row per token", () => {
|
|
33
|
+
const doc = new FakeDocument();
|
|
34
|
+
const host = new FakeElement("body");
|
|
35
|
+
renderCockpit(host, doc, view());
|
|
36
|
+
|
|
37
|
+
const networks = host.byClass("cockpit-network");
|
|
38
|
+
assert.deepEqual(
|
|
39
|
+
networks.map((n) => n.getAttribute("data-network")),
|
|
40
|
+
["ci", "planning"],
|
|
41
|
+
);
|
|
42
|
+
const tokens = host.byClass("cockpit-token");
|
|
43
|
+
assert.deepEqual(
|
|
44
|
+
tokens.map((t) => t.getAttribute("data-token")),
|
|
45
|
+
["ci.build", "planning.plan#blue", "planning.plan#red"],
|
|
46
|
+
);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("token tables use valid <thead>/<tbody> structure (rows are never direct <table> children)", () => {
|
|
50
|
+
const doc = new FakeDocument();
|
|
51
|
+
const host = new FakeElement("body");
|
|
52
|
+
renderCockpit(host, doc, view());
|
|
53
|
+
|
|
54
|
+
const tables = host.byClass("cockpit-tokens");
|
|
55
|
+
assert.ok(tables.length >= 1, "at least one token table is rendered");
|
|
56
|
+
for (const table of tables) {
|
|
57
|
+
// A <table> must only contain section elements (<thead>/<tbody>), never a bare <tr>.
|
|
58
|
+
assert.deepEqual(
|
|
59
|
+
table.children.map((c) => c.tagName),
|
|
60
|
+
["thead", "tbody"],
|
|
61
|
+
"table children are the section wrappers, not rows",
|
|
62
|
+
);
|
|
63
|
+
const [thead, tbody] = table.children;
|
|
64
|
+
assert.deepEqual(
|
|
65
|
+
thead?.children.map((c) => c.tagName),
|
|
66
|
+
["tr"],
|
|
67
|
+
"the header row lives inside <thead>",
|
|
68
|
+
);
|
|
69
|
+
for (const row of tbody?.children ?? []) {
|
|
70
|
+
assert.equal(row.tagName, "tr", "every data row lives inside <tbody>");
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("a missing agent type renders a RED token, a RED network and a red missing light", () => {
|
|
76
|
+
const doc = new FakeDocument();
|
|
77
|
+
const host = new FakeElement("body");
|
|
78
|
+
renderCockpit(host, doc, view());
|
|
79
|
+
|
|
80
|
+
const missingTok = host.byData("token", "planning.plan#blue")[0];
|
|
81
|
+
assert.equal(missingTok?.getAttribute("data-status"), "red");
|
|
82
|
+
const planning = host.byData("network", "planning")[0];
|
|
83
|
+
assert.equal(planning?.getAttribute("data-status"), "red");
|
|
84
|
+
|
|
85
|
+
const lights = host.byClass("cockpit-light").filter((l) => l.getAttribute("data-light-id")?.startsWith("missing:"));
|
|
86
|
+
assert.equal(lights.length, 1);
|
|
87
|
+
assert.equal(lights[0]?.getAttribute("data-status"), "red");
|
|
88
|
+
assert.match(lights[0]?.text() ?? "", /planning\.plan#blue/);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("the overall status and diversity light render in the header", () => {
|
|
92
|
+
const doc = new FakeDocument();
|
|
93
|
+
const host = new FakeElement("body");
|
|
94
|
+
renderCockpit(host, doc, view());
|
|
95
|
+
assert.equal(host.byClass("cockpit")[0]?.getAttribute("data-status"), "red");
|
|
96
|
+
const badge = host.byData("badge-id", "overall")[0];
|
|
97
|
+
assert.equal(badge?.getAttribute("data-status"), "red");
|
|
98
|
+
const diversity = host.byData("light-id", "diversity")[0];
|
|
99
|
+
assert.equal(diversity?.getAttribute("data-status"), "green");
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("clicking a worker chip drills into that instance's relay stream", () => {
|
|
103
|
+
const doc = new FakeDocument();
|
|
104
|
+
const host = new FakeElement("body");
|
|
105
|
+
const drilled: string[] = [];
|
|
106
|
+
renderCockpit(host, doc, view(), { onDrill: (stream) => drilled.push(stream) });
|
|
107
|
+
|
|
108
|
+
const chip = host.byData("stream", "ci-a")[0];
|
|
109
|
+
assert.ok(chip instanceof FakeElement);
|
|
110
|
+
chip.dispatch("click");
|
|
111
|
+
assert.deepEqual(drilled, ["ci-a"]);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("re-rendering replaces prior content (idempotent refresh)", () => {
|
|
115
|
+
const doc = new FakeDocument();
|
|
116
|
+
const host = new FakeElement("body");
|
|
117
|
+
renderCockpit(host, doc, view());
|
|
118
|
+
renderCockpit(host, doc, view());
|
|
119
|
+
assert.equal(host.children.length, 1, "host holds exactly one cockpit root after re-render");
|
|
120
|
+
assert.equal(host.byClass("cockpit-network").length, 2);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("embedded and standalone render identically — same view, same host subset ⇒ same DOM", () => {
|
|
124
|
+
const doc = new FakeDocument();
|
|
125
|
+
const standaloneHost = new FakeElement("body");
|
|
126
|
+
const embeddedHost = new FakeElement("div");
|
|
127
|
+
const v = view();
|
|
128
|
+
renderCockpit(standaloneHost, doc, v);
|
|
129
|
+
renderCockpit(embeddedHost, doc, v);
|
|
130
|
+
|
|
131
|
+
const shape = (root: FakeElement): unknown => ({
|
|
132
|
+
tag: root.tagName,
|
|
133
|
+
cls: root.className,
|
|
134
|
+
attrs: [...root.attributes.entries()].sort(),
|
|
135
|
+
text: root.textContent,
|
|
136
|
+
children: root.children.map(shape),
|
|
137
|
+
});
|
|
138
|
+
const standaloneRoot = standaloneHost.children[0];
|
|
139
|
+
const embeddedRoot = embeddedHost.children[0];
|
|
140
|
+
assert.ok(standaloneRoot !== undefined && embeddedRoot !== undefined);
|
|
141
|
+
assert.deepEqual(shape(standaloneRoot), shape(embeddedRoot));
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test("non-agentic task types render in a footer", () => {
|
|
145
|
+
const doc = new FakeDocument();
|
|
146
|
+
const host = new FakeElement("body");
|
|
147
|
+
renderCockpit(host, doc, view());
|
|
148
|
+
assert.match(host.byClass("cockpit-nonagentic")[0]?.text() ?? "", /send-email/);
|
|
149
|
+
});
|
|
@@ -0,0 +1,194 @@
|
|
|
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 { CockpitLight, CockpitView, SloStatus } from "./view.ts";
|
|
32
|
+
|
|
33
|
+
/** The minimal element surface the renderer builds against (the in-memory fake, and the real DOM at runtime, satisfy it — see file header). */
|
|
34
|
+
export interface ElementLike {
|
|
35
|
+
className: string;
|
|
36
|
+
textContent: string | null;
|
|
37
|
+
setAttribute(name: string, value: string): void;
|
|
38
|
+
appendChild(child: ElementLike): ElementLike;
|
|
39
|
+
replaceChildren(): void;
|
|
40
|
+
addEventListener(type: string, handler: () => void): void;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The minimal document surface the renderer builds against (the in-memory fake, and the real DOM at runtime, satisfy it — see file header). */
|
|
44
|
+
export interface DocumentLike {
|
|
45
|
+
createElement(tagName: string): ElementLike;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface RenderOptions {
|
|
49
|
+
/** Called with a worker instance's relay stream id when the operator drills in. */
|
|
50
|
+
readonly onDrill?: (stream: string) => void;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Handles into the rendered tree the caller may need. */
|
|
54
|
+
export interface CockpitDom {
|
|
55
|
+
/** The freshly built root the view was rendered into. */
|
|
56
|
+
readonly root: ElementLike;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function el(doc: DocumentLike, tag: string, className?: string, text?: string): ElementLike {
|
|
60
|
+
const node = doc.createElement(tag);
|
|
61
|
+
if (className !== undefined) node.className = className;
|
|
62
|
+
if (text !== undefined) node.textContent = text;
|
|
63
|
+
return node;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function lightNode(doc: DocumentLike, light: CockpitLight): ElementLike {
|
|
67
|
+
const row = el(doc, "div", "cockpit-light");
|
|
68
|
+
row.setAttribute("data-status", light.status);
|
|
69
|
+
row.setAttribute("data-light-id", light.id);
|
|
70
|
+
const dot = el(doc, "span", "cockpit-dot");
|
|
71
|
+
dot.setAttribute("data-status", light.status);
|
|
72
|
+
row.appendChild(dot);
|
|
73
|
+
row.appendChild(el(doc, "span", "cockpit-light-label", light.label));
|
|
74
|
+
if (light.detail !== undefined) {
|
|
75
|
+
const detail = el(doc, "span", "cockpit-light-detail", light.detail);
|
|
76
|
+
detail.setAttribute("title", light.detail);
|
|
77
|
+
row.appendChild(detail);
|
|
78
|
+
}
|
|
79
|
+
return row;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function statusBadge(doc: DocumentLike, id: string, label: string, status: SloStatus): ElementLike {
|
|
83
|
+
const badge = el(doc, "div", "cockpit-status", `${label}: ${status}`);
|
|
84
|
+
badge.setAttribute("data-status", status);
|
|
85
|
+
badge.setAttribute("data-badge-id", id);
|
|
86
|
+
return badge;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function tokenCell(doc: DocumentLike, network: CockpitView["networks"][number], options: RenderOptions): ElementLike {
|
|
90
|
+
const table = el(doc, "table", "cockpit-tokens");
|
|
91
|
+
const thead = el(doc, "thead", "cockpit-tokens-thead");
|
|
92
|
+
const head = el(doc, "tr", "cockpit-tokens-head");
|
|
93
|
+
head.appendChild(el(doc, "th", "cockpit-th", "token"));
|
|
94
|
+
head.appendChild(el(doc, "th", "cockpit-th", "supply"));
|
|
95
|
+
head.appendChild(el(doc, "th", "cockpit-th", "workers"));
|
|
96
|
+
thead.appendChild(head);
|
|
97
|
+
table.appendChild(thead);
|
|
98
|
+
const tbody = el(doc, "tbody", "cockpit-tokens-tbody");
|
|
99
|
+
for (const token of network.tokens) {
|
|
100
|
+
const row = el(doc, "tr", "cockpit-token");
|
|
101
|
+
row.setAttribute("data-status", token.status);
|
|
102
|
+
row.setAttribute("data-token", token.token);
|
|
103
|
+
const dot = el(doc, "td", "cockpit-td");
|
|
104
|
+
const light = el(doc, "span", "cockpit-dot");
|
|
105
|
+
light.setAttribute("data-status", token.status);
|
|
106
|
+
dot.appendChild(light);
|
|
107
|
+
dot.appendChild(el(doc, "span", "cockpit-token-name", token.token));
|
|
108
|
+
row.appendChild(dot);
|
|
109
|
+
row.appendChild(el(doc, "td", "cockpit-td", String(token.supply)));
|
|
110
|
+
const workers = el(doc, "td", "cockpit-td cockpit-workers");
|
|
111
|
+
for (const instance of token.instances) {
|
|
112
|
+
const chip = el(doc, "button", "cockpit-worker", instance);
|
|
113
|
+
chip.setAttribute("data-stream", instance);
|
|
114
|
+
chip.setAttribute("type", "button");
|
|
115
|
+
const onDrill = options.onDrill;
|
|
116
|
+
if (onDrill !== undefined) {
|
|
117
|
+
chip.addEventListener("click", () => onDrill(instance));
|
|
118
|
+
}
|
|
119
|
+
workers.appendChild(chip);
|
|
120
|
+
}
|
|
121
|
+
row.appendChild(workers);
|
|
122
|
+
tbody.appendChild(row);
|
|
123
|
+
}
|
|
124
|
+
table.appendChild(tbody);
|
|
125
|
+
return table;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function networkSection(doc: DocumentLike, network: CockpitView["networks"][number], options: RenderOptions): ElementLike {
|
|
129
|
+
const section = el(doc, "section", "cockpit-network");
|
|
130
|
+
section.setAttribute("data-status", network.status);
|
|
131
|
+
section.setAttribute("data-network", network.network);
|
|
132
|
+
const header = el(doc, "div", "cockpit-network-head");
|
|
133
|
+
const dot = el(doc, "span", "cockpit-dot");
|
|
134
|
+
dot.setAttribute("data-status", network.status);
|
|
135
|
+
header.appendChild(dot);
|
|
136
|
+
header.appendChild(el(doc, "span", "cockpit-network-name", network.network));
|
|
137
|
+
if (network.missing.length > 0) {
|
|
138
|
+
header.appendChild(el(doc, "span", "cockpit-network-missing", `missing: ${network.missing.join(", ")}`));
|
|
139
|
+
}
|
|
140
|
+
section.appendChild(header);
|
|
141
|
+
section.appendChild(tokenCell(doc, network, options));
|
|
142
|
+
return section;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function missingPanel(doc: DocumentLike, view: CockpitView): ElementLike {
|
|
146
|
+
const panel = el(doc, "aside", "cockpit-missing");
|
|
147
|
+
panel.setAttribute("data-empty", view.missingLights.length === 0 ? "true" : "false");
|
|
148
|
+
panel.appendChild(el(doc, "h2", "cockpit-panel-title", "Missing agent types"));
|
|
149
|
+
if (view.missingLights.length === 0) {
|
|
150
|
+
panel.appendChild(el(doc, "div", "cockpit-missing-none", "none — every demanded token is served"));
|
|
151
|
+
return panel;
|
|
152
|
+
}
|
|
153
|
+
for (const light of view.missingLights) {
|
|
154
|
+
panel.appendChild(lightNode(doc, light));
|
|
155
|
+
}
|
|
156
|
+
return panel;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Render `view` into `host`, replacing whatever was there. Idempotent: call it
|
|
161
|
+
* again on every refresh to reflect the latest demand×supply snapshot.
|
|
162
|
+
*/
|
|
163
|
+
export function renderCockpit(
|
|
164
|
+
host: ElementLike,
|
|
165
|
+
doc: DocumentLike,
|
|
166
|
+
view: CockpitView,
|
|
167
|
+
options: RenderOptions = {},
|
|
168
|
+
): CockpitDom {
|
|
169
|
+
host.replaceChildren();
|
|
170
|
+
const root = el(doc, "div", "cockpit");
|
|
171
|
+
root.setAttribute("data-status", view.status);
|
|
172
|
+
|
|
173
|
+
const header = el(doc, "header", "cockpit-header");
|
|
174
|
+
header.appendChild(el(doc, "h1", "cockpit-title", "Agent networks — the cockpit"));
|
|
175
|
+
header.appendChild(statusBadge(doc, "overall", "overall", view.status));
|
|
176
|
+
header.appendChild(lightNode(doc, view.diversityLight));
|
|
177
|
+
root.appendChild(header);
|
|
178
|
+
|
|
179
|
+
root.appendChild(missingPanel(doc, view));
|
|
180
|
+
|
|
181
|
+
const matrix = el(doc, "div", "cockpit-matrix");
|
|
182
|
+
for (const network of view.networks) {
|
|
183
|
+
matrix.appendChild(networkSection(doc, network, options));
|
|
184
|
+
}
|
|
185
|
+
root.appendChild(matrix);
|
|
186
|
+
|
|
187
|
+
if (view.nonAgentic.length > 0) {
|
|
188
|
+
const footer = el(doc, "footer", "cockpit-nonagentic", `non-agentic task types: ${view.nonAgentic.join(", ")}`);
|
|
189
|
+
root.appendChild(footer);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
host.appendChild(root);
|
|
193
|
+
return { root };
|
|
194
|
+
}
|